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
|
||||||
API_BASE=http://127.0.0.1:8000
|
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 (for gitea_inventory.py)
|
||||||
GITEA_URL=http://92.205.130.254:32166
|
GITEA_URL=http://92.205.130.254:32166
|
||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
|
|
|
||||||
14
AGENTS.md
14
AGENTS.md
|
|
@ -13,8 +13,10 @@
|
||||||
|
|
||||||
## State Hub Integration
|
## State Hub Integration
|
||||||
|
|
||||||
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and
|
||||||
there is no MCP server for Codex agents.
|
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 |
|
| 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
|
unreachable. Treat that as pending local evidence, then ask the operator to run
|
||||||
statehub outbox status/replay after connectivity returns.
|
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
|
### Orient at session start
|
||||||
|
|
||||||
```bash
|
```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,
|
> This file explains why State Hub exists, where its authority begins and ends,
|
||||||
> and why it is now separate from the-custodian.
|
> 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
|
## 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
|
**Narrow authority.** State Hub coordinates and exposes state; it does not make
|
||||||
irreversible human decisions or become the owner of every adjacent system.
|
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
|
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
|
|
@ -180,6 +180,14 @@ register-mcp:
|
||||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||||
$(if $(DRY_RUN),--dry-run,)
|
$(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 a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing"
|
||||||
bootstrap-env:
|
bootstrap-env:
|
||||||
scripts/bootstrap-env.sh $(ARGS)
|
scripts/bootstrap-env.sh $(ARGS)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| workplan | ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md |
|
| 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-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-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-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||||
| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.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-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-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-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-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-T01 | done | — | workplans/ADHOC-2026-07-01.md |
|
||||||
| task | ADHOC-2026-07-01-T02 | 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-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-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||||
| task | CUST-WP-0003-T03 | 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-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-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-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-T01 | done | — | 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-T02 | done | — | 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-T03 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class Settings(BaseSettings):
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
state_hub_report_dir: str = "reports/recently-on-scope"
|
state_hub_report_dir: str = "reports/recently-on-scope"
|
||||||
state_hub_markitect_cli_path: str | None = None
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
|
||||||
from api.database import engine
|
from api.database import engine
|
||||||
from api.events import shutdown_publisher
|
from api.events import shutdown_publisher
|
||||||
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
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 domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||||
from api.routers import token_events
|
from api.routers import token_events
|
||||||
from api.routers import interface_changes
|
from api.routers import interface_changes
|
||||||
|
|
@ -135,6 +135,7 @@ app.include_router(execution.router)
|
||||||
app.include_router(fabric.router)
|
app.include_router(fabric.router)
|
||||||
app.include_router(legacy_meter.router)
|
app.include_router(legacy_meter.router)
|
||||||
app.include_router(state.router)
|
app.include_router(state.router)
|
||||||
|
app.include_router(ops_runs.router)
|
||||||
app.include_router(policy.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,
|
get_summary_cache,
|
||||||
register_summary_cache_invalidation,
|
register_summary_cache_invalidation,
|
||||||
)
|
)
|
||||||
|
from api.services.ops_run_projection import get_ops_run_projection
|
||||||
|
|
||||||
|
|
||||||
def _dual_workplan_refs(
|
def _dual_workplan_refs(
|
||||||
|
|
@ -111,22 +112,22 @@ async def get_summary(
|
||||||
|
|
||||||
if cache_status == "hit-revision" and cached is not None:
|
if cache_status == "hit-revision" and cached is not None:
|
||||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
_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:
|
if cache_status == "progress-section" and cached is not None:
|
||||||
result = await apply_progress_section(session, cached, revision)
|
result = await apply_progress_section(session, cached, revision)
|
||||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
_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:
|
if cache_status == "stale" and cached is not None:
|
||||||
cache.schedule_refresh(revision)
|
cache.schedule_refresh(revision)
|
||||||
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
_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)
|
result = await build_state_summary(session)
|
||||||
cache.store(result, revision)
|
cache.store(result, revision)
|
||||||
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
_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:
|
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.topic import TopicWithWorkstreams
|
||||||
from api.schemas.suggestion import RankedSuggestionDigest
|
from api.schemas.suggestion import RankedSuggestionDigest
|
||||||
from api.schemas.workstream import WorkstreamWithDeps
|
from api.schemas.workstream import WorkstreamWithDeps
|
||||||
|
from api.schemas.ops_run import OpsRunProjection
|
||||||
|
|
||||||
|
|
||||||
class TopicTotals(BaseModel):
|
class TopicTotals(BaseModel):
|
||||||
|
|
@ -90,6 +91,7 @@ class StateSummary(BaseModel):
|
||||||
licence_risk_count: int = 0
|
licence_risk_count: int = 0
|
||||||
open_capability_requests: int = 0
|
open_capability_requests: int = 0
|
||||||
ranked_suggestions: list[RankedSuggestionDigest] = []
|
ranked_suggestions: list[RankedSuggestionDigest] = []
|
||||||
|
ops_runs: OpsRunProjection | None = None
|
||||||
|
|
||||||
|
|
||||||
class DashboardWorkplanRow(BaseModel):
|
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.
|
cutover while the bridge target chain was rewired. Manual sweeps succeed.
|
||||||
Re-enablement is tracked outside this workplan (service-inventory gap).
|
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)
|
## Pragmatic Limitations (Single-Node)
|
||||||
|
|
||||||
This deployment is intentionally **not** highly available:
|
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-0011-state-hub-threephoenix-migration.md` — migration plan
|
||||||
- `workplans/CUST-WP-0038-state-hub-threephoenix-ha.md` — future HA target
|
- `workplans/CUST-WP-0038-state-hub-threephoenix-ha.md` — future HA target
|
||||||
- `deploy/railiance/README.md` — Helm/CNPG handoff
|
- `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
|
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:
|
On a clean Ubuntu 24.04 machine, allow package installation explicitly:
|
||||||
|
|
||||||
```bash
|
```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:
|
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:
|
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})
|
blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
|
||||||
recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
|
recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
|
||||||
repos = _get("/repos", {"domain": domain_slug})
|
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 ──────────────────────────────────────────────────────────
|
# ── Goal guidance ──────────────────────────────────────────────────────────
|
||||||
# Fetch active repo goals per repo, then cross-reference with workstreams.
|
# 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:
|
for repo in repos:
|
||||||
repo_slug = repo["slug"]
|
repo_slug = repo["slug"]
|
||||||
repo_id = repo["id"]
|
repo_id = repo["id"]
|
||||||
active_goals = _get("/repo-goals", {"repo_slug": repo_slug, "status": "active"})
|
repo_goals = goals_by_repo.get(str(repo_id), goals_by_repo.get(repo_slug, []))
|
||||||
if not active_goals:
|
if not repo_goals:
|
||||||
continue
|
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"], [])
|
linked = ws_by_repo_goal.get(goal["id"], [])
|
||||||
if not linked:
|
if not linked:
|
||||||
needs_workplan.append({
|
needs_workplan.append({
|
||||||
|
|
@ -324,7 +334,7 @@ def get_domain_summary(domain_slug: str) -> str:
|
||||||
"recent_workplan_title": recent_ws["title"],
|
"recent_workplan_title": recent_ws["title"],
|
||||||
"recent_workstream_id": recent_ws["id"],
|
"recent_workstream_id": recent_ws["id"],
|
||||||
"recent_workstream_title": recent_ws["title"],
|
"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": (
|
"message": (
|
||||||
f"Workplan '{recent_ws['title']}' is not linked to the current "
|
f"Workplan '{recent_ws['title']}' is not linked to the current "
|
||||||
f"repo goal(s) for {repo_slug}. "
|
f"repo goal(s) for {repo_slug}. "
|
||||||
|
|
@ -353,31 +363,6 @@ def get_domain_summary(domain_slug: str) -> str:
|
||||||
if goal_guidance:
|
if goal_guidance:
|
||||||
result["goal_guidance"] = 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)
|
# Compact capabilities list (type + title + repo_slug only, capped at 20)
|
||||||
caps_raw = _get("/capability-catalog/", {"domain": domain_slug, "status": "active"})
|
caps_raw = _get("/capability-catalog/", {"domain": domain_slug, "status": "active"})
|
||||||
if isinstance(caps_raw, list):
|
if isinstance(caps_raw, list):
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ AUTHORIZE_SSH=0
|
||||||
ALLOW_PLAINTEXT_STORE=0
|
ALLOW_PLAINTEXT_STORE=0
|
||||||
SKIP_GITEA=0
|
SKIP_GITEA=0
|
||||||
SKIP_MCP=0
|
SKIP_MCP=0
|
||||||
|
SKIP_CODEX=0
|
||||||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
|
||||||
SSH_TARGETS=(
|
SSH_TARGETS=(
|
||||||
"tegwick@92.205.62.239"
|
"tegwick@92.205.62.239"
|
||||||
|
|
@ -39,6 +40,7 @@ Options:
|
||||||
--gitea-token TOKEN Gitea token; otherwise prompted when interactive.
|
--gitea-token TOKEN Gitea token; otherwise prompted when interactive.
|
||||||
--skip-gitea Do not create or update ~/.railiance_gitea.conf.
|
--skip-gitea Do not create or update ~/.railiance_gitea.conf.
|
||||||
--skip-mcp Do not run make register-mcp.
|
--skip-mcp Do not run make register-mcp.
|
||||||
|
--skip-codex Do not configure Codex sandbox networking.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +120,10 @@ while [ "$#" -gt 0 ]; do
|
||||||
SKIP_MCP=1
|
SKIP_MCP=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--skip-codex)
|
||||||
|
SKIP_CODEX=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
|
|
@ -342,6 +348,19 @@ register_mcp() {
|
||||||
fi
|
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() {
|
health_check() {
|
||||||
step "Checking State Hub reachability"
|
step "Checking State Hub reachability"
|
||||||
if curl -fsS --max-time 2 "http://127.0.0.1:8000/state/health" >/dev/null 2>&1; then
|
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
|
setup_ssh_key
|
||||||
write_gitea_conf
|
write_gitea_conf
|
||||||
register_mcp
|
register_mcp
|
||||||
|
configure_codex
|
||||||
health_check
|
health_check
|
||||||
ok "Bootstrap checks complete."
|
ok "Bootstrap checks complete."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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}"
|
API_BASE="${API_BASE:-http://127.0.0.1:8000}"
|
||||||
HEALTH_URL="${API_BASE%/}/state/health"
|
HEALTH_URL="${API_BASE%/}/state/health"
|
||||||
|
|
@ -24,6 +26,12 @@ if ! api_healthy; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 API_BASE
|
||||||
export MCP_TRANSPORT=stdio
|
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
|
## State Hub Integration
|
||||||
|
|
||||||
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and
|
||||||
there is no MCP server for Codex agents.
|
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 |
|
| 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
|
unreachable. Treat that as pending local evidence, then ask the operator to run
|
||||||
statehub outbox status/replay after connectivity returns.
|
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
|
### Orient at session start
|
||||||
|
|
||||||
```bash
|
```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":
|
if path == "/repos":
|
||||||
return [{"id": "repo-1", "slug": "demo-repo", "domain_slug": "infotech"}]
|
return [{"id": "repo-1", "slug": "demo-repo", "domain_slug": "infotech"}]
|
||||||
if path == "/repo-goals":
|
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/":
|
if path == "/capability-catalog/":
|
||||||
return []
|
return []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def test_get_domain_summary_goal_guidance_is_workplan_first(monkeypatch) -> None:
|
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"))
|
payload = json.loads(server.get_domain_summary("infotech"))
|
||||||
assert "workplans" in payload
|
assert "workplans" in payload
|
||||||
assert payload["workstreams"] == payload["workplans"]
|
assert payload["workstreams"] == payload["workplans"]
|
||||||
action = payload["goal_guidance"]["needs_workplan"][0]["action"]
|
action = payload["goal_guidance"]["needs_workplan"][0]["action"]
|
||||||
assert "workplan" in action.lower()
|
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"
|
title: "Ops run read projection for fleet visibility"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: ready
|
status: finished
|
||||||
owner: grok
|
owner: codex
|
||||||
topic_slug: state-hub
|
topic_slug: state-hub
|
||||||
priority: low
|
priority: low
|
||||||
created: "2026-08-03"
|
created: "2026-08-03"
|
||||||
updated: "2026-08-03"
|
updated: "2026-08-08"
|
||||||
depends_on:
|
depends_on:
|
||||||
- ACTIVITY-WP-0026
|
- ACTIVITY-WP-0026
|
||||||
related:
|
related:
|
||||||
- ACT-ADR-005
|
- ACT-ADR-005
|
||||||
- STATE-WP-0074
|
- STATE-WP-0074
|
||||||
state_hub_workstream_id: "57b75246-ef6d-4d6e-93dc-7f47e7200d1d"
|
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
|
# STATE-WP-0078 — Ops run read projection
|
||||||
|
|
@ -39,7 +38,7 @@ coulombcore-oriented agents see automation health without claiming work.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0078-T01
|
id: STATE-WP-0078-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "399b4985-3578-4fff-97dd-0f68e1e266b3"
|
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.
|
**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
|
### T02 — Read API + summary field
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0078-T02
|
id: STATE-WP-0078-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "192be2e0-0335-46da-ba22-17e3e8f0d28c"
|
state_hub_task_id: "192be2e0-0335-46da-ba22-17e3e8f0d28c"
|
||||||
```
|
```
|
||||||
|
|
@ -68,13 +71,18 @@ Minimal:
|
||||||
|
|
||||||
**Done when:** MCP/summary consumers can show stuck automation.
|
**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
|
### T03 — Edge/federation notes
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0078-T03
|
id: STATE-WP-0078-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "09afc52e-e1c6-454f-ac1d-33d6e773f47f"
|
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.
|
**Done when:** cluster-operating-model or ops doc updated.
|
||||||
|
|
||||||
|
Documented in `docs/cluster-operating-model.md` and
|
||||||
|
`docs/ops-run-read-projection.md`.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [ ] Hub does not claim ops_runs
|
- [x] Hub does not claim ops_runs
|
||||||
- [ ] Operators see automation backlog on primary hub views
|
- [x] Operators see automation backlog on primary hub views
|
||||||
- [ ] No Forgejo/Gitea dependency
|
- [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
|
## Out of scope
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue