feat(capability-requests): add cross-domain capability catalog and request routing
Introduces a capability catalog (CUST-WP-0022) so domains can advertise what they provide and agents can request capabilities from other domains with auto-routing, lifecycle tracking, and task-unblocking on completion. - New models: CapabilityCatalog, CapabilityRequest with full lifecycle (requested → accepted → in_progress → ready_for_review → completed/rejected/withdrawn) - Migration i6d7e8f9a0b1: capability_catalog + capability_requests tables - Router /capability-catalog and /capability-requests with accept/status endpoints - 7 new MCP tools: register_capability, list_capabilities, request_capability, accept_capability_request, update_capability_request_status, list_capability_requests, get_capability_request - StateSummary gains open_capability_requests count - Dashboard: capability-requests.md page + docs/capabilities.md + docs/scope.md - SCOPE.md: three seed capabilities documented (MCP registration, state tracking, SBOM) - scope.template: Provided Capabilities section with example block - scripts/ingest_capabilities.py + make ingest-capabilities[/-all] targets Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
SCOPE.md
25
SCOPE.md
@@ -97,6 +97,31 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI
|
||||
|
||||
---
|
||||
|
||||
## Provided Capabilities
|
||||
|
||||
```capability
|
||||
type: api
|
||||
title: MCP tool registration
|
||||
description: Register and expose new MCP tools to all Claude Code sessions via the state-hub server.
|
||||
keywords: [mcp, tool, api, registration, server]
|
||||
```
|
||||
|
||||
```capability
|
||||
type: data
|
||||
title: Cross-domain state tracking
|
||||
description: Track workstreams, tasks, decisions, and progress events across all six project domains.
|
||||
keywords: [state, tracking, workstream, task, decision, progress]
|
||||
```
|
||||
|
||||
```capability
|
||||
type: api
|
||||
title: SBOM and licence reporting
|
||||
description: Ingest lockfiles from any repo and provide aggregated SBOM and copyleft licence risk reports.
|
||||
keywords: [sbom, licence, license, dependency, lockfile, copyleft]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Dependency order for domain sequencing: Railiance → Markitect → Coulomb.social → Personhood/Foerster → Custodian. The consistency checker (`make fix-consistency REPO=the-custodian`) must be run after any workplan changes to keep the dashboard accurate.
|
||||
|
||||
@@ -169,6 +169,20 @@ ingest-sbom:
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Ingest capability declarations from SCOPE.md into the catalog.
|
||||
## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian]
|
||||
## Or: make ingest-capabilities-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-capabilities:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-capabilities-all:
|
||||
uv run python scripts/ingest_capabilities.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
|
||||
## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -47,6 +47,7 @@ app.include_router(repo_goals.router)
|
||||
app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ from api.models.contribution import Contribution, ContributionType, Contribution
|
||||
from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -34,4 +36,6 @@ __all__ = [
|
||||
"SBOMSnapshot",
|
||||
"SBOMEntry", "Ecosystem",
|
||||
"AgentMessage",
|
||||
"CapabilityCatalog",
|
||||
"CapabilityRequest",
|
||||
]
|
||||
|
||||
39
state-hub/api/models/capability_catalog.py
Normal file
39
state-hub/api/models/capability_catalog.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class CapabilityCatalog(Base, TimestampMixin):
|
||||
__tablename__ = "capability_catalog"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
keywords: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String), nullable=False, server_default="{}"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
93
state-hub/api/models/capability_request.py
Normal file
93
state-hub/api/models/capability_request.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class CapabilityRequest(Base, TimestampMixin):
|
||||
__tablename__ = "capability_requests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
priority: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="medium", server_default="medium"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="requested", server_default="requested"
|
||||
)
|
||||
|
||||
# Requester side
|
||||
requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Fulfiller side (populated on accept / auto-route)
|
||||
fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Links
|
||||
blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("capability_catalog.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
|
||||
)
|
||||
fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
|
||||
"Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
|
||||
)
|
||||
blocking_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
|
||||
"CapabilityCatalog", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def requesting_domain_slug(self) -> str:
|
||||
return self.requesting_domain.slug if self.requesting_domain else ""
|
||||
|
||||
@property
|
||||
def fulfilling_domain_slug(self) -> str | None:
|
||||
return self.fulfilling_domain.slug if self.fulfilling_domain else None
|
||||
358
state-hub/api/routers/capability_requests.py
Normal file
358
state-hub/api/routers/capability_requests.py
Normal file
@@ -0,0 +1,358 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.domain import Domain
|
||||
from api.models.task import Task
|
||||
from api.schemas.capability_request import (
|
||||
CatalogCreate,
|
||||
CatalogRead,
|
||||
CapabilityRequestAccept,
|
||||
CapabilityRequestCreate,
|
||||
CapabilityRequestRead,
|
||||
CapabilityRequestStatusPatch,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["capability-requests"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_TRANSITIONS: dict[str, set[str]] = {
|
||||
"requested": {"accepted", "rejected", "withdrawn"},
|
||||
"accepted": {"in_progress", "rejected", "withdrawn"},
|
||||
"in_progress": {"ready_for_review", "rejected", "withdrawn"},
|
||||
"ready_for_review": {"completed", "in_progress", "withdrawn"},
|
||||
"completed": set(),
|
||||
"rejected": set(),
|
||||
"withdrawn": set(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_catalog_entry(
|
||||
body: CatalogCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
domain = await _resolve_domain(body.domain, session)
|
||||
entry = CapabilityCatalog(
|
||||
domain_id=domain.id,
|
||||
capability_type=body.capability_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
keywords=body.keywords,
|
||||
)
|
||||
session.add(entry)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Catalog entry '{body.title}' for type '{body.capability_type}' already exists in domain '{body.domain}'",
|
||||
)
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
||||
async def list_catalog(
|
||||
domain: str | None = Query(None),
|
||||
capability_type: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityCatalog]:
|
||||
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
||||
if domain:
|
||||
d = await _resolve_domain(domain, session)
|
||||
q = q.where(CapabilityCatalog.domain_id == d.id)
|
||||
if capability_type:
|
||||
q = q.where(CapabilityCatalog.capability_type == capability_type)
|
||||
if status_filter and status_filter != "all":
|
||||
q = q.where(CapabilityCatalog.status == status_filter)
|
||||
elif not status_filter:
|
||||
q = q.where(CapabilityCatalog.status == "active")
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Request endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_request(
|
||||
body: CapabilityRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req_domain = await _resolve_domain(body.requesting_domain, session)
|
||||
|
||||
# Route to provider
|
||||
fulfilling_domain_id, catalog_entry_id = await _route_capability(
|
||||
session, body.capability_type, body.description or ""
|
||||
)
|
||||
|
||||
req = CapabilityRequest(
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
capability_type=body.capability_type,
|
||||
priority=body.priority,
|
||||
requesting_domain_id=req_domain.id,
|
||||
requesting_agent=body.requesting_agent,
|
||||
requesting_workstream_id=body.requesting_workstream_id,
|
||||
blocking_task_id=body.blocking_task_id,
|
||||
fulfilling_domain_id=fulfilling_domain_id,
|
||||
catalog_entry_id=catalog_entry_id,
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # get req.id before creating notification
|
||||
|
||||
# Auto-notify
|
||||
if fulfilling_domain_id:
|
||||
ful_domain = await session.get(Domain, fulfilling_domain_id)
|
||||
to_agent = ful_domain.slug if ful_domain else "broadcast"
|
||||
else:
|
||||
to_agent = "broadcast"
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent="system",
|
||||
to_agent=to_agent,
|
||||
subject=f"[capability-request] {body.title}",
|
||||
body=(
|
||||
f"New capability request from **{body.requesting_agent}** "
|
||||
f"({body.requesting_domain}):\n\n"
|
||||
f"**Type:** {body.capability_type}\n"
|
||||
f"**Priority:** {body.priority}\n\n"
|
||||
f"{body.description or '(no description)'}"
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
|
||||
async def list_requests(
|
||||
domain: str | None = Query(None, description="Filter by requesting OR fulfilling domain slug"),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
capability_type: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityRequest]:
|
||||
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
|
||||
if domain:
|
||||
d = await _resolve_domain(domain, session)
|
||||
q = q.where(
|
||||
(CapabilityRequest.requesting_domain_id == d.id)
|
||||
| (CapabilityRequest.fulfilling_domain_id == d.id)
|
||||
)
|
||||
if status_filter:
|
||||
q = q.where(CapabilityRequest.status == status_filter)
|
||||
if capability_type:
|
||||
q = q.where(CapabilityRequest.capability_type == capability_type)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||
async def get_request(
|
||||
request_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
return await _get_request_or_404(request_id, session)
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
||||
async def accept_request(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestAccept,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, "accepted")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
req.status = "accepted"
|
||||
req.fulfilling_agent = body.fulfilling_agent
|
||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||
req.accepted_at = now
|
||||
|
||||
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
||||
# (The agent can also PATCH it later if needed)
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.fulfilling_agent,
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-accepted] {req.title}",
|
||||
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
||||
async def patch_request_status(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestStatusPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, body.status)
|
||||
|
||||
req.status = body.status
|
||||
if body.note:
|
||||
req.resolution_note = body.note
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Status-specific side effects
|
||||
if body.status == "completed":
|
||||
req.completed_at = now
|
||||
# Auto-unblock the blocking task
|
||||
if req.blocking_task_id:
|
||||
task = await session.get(Task, req.blocking_task_id)
|
||||
if task and task.status == "blocked":
|
||||
task.status = "todo"
|
||||
task.blocking_reason = None
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent="system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-completed] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been completed.\n\n"
|
||||
f"{body.note or ''}"
|
||||
),
|
||||
)
|
||||
elif body.status == "ready_for_review":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-ready] {req.title} -- please review",
|
||||
body=(
|
||||
f"Capability **{req.title}** is ready for your review and optimization.\n\n"
|
||||
f"{body.note or ''}"
|
||||
),
|
||||
)
|
||||
elif body.status == "rejected":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-rejected] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been rejected.\n\n"
|
||||
f"**Reason:** {body.note or '(no reason given)'}"
|
||||
),
|
||||
)
|
||||
elif body.status == "in_progress":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-in-progress] {req.title}",
|
||||
body=f"Work on capability **{req.title}** is now in progress.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing algorithm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _route_capability(
|
||||
session: AsyncSession, capability_type: str, description: str
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None]:
|
||||
"""Find the best-matching domain for a capability request.
|
||||
|
||||
Returns (domain_id, catalog_entry_id) or (None, None) for broadcast.
|
||||
"""
|
||||
q = select(CapabilityCatalog).where(
|
||||
CapabilityCatalog.capability_type == capability_type,
|
||||
CapabilityCatalog.status == "active",
|
||||
)
|
||||
entries = list((await session.execute(q)).scalars().all())
|
||||
|
||||
if len(entries) == 1:
|
||||
return entries[0].domain_id, entries[0].id
|
||||
|
||||
if len(entries) > 1 and description:
|
||||
desc_lower = description.lower()
|
||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||
for entry in entries:
|
||||
score = sum(1 for kw in (entry.keywords or []) if kw.lower() in desc_lower)
|
||||
scored.append((score, entry))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
if scored[0][0] > 0 and (len(scored) < 2 or scored[0][0] > scored[1][0]):
|
||||
return scored[0][1].domain_id, scored[0][1].id
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_notification(
|
||||
session: AsyncSession,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Create an AgentMessage notification in the current session (no commit)."""
|
||||
msg = AgentMessage(
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
session.add(msg)
|
||||
|
||||
|
||||
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
|
||||
result = await session.execute(select(Domain).where(Domain.slug == slug))
|
||||
domain = result.scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||
return domain
|
||||
|
||||
|
||||
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
|
||||
req = await session.get(CapabilityRequest, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
||||
return req
|
||||
|
||||
|
||||
def _check_transition(current: str, target: str) -> None:
|
||||
allowed = _VALID_TRANSITIONS.get(current, set())
|
||||
if target not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Cannot transition from '{current}' to '{target}'. "
|
||||
f"Allowed: {sorted(allowed) or 'none (terminal state)'}"
|
||||
),
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session, engine
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.contribution import Contribution, ContributionStatus, ContributionType
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.domain import Domain
|
||||
@@ -204,6 +205,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
|
||||
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
|
||||
)
|
||||
|
||||
# Open capability requests (non-terminal statuses)
|
||||
open_cap_req_count = (await session.execute(
|
||||
select(func.count()).select_from(CapabilityRequest).where(
|
||||
CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
return StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
totals=totals,
|
||||
@@ -215,6 +223,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm
|
||||
domains=domain_summaries,
|
||||
contribution_counts=contribution_counts,
|
||||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
open_workstreams=[
|
||||
WorkstreamWithDeps(
|
||||
**WorkstreamRead.model_validate(w).model_dump(),
|
||||
|
||||
79
state-hub/api/schemas/capability_request.py
Normal file
79
state-hub/api/schemas/capability_request.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CatalogCreate(BaseModel):
|
||||
domain: str # slug, resolved to domain_id in router
|
||||
capability_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
keywords: list[str] = []
|
||||
|
||||
|
||||
class CatalogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
domain_slug: str
|
||||
capability_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
keywords: list[str] = []
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CapabilityRequestCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
capability_type: str
|
||||
priority: str = "medium"
|
||||
requesting_domain: str # slug, resolved to domain_id in router
|
||||
requesting_agent: str
|
||||
requesting_workstream_id: uuid.UUID | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestAccept(BaseModel):
|
||||
fulfilling_agent: str
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestStatusPatch(BaseModel):
|
||||
status: str # in_progress | ready_for_review | completed | rejected | withdrawn
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class CapabilityRequestRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
capability_type: str
|
||||
priority: str
|
||||
status: str
|
||||
requesting_domain_slug: str
|
||||
requesting_agent: str
|
||||
requesting_workstream_id: uuid.UUID | None = None
|
||||
fulfilling_domain_slug: str | None = None
|
||||
fulfilling_agent: str | None = None
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
resolution_note: str | None = None
|
||||
accepted_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -79,3 +79,4 @@ class StateSummary(BaseModel):
|
||||
domains: list[DomainSummary] = []
|
||||
contribution_counts: dict[str, int] = {}
|
||||
licence_risk_count: int = 0
|
||||
open_capability_requests: int = 0
|
||||
|
||||
@@ -19,6 +19,7 @@ export default {
|
||||
pages: [
|
||||
// ── Pages (Overview first, then alphabetical) ────────────────────────────
|
||||
{ name: "Overview", path: "/" },
|
||||
{ name: "Capabilities", path: "/capability-requests" },
|
||||
{ name: "Contributions", path: "/contributions" },
|
||||
{ name: "Domains", path: "/domains" },
|
||||
{ name: "Goals", path: "/goals" },
|
||||
@@ -66,6 +67,7 @@ export default {
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Capabilities", path: "/docs/capabilities" },
|
||||
{ name: "Connecting to the Hub", path: "/docs/connecting" },
|
||||
{ name: "Contributions", path: "/docs/contributions" },
|
||||
{ name: "Decision Health", path: "/docs/decisions-kpi" },
|
||||
@@ -84,6 +86,7 @@ export default {
|
||||
{ name: "Repo Integration", path: "/docs/repo-integration" },
|
||||
{ name: "Repos", path: "/docs/repos" },
|
||||
{ name: "SBOM", path: "/docs/sbom" },
|
||||
{ name: "SCOPE.md", path: "/docs/scope" },
|
||||
{ name: "Tasks", path: "/docs/tasks" },
|
||||
{ name: "Technical Debt", path: "/docs/debt" },
|
||||
{ name: "Todo", path: "/docs/todo" },
|
||||
|
||||
250
state-hub/dashboard/src/capability-requests.md
Normal file
250
state-hub/dashboard/src/capability-requests.md
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: Capability Requests
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "./components/config.js";
|
||||
const POLL = 30_000;
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll for capability requests
|
||||
const reqState = (async function*() {
|
||||
while (true) {
|
||||
let data = [], ok = false;
|
||||
try {
|
||||
const r = await fetch(`${API}/capability-requests/`);
|
||||
ok = r.ok;
|
||||
data = ok ? await r.json() : [];
|
||||
} catch {}
|
||||
yield {data, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const requests = reqState.data ?? [];
|
||||
const _ok = reqState.ok ?? false;
|
||||
const _ts = reqState.ts;
|
||||
```
|
||||
|
||||
# Capability Requests
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
|
||||
const _liveEl = html`<div class="live-indicator">
|
||||
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
||||
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
|
||||
</div>`;
|
||||
withDocHelp(_liveEl, "/docs/live-data");
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
|
||||
const _h1 = document.querySelector("#observablehq-main h1");
|
||||
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities"); }
|
||||
```
|
||||
|
||||
```js
|
||||
// KPI sidebar
|
||||
const open = requests.filter(r => ["requested","accepted","in_progress","ready_for_review"].includes(r.status));
|
||||
const completed = requests.filter(r => r.status === "completed");
|
||||
const avgFulfill = completed.length > 0
|
||||
? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1)
|
||||
: "—";
|
||||
const critical = open.filter(r => r.priority === "critical" || r.priority === "high").length;
|
||||
|
||||
const kpiEl = html`<div class="kpi-infobox">
|
||||
<div class="kpi-infobox-title">Capability Requests</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.8rem;font-size:0.82rem">
|
||||
<span>Open</span><strong>${open.length}</strong>
|
||||
<span>Avg fulfill</span><strong>${avgFulfill}d</strong>
|
||||
<span>High/Critical</span><strong style="color:${critical > 0 ? 'orange' : 'inherit'}">${critical}</strong>
|
||||
<span>Total</span><strong>${requests.length}</strong>
|
||||
</div>
|
||||
</div>`;
|
||||
injectTocTop("cap-req-kpi", kpiEl);
|
||||
```
|
||||
|
||||
```js
|
||||
// Filters
|
||||
const typeFilter = Inputs.select(
|
||||
["all", ...new Set(requests.map(r => r.capability_type))],
|
||||
{label: "Type", value: "all"}
|
||||
);
|
||||
const statFilter = Inputs.select(
|
||||
["all", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"],
|
||||
{label: "Status", value: "all"}
|
||||
);
|
||||
const domFilter = Inputs.select(
|
||||
["all", ...new Set([...requests.map(r => r.requesting_domain_slug), ...requests.map(r => r.fulfilling_domain_slug).filter(Boolean)])],
|
||||
{label: "Domain", value: "all"}
|
||||
);
|
||||
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
${typeFilter}${statFilter}${domFilter}
|
||||
</div>`);
|
||||
```
|
||||
|
||||
```js
|
||||
const tf = typeFilter.value;
|
||||
const sf = statFilter.value;
|
||||
const df = domFilter.value;
|
||||
|
||||
const filtered = requests.filter(r =>
|
||||
(tf === "all" || r.capability_type === tf) &&
|
||||
(sf === "all" || r.status === sf) &&
|
||||
(df === "all" || r.requesting_domain_slug === df || r.fulfilling_domain_slug === df)
|
||||
);
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
```js
|
||||
const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"};
|
||||
display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem">
|
||||
<div class="card"><h3>Requested</h3><p class="big-num">${requests.filter(r => r.status === "requested").length}</p></div>
|
||||
<div class="card"><h3>In Progress</h3><p class="big-num">${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}</p></div>
|
||||
<div class="card"><h3>Ready for Review</h3><p class="big-num">${requests.filter(r => r.status === "ready_for_review").length}</p></div>
|
||||
<div class="card"><h3>Completed</h3><p class="big-num">${completed.length}</p></div>
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Status Kanban
|
||||
|
||||
```js
|
||||
const statusCols = [
|
||||
{key: "requested", label: "Requested", color: "steelblue"},
|
||||
{key: "accepted", label: "Accepted", color: "#f0a500"},
|
||||
{key: "in_progress", label: "In Progress", color: "#2196f3"},
|
||||
{key: "ready_for_review", label: "Ready for Review", color: "#4caf50"},
|
||||
{key: "completed", label: "Completed", color: "#2e7d32"},
|
||||
{key: "rejected", label: "Rejected", color: "#e53935"},
|
||||
{key: "withdrawn", label: "Withdrawn", color: "#bbb"},
|
||||
];
|
||||
|
||||
const colMap = {};
|
||||
for (const r of filtered) {
|
||||
(colMap[r.status] = colMap[r.status] ?? []).push(r);
|
||||
}
|
||||
|
||||
const activeCols = statusCols.filter(s => colMap[s.key]?.length);
|
||||
if (activeCols.length === 0) {
|
||||
display(html`<p style="color:gray">No capability requests match the current filters.</p>`);
|
||||
} else {
|
||||
const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0);
|
||||
display(html`<div class="kanban">
|
||||
${activeCols.map(s => html`
|
||||
<div class="kanban-col">
|
||||
<div class="kanban-header" style="border-bottom:2px solid ${s.color}">${s.label} <span class="kanban-count">${colMap[s.key].length}</span></div>
|
||||
${colMap[s.key].map(r => html`
|
||||
<div class="cap-card">
|
||||
<div class="cap-type-badge" style="background:${priorityColors[r.priority] ?? '#aaa'}20;color:${priorityColors[r.priority] ?? '#aaa'}">${r.capability_type}</div>
|
||||
<div class="cap-priority-badge" style="color:${priorityColors[r.priority] ?? '#888'}">${r.priority}</div>
|
||||
<div class="cap-title">${r.title}</div>
|
||||
<div class="cap-domains">
|
||||
<span>${r.requesting_domain_slug}</span>
|
||||
${r.fulfilling_domain_slug ? html` → <strong>${r.fulfilling_domain_slug}</strong>` : html` → <em>unassigned</em>`}
|
||||
</div>
|
||||
<div class="cap-age">${ageDays(r)}d old</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
## All Requests
|
||||
|
||||
```js
|
||||
display(Inputs.table(filtered.map(r => ({
|
||||
Type: r.capability_type,
|
||||
Title: r.title,
|
||||
Priority: r.priority,
|
||||
Status: r.status,
|
||||
Requester: r.requesting_domain_slug,
|
||||
Provider: r.fulfilling_domain_slug ?? "—",
|
||||
Agent: r.requesting_agent,
|
||||
Created: new Date(r.created_at).toLocaleDateString(),
|
||||
})), {maxWidth: 1000}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Capability Catalog
|
||||
|
||||
```js
|
||||
// Live poll for catalog entries
|
||||
const catalogState = (async function*() {
|
||||
while (true) {
|
||||
let data = [];
|
||||
try {
|
||||
const r = await fetch(`${API}/capability-catalog/?status=all`);
|
||||
if (r.ok) data = await r.json();
|
||||
} catch {}
|
||||
yield data;
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const catalog = catalogState ?? [];
|
||||
```
|
||||
|
||||
```js
|
||||
if (catalog.length === 0) {
|
||||
display(html`<p style="color:gray">No capabilities registered yet. Add <code>```capability</code> blocks to SCOPE.md files and run <code>make ingest-capabilities-all</code>.</p>`);
|
||||
} else {
|
||||
// Group by domain
|
||||
const byDomain = {};
|
||||
for (const c of catalog) {
|
||||
(byDomain[c.domain_slug] = byDomain[c.domain_slug] ?? []).push(c);
|
||||
}
|
||||
const typeColors = {
|
||||
infrastructure: "#e65100", api: "#1565c0", data: "#2e7d32",
|
||||
security: "#c62828", documentation: "#6a1b9a", other: "#888"
|
||||
};
|
||||
display(html`<div class="catalog-grid">
|
||||
${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html`
|
||||
<div class="catalog-domain">
|
||||
<div class="catalog-domain-header">${domain} <span class="kanban-count">${caps.length}</span></div>
|
||||
${caps.map(c => html`
|
||||
<div class="catalog-entry ${c.status === 'deprecated' ? 'catalog-deprecated' : ''}">
|
||||
<div class="cap-type-badge" style="background:${(typeColors[c.capability_type] ?? '#888')}18;color:${typeColors[c.capability_type] ?? '#888'}">${c.capability_type}</div>
|
||||
<div class="catalog-title">${c.title}</div>
|
||||
${c.description ? html`<div class="catalog-desc">${c.description}</div>` : ""}
|
||||
${c.keywords?.length ? html`<div class="catalog-kw">${c.keywords.map(k => html`<span class="kw-tag">${k}</span>`)}</div>` : ""}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.live-indicator { font-size: 0.8rem; color: gray; padding: 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
||||
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
|
||||
.kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.kanban-col { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
|
||||
.kanban-header { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||
.kanban-count { font-size: 0.75rem; background: var(--theme-background); border-radius: 10px; padding: 0.1rem 0.4rem; font-weight: 500; }
|
||||
.cap-card { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
|
||||
.cap-type-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; border-radius: 3px; padding: 0.1rem 0.35rem; margin-bottom: 0.15rem; }
|
||||
.cap-priority-badge { display: inline-block; font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 0.3rem; }
|
||||
.cap-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.2rem; line-height: 1.3; }
|
||||
.cap-domains { font-size: 0.75rem; color: steelblue; font-family: monospace; }
|
||||
.cap-age { font-size: 0.7rem; color: gray; margin-top: 0.3rem; }
|
||||
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.catalog-domain { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
|
||||
.catalog-domain-header { font-weight: 600; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; border-bottom: 2px solid var(--theme-foreground-faint, #ddd); display: flex; align-items: center; gap: 0.4rem; }
|
||||
.catalog-entry { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
|
||||
.catalog-deprecated { opacity: 0.5; }
|
||||
.catalog-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.15rem; }
|
||||
.catalog-desc { font-size: 0.75rem; color: var(--theme-foreground-muted, #666); line-height: 1.35; margin-bottom: 0.3rem; }
|
||||
.catalog-kw { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.kw-tag { font-size: 0.6rem; background: var(--theme-background-alt, #f0f0f0); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 3px; padding: 0.05rem 0.3rem; font-family: monospace; color: var(--theme-foreground-muted, #666); }
|
||||
</style>
|
||||
228
state-hub/dashboard/src/docs/capabilities.md
Normal file
228
state-hub/dashboard/src/docs/capabilities.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: Capabilities — Reference
|
||||
---
|
||||
|
||||
# Capabilities — Reference
|
||||
|
||||
The Capability Requests page shows cross-domain provisioning requests — a
|
||||
decoupled mechanism for one domain to request something that another domain is
|
||||
responsible for, without needing to know *who* is responsible.
|
||||
|
||||
---
|
||||
|
||||
## What is a capability?
|
||||
|
||||
A **capability** is something a domain can provide to the broader ecosystem —
|
||||
infrastructure provisioning, API endpoints, security tooling, documentation,
|
||||
data pipelines, etc. Capabilities are registered in the **capability catalog**
|
||||
so the system knows which domain provides what.
|
||||
|
||||
A **capability request** is a structured declaration from a requester
|
||||
("I need X") that the system routes to the right provider automatically.
|
||||
|
||||
---
|
||||
|
||||
## Capability catalog
|
||||
|
||||
The catalog is the routing backbone. Each entry registers one thing a domain
|
||||
can provide.
|
||||
|
||||
**Origin of truth: SCOPE.md** — following ADR-001, capability declarations
|
||||
live in each repo's `SCOPE.md` file under the `## Provided Capabilities`
|
||||
section. The state-hub catalog table is a derived index, reconstructable from
|
||||
repo files via `make ingest-capabilities-all`.
|
||||
|
||||
### SCOPE.md capability blocks
|
||||
|
||||
Add fenced `capability` blocks to your repo's SCOPE.md:
|
||||
|
||||
````markdown
|
||||
## Provided Capabilities
|
||||
|
||||
```capability
|
||||
type: infrastructure
|
||||
title: Cluster provisioning
|
||||
description: Provision k8s clusters and managed instances for any domain.
|
||||
keywords: [cluster, k8s, privacy, instance]
|
||||
```
|
||||
````
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| **type** | Category — `infrastructure`, `api`, `data`, `security`, `documentation`, `other` |
|
||||
| **title** | Short name (unique within domain + type) |
|
||||
| **description** | What this capability provides, in one or two sentences |
|
||||
| **keywords** | Routing hints matched against request descriptions |
|
||||
|
||||
### Ingesting into the catalog
|
||||
|
||||
```bash
|
||||
make ingest-capabilities REPO=the-custodian # single repo
|
||||
make ingest-capabilities-all # all registered repos
|
||||
make ingest-capabilities REPO=railiance-infra DRY_RUN=1 # preview
|
||||
```
|
||||
|
||||
The ingest script reads `SCOPE.md` → parses `capability` blocks → upserts into
|
||||
the `capability_catalog` table via the API. Existing entries (same domain + type
|
||||
+ title) are skipped.
|
||||
|
||||
### Browsing the catalog
|
||||
|
||||
Via MCP:
|
||||
```
|
||||
list_capabilities(domain="railiance")
|
||||
```
|
||||
|
||||
Via API:
|
||||
```
|
||||
GET /capability-catalog/?domain=railiance
|
||||
```
|
||||
|
||||
The catalog is also shown at the bottom of the Capabilities dashboard page,
|
||||
grouped by domain with type badges and keyword tags.
|
||||
|
||||
---
|
||||
|
||||
## Routing algorithm
|
||||
|
||||
When a request is created, the system auto-routes it:
|
||||
|
||||
1. **Exact type match** — find catalog entries where `capability_type` matches
|
||||
2. **Single match** — auto-assign the providing domain
|
||||
3. **Multiple matches** — keyword-score the request description against each entry's keywords; pick the winner if unambiguous
|
||||
4. **No match or tie** — leave the provider unassigned and **broadcast** a notification to all domains so one can claim it
|
||||
|
||||
This means the requester never needs to know which domain owns a capability.
|
||||
|
||||
---
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
```
|
||||
requested → accepted → in_progress → ready_for_review → completed
|
||||
↓ ↓ ↓ ↓
|
||||
withdrawn rejected withdrawn withdrawn
|
||||
withdrawn
|
||||
```
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **requested** | Need declared; routed (or broadcast) to provider |
|
||||
| **accepted** | Provider acknowledged and claimed the request |
|
||||
| **in_progress** | Provider is actively working on it |
|
||||
| **ready_for_review** | Provider finished; requester should review and optimise |
|
||||
| **completed** | Requester confirmed; capability is live |
|
||||
| **rejected** | Provider cannot or will not fulfil the request |
|
||||
| **withdrawn** | Requester cancelled the request |
|
||||
|
||||
Transitions are enforced by the API — you cannot skip stages. Terminal states
|
||||
(`completed`, `rejected`, `withdrawn`) allow no further transitions.
|
||||
|
||||
---
|
||||
|
||||
## Auto-notifications
|
||||
|
||||
Every lifecycle transition creates an **AgentMessage** atomically:
|
||||
|
||||
| Transition | Notification to |
|
||||
|------------|----------------|
|
||||
| **requested** | Provider domain agent (or `broadcast` if unrouted) |
|
||||
| **accepted** | Requesting agent |
|
||||
| **in_progress** | Requesting agent |
|
||||
| **ready_for_review** | Requesting agent |
|
||||
| **completed** | Requesting agent |
|
||||
| **rejected** | Requesting agent (with reason) |
|
||||
|
||||
Notifications appear in the [Inbox](/inbox) page and are queryable via
|
||||
`get_messages(to_agent="<your-agent>")`.
|
||||
|
||||
---
|
||||
|
||||
## Auto-unblock
|
||||
|
||||
A request can optionally link to a **blocking task** via `blocking_task_id`.
|
||||
When the request reaches `completed`, the system automatically patches that
|
||||
task from `blocked` → `todo` and clears its `blocking_reason`. This means
|
||||
blocked work resumes without manual intervention.
|
||||
|
||||
---
|
||||
|
||||
## Creating a request
|
||||
|
||||
Via MCP:
|
||||
|
||||
```
|
||||
request_capability(
|
||||
title = "Privacy idea instance on cluster",
|
||||
description = "Need a privacy idea instance provisioned on the k8s cluster",
|
||||
capability_type = "infrastructure",
|
||||
requesting_agent = "net-kingdom-worker",
|
||||
requesting_domain = "custodian",
|
||||
requesting_workstream_id = "<uuid>", # optional
|
||||
priority = "high", # low | medium | high | critical
|
||||
blocking_task_id = "<task-uuid>" # optional — auto-unblocked on completion
|
||||
)
|
||||
```
|
||||
|
||||
The system routes this to `railiance` (if a matching catalog entry exists),
|
||||
creates an AgentMessage notification, and returns the request with
|
||||
`fulfilling_domain_slug: "railiance"`.
|
||||
|
||||
---
|
||||
|
||||
## Accepting and fulfilling
|
||||
|
||||
The provider agent checks their inbox, sees the request, and accepts:
|
||||
|
||||
```
|
||||
accept_capability_request(
|
||||
request_id = "<uuid>",
|
||||
fulfilling_agent = "railiance-worker",
|
||||
fulfilling_workstream_id = "<uuid>" # optional
|
||||
)
|
||||
```
|
||||
|
||||
Then advances through the lifecycle:
|
||||
|
||||
```
|
||||
update_capability_request_status(request_id, "in_progress")
|
||||
update_capability_request_status(request_id, "ready_for_review", note="Instance up at 10.0.1.42")
|
||||
```
|
||||
|
||||
The requester reviews and completes:
|
||||
|
||||
```
|
||||
update_capability_request_status(request_id, "completed", note="Verified, looks good")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboard
|
||||
|
||||
The Capabilities page shows:
|
||||
|
||||
- **KPI sidebar** — open count, average fulfillment time, high/critical count
|
||||
- **Summary cards** — requested, in progress, ready for review, completed
|
||||
- **Kanban board** — cards grouped by status column
|
||||
- **Table** — all requests with filters by type, status, and domain
|
||||
|
||||
Each card shows the capability type, priority, requester → provider domains,
|
||||
and age in days.
|
||||
|
||||
---
|
||||
|
||||
## Relation to other concepts
|
||||
|
||||
| Concept | Relationship |
|
||||
|---------|-------------|
|
||||
| **SCOPE.md** | Defines what a repo *is responsible for* — the catalog registers what it *can provide* |
|
||||
| **Dependencies** | Workstream-to-workstream edges — capabilities are higher-level, domain-to-domain |
|
||||
| **Extension Points** | Design forks for *future* enhancement — capabilities are *operational* requests |
|
||||
| **Contributions** | Outbound upstream work — capabilities are *inbound* requests between internal domains |
|
||||
| **Human Interventions** | Flagged tasks for Bernd — capabilities are agent-to-agent coordination |
|
||||
|
||||
---
|
||||
|
||||
*Capability requests are a sanctioned write use case of the State Hub alongside
|
||||
`resolve_decision` and `get_next_steps`. They do not originate in workplan files —
|
||||
they are operational coordination.*
|
||||
177
state-hub/dashboard/src/docs/scope.md
Normal file
177
state-hub/dashboard/src/docs/scope.md
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
title: SCOPE.md — Reference
|
||||
---
|
||||
|
||||
# SCOPE.md — Reference
|
||||
|
||||
SCOPE.md is a lightweight, strategic orientation artifact placed at the root of
|
||||
every registered repository. It helps humans and agents quickly understand what
|
||||
a repo is about, when it is relevant, and where it fits in the ecosystem.
|
||||
|
||||
---
|
||||
|
||||
## What SCOPE.md is
|
||||
|
||||
SCOPE.md answers these questions **in under 60 seconds**:
|
||||
|
||||
- What is this repository for?
|
||||
- Should I care about it right now?
|
||||
- When is it relevant to my work?
|
||||
- Where does it fit in the ecosystem?
|
||||
- Is it mature enough to trust or reuse?
|
||||
- Does it overlap with something else?
|
||||
|
||||
It is **not** a README, not architecture documentation, and not marketing text.
|
||||
It is a pragmatic, scannable boundary definition.
|
||||
|
||||
---
|
||||
|
||||
## Template structure
|
||||
|
||||
Every SCOPE.md follows an 11-section template:
|
||||
|
||||
| Section | Purpose |
|
||||
|---------|---------|
|
||||
| **One-liner** | One precise sentence describing the repo's purpose |
|
||||
| **Core Idea** | Main capability and what problem it solves |
|
||||
| **In Scope** | What the repo is explicitly responsible for — concrete, not vague |
|
||||
| **Out of Scope** | What it deliberately does NOT do (often more important) |
|
||||
| **Relevant When** | Real usage scenarios when someone should consider this repo |
|
||||
| **Not Relevant When** | When someone should look elsewhere |
|
||||
| **Current State** | Maturity indicators: status, implementation, stability, usage |
|
||||
| **How It Fits** | Upstream dependencies, downstream consumers, often-used-with |
|
||||
| **Terminology** | Domain terms, potential confusions with similar concepts |
|
||||
| **Related / Overlapping** | Repos with similar or adjacent responsibilities |
|
||||
| **Provided Capabilities** | What this repo's domain can provide to others on request |
|
||||
| **Getting Oriented** | Entry points, key files, where to start |
|
||||
|
||||
The template is at `state-hub/scripts/project_rules/scope.template`.
|
||||
|
||||
---
|
||||
|
||||
## Current State indicators
|
||||
|
||||
The Current State section uses four axes:
|
||||
|
||||
| Axis | Values |
|
||||
|------|--------|
|
||||
| **Status** | concept / experimental / active / stable / deprecated |
|
||||
| **Implementation** | idea / partial / substantial / complete |
|
||||
| **Stability** | unstable / evolving / stable |
|
||||
| **Usage** | none / personal / internal / production |
|
||||
|
||||
These help an agent decide whether to depend on, extend, or avoid a repo
|
||||
without needing to read its full codebase.
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Intentionally short and scannable** — not comprehensive documentation
|
||||
- **Pragmatic** — real usage scenarios, not ideals
|
||||
- **Easy to maintain** — update when scope changes, not on every commit
|
||||
- **Direct language** — no filler, no marketing, no invented features
|
||||
- **Honest about gaps** — if something is incomplete or unstable, say so
|
||||
|
||||
**Anti-goals:**
|
||||
- No long prose or verbose explanations
|
||||
- No repetition of README content
|
||||
- No hiding ambiguity behind vague language
|
||||
- No assumption of production readiness
|
||||
|
||||
---
|
||||
|
||||
## How SCOPE.md is created
|
||||
|
||||
### New repositories
|
||||
|
||||
When a repo is registered via `make register-project`, the scaffold copies
|
||||
`scope.template` → `SCOPE.md` at the repo root. The human or an agent then
|
||||
populates the sections from the repo's actual state.
|
||||
|
||||
### Existing repositories
|
||||
|
||||
The **scope-analyst** kaizen agent persona can be loaded to generate or refine
|
||||
a SCOPE.md:
|
||||
|
||||
```
|
||||
get_kaizen_agent("scope-analyst")
|
||||
```
|
||||
|
||||
This agent reads the repo's codebase, existing documentation, and CLAUDE.md
|
||||
to produce a SCOPE.md that accurately reflects the current state.
|
||||
|
||||
---
|
||||
|
||||
## Ecosystem coverage
|
||||
|
||||
SCOPE.md files exist across all custodian domains:
|
||||
|
||||
| Domain | Repos with SCOPE.md |
|
||||
|--------|-------------------|
|
||||
| **custodian** | the-custodian, kaizen-agentic, ops-bridge, activity-core |
|
||||
| **custodian** (netkingdom) | net-kingdom, key-cape |
|
||||
| **railiance** | railiance-apps, railiance-cluster, railiance-enablement, railiance-infra, railiance-platform |
|
||||
| **markitect** | markitect_project |
|
||||
|
||||
---
|
||||
|
||||
## Provided Capabilities section
|
||||
|
||||
The `## Provided Capabilities` section uses fenced `capability` blocks that
|
||||
are machine-readable and ingested into the state-hub capability catalog:
|
||||
|
||||
````markdown
|
||||
```capability
|
||||
type: infrastructure
|
||||
title: Cluster provisioning
|
||||
description: Provision k8s clusters and managed instances for any domain.
|
||||
keywords: [cluster, k8s, privacy, instance]
|
||||
```
|
||||
````
|
||||
|
||||
| Field | Required | Purpose |
|
||||
|-------|----------|---------|
|
||||
| **type** | yes | Category: `infrastructure`, `api`, `data`, `security`, `documentation`, `other` |
|
||||
| **title** | yes | Short name (unique within domain + type) |
|
||||
| **description** | no | What this capability provides |
|
||||
| **keywords** | no | Routing hints for auto-matching capability requests |
|
||||
|
||||
The ingest script (`make ingest-capabilities-all`) parses these blocks from all
|
||||
registered repos and populates the state-hub catalog table. This follows
|
||||
ADR-001: **files are the origin of truth, DB is cache/index**.
|
||||
|
||||
---
|
||||
|
||||
## Relation to capabilities
|
||||
|
||||
SCOPE.md is both the **human-readable boundary definition** and the **origin of
|
||||
truth for the capability catalog**:
|
||||
|
||||
| | SCOPE.md | Capability Catalog (DB) |
|
||||
|-|----------|------------------------|
|
||||
| **Role** | Origin of truth | Derived index |
|
||||
| **Granularity** | Per-repository | Per-domain (aggregated from repo files) |
|
||||
| **Purpose** | "What is this repo?" + "What can it provide?" | Routing engine for capability requests |
|
||||
| **Updates** | Edit the file, re-ingest | Auto-populated from SCOPE.md |
|
||||
| **Readable without hub** | Yes — just open the file | No — requires API |
|
||||
|
||||
This means a repo is fully self-describing: you can understand what it provides
|
||||
by reading SCOPE.md alone, without any centralized infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Relation to other concepts
|
||||
|
||||
| Concept | Relationship |
|
||||
|---------|-------------|
|
||||
| **CLAUDE.md** | Build/test/lint instructions — *how* to work with the repo |
|
||||
| **SCOPE.md** | Boundary definition — *what* the repo is and isn't |
|
||||
| **Capability Catalog** | Operational routing — *what the domain can provide* on request |
|
||||
| **Domain Goals** | Strategic direction — *where the domain is heading* |
|
||||
| **Project Charters** | Founding intent — *why the domain exists* (in `canon/projects/`) |
|
||||
|
||||
---
|
||||
|
||||
*SCOPE.md is the boundary definition layer. It tells you whether you are in the
|
||||
right place before you start reading code.*
|
||||
@@ -1729,6 +1729,173 @@ def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
|
||||
return json.dumps(_post(f"/messages/{message_id}/reply", {"from_agent": from_agent, "body": body}), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog & Requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def register_capability(
|
||||
domain: str,
|
||||
capability_type: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Register a capability that a domain can provide. Used for routing requests.
|
||||
|
||||
Args:
|
||||
domain: Domain slug (e.g. 'railiance', 'markitect')
|
||||
capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security', 'documentation')
|
||||
title: Short title for this capability
|
||||
description: Longer description (optional)
|
||||
keywords: List of keywords for routing (e.g. ['cluster', 'k8s', 'privacy'])
|
||||
"""
|
||||
entry = _post("/capability-catalog", {
|
||||
"domain": domain,
|
||||
"capability_type": capability_type,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"keywords": keywords or [],
|
||||
})
|
||||
return json.dumps(entry, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_capabilities(
|
||||
domain: str | None = None,
|
||||
capability_type: str | None = None,
|
||||
) -> str:
|
||||
"""Browse the capability catalog — what domains can provide.
|
||||
|
||||
Args:
|
||||
domain: Filter by domain slug (optional)
|
||||
capability_type: Filter by type (optional)
|
||||
"""
|
||||
return json.dumps(_get("/capability-catalog", {
|
||||
"domain": domain,
|
||||
"capability_type": capability_type,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def request_capability(
|
||||
title: str,
|
||||
description: str,
|
||||
capability_type: str,
|
||||
requesting_agent: str,
|
||||
requesting_domain: str,
|
||||
requesting_workstream_id: str | None = None,
|
||||
priority: str = "medium",
|
||||
blocking_task_id: str | None = None,
|
||||
) -> str:
|
||||
"""Request a capability from another domain. Auto-routes to the responsible
|
||||
domain via the capability catalog. If no unique match, broadcasts to all.
|
||||
|
||||
Args:
|
||||
title: Short title (e.g. 'Privacy idea instance on cluster')
|
||||
description: Detailed description of what you need
|
||||
capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security')
|
||||
requesting_agent: Your agent identifier (e.g. 'net-kingdom-worker')
|
||||
requesting_domain: Your domain slug (e.g. 'custodian')
|
||||
requesting_workstream_id: UUID of your workstream (optional)
|
||||
priority: low | medium | high | critical (default: medium)
|
||||
blocking_task_id: UUID of the task blocked until this is fulfilled (optional)
|
||||
"""
|
||||
req = _post("/capability-requests", {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"capability_type": capability_type,
|
||||
"requesting_agent": requesting_agent,
|
||||
"requesting_domain": requesting_domain,
|
||||
"requesting_workstream_id": requesting_workstream_id,
|
||||
"priority": priority,
|
||||
"blocking_task_id": blocking_task_id,
|
||||
})
|
||||
_post("/progress", {
|
||||
"event_type": "capability_requested",
|
||||
"summary": f"Capability requested: {title} ({capability_type})",
|
||||
"author": requesting_agent,
|
||||
"detail": {
|
||||
"capability_request_id": req.get("id"),
|
||||
"capability_type": capability_type,
|
||||
"routed_to": req.get("fulfilling_domain_slug"),
|
||||
},
|
||||
})
|
||||
return json.dumps(req, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def accept_capability_request(
|
||||
request_id: str,
|
||||
fulfilling_agent: str,
|
||||
fulfilling_workstream_id: str | None = None,
|
||||
) -> str:
|
||||
"""Accept a capability request. Assigns yourself as the fulfilling agent.
|
||||
|
||||
Args:
|
||||
request_id: UUID of the capability request
|
||||
fulfilling_agent: Your agent identifier (e.g. 'railiance-worker')
|
||||
fulfilling_workstream_id: UUID of your workstream for this work (optional)
|
||||
"""
|
||||
result = _post(f"/capability-requests/{request_id}/accept", {
|
||||
"fulfilling_agent": fulfilling_agent,
|
||||
"fulfilling_workstream_id": fulfilling_workstream_id,
|
||||
})
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_capability_request_status(
|
||||
request_id: str,
|
||||
status: str,
|
||||
note: str | None = None,
|
||||
) -> str:
|
||||
"""Advance a capability request through its lifecycle.
|
||||
|
||||
On 'completed': auto-unblocks the blocking task if one was set.
|
||||
|
||||
Args:
|
||||
request_id: UUID of the capability request
|
||||
status: in_progress | ready_for_review | completed | rejected | withdrawn
|
||||
note: Optional note (required for rejection, recommended for completion)
|
||||
"""
|
||||
result = _patch(f"/capability-requests/{request_id}/status", {
|
||||
"status": status,
|
||||
"note": note,
|
||||
})
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_capability_requests(
|
||||
domain: str | None = None,
|
||||
status: str | None = None,
|
||||
capability_type: str | None = None,
|
||||
) -> str:
|
||||
"""List capability requests with optional filters.
|
||||
|
||||
Args:
|
||||
domain: Filter by requesting OR fulfilling domain slug
|
||||
status: Filter by status (requested/accepted/in_progress/ready_for_review/completed/rejected/withdrawn)
|
||||
capability_type: Filter by capability type
|
||||
"""
|
||||
return json.dumps(_get("/capability-requests", {
|
||||
"domain": domain,
|
||||
"status": status,
|
||||
"capability_type": capability_type,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_capability_request(request_id: str) -> str:
|
||||
"""Get a single capability request by ID.
|
||||
|
||||
Args:
|
||||
request_id: UUID of the capability request
|
||||
"""
|
||||
return json.dumps(_get(f"/capability-requests/{request_id}"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""add capability_catalog and capability_requests tables
|
||||
|
||||
Revision ID: i6d7e8f9a0b1
|
||||
Revises: h5c6d7e8f9a0
|
||||
Create Date: 2026-03-19
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "i6d7e8f9a0b1"
|
||||
down_revision = "h5c6d7e8f9a0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"capability_catalog",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("domain_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False, index=True),
|
||||
sa.Column("capability_type", sa.String(50), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("keywords", sa.ARRAY(sa.String), nullable=False, server_default="{}"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"capability_requests",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("capability_type", sa.String(50), nullable=False),
|
||||
sa.Column("priority", sa.String(20), nullable=False, server_default="medium"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="requested"),
|
||||
sa.Column("requesting_domain_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False, index=True),
|
||||
sa.Column("requesting_workstream_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
nullable=True),
|
||||
sa.Column("requesting_agent", sa.String(100), nullable=False),
|
||||
sa.Column("fulfilling_domain_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("domains.id", ondelete="SET NULL"),
|
||||
nullable=True, index=True),
|
||||
sa.Column("fulfilling_workstream_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
nullable=True),
|
||||
sa.Column("fulfilling_agent", sa.String(100), nullable=True),
|
||||
sa.Column("blocking_task_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True),
|
||||
sa.Column("catalog_entry_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("capability_catalog.id", ondelete="SET NULL"),
|
||||
nullable=True),
|
||||
sa.Column("resolution_note", sa.Text, nullable=True),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("capability_requests")
|
||||
op.drop_table("capability_catalog")
|
||||
253
state-hub/scripts/ingest_capabilities.py
Normal file
253
state-hub/scripts/ingest_capabilities.py
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ingest capability declarations from SCOPE.md files into the State Hub catalog.
|
||||
|
||||
Reads ``## Provided Capabilities`` sections from SCOPE.md files in registered
|
||||
repos and upserts them into the capability_catalog table via the API.
|
||||
|
||||
Usage:
|
||||
python ingest_capabilities.py --repo <slug> [--repo-path <path>] [--dry-run]
|
||||
python ingest_capabilities.py --all [--dry-run]
|
||||
|
||||
Capability blocks in SCOPE.md use this format:
|
||||
|
||||
```capability
|
||||
type: infrastructure
|
||||
title: Cluster provisioning
|
||||
description: Provision k8s clusters for any domain.
|
||||
keywords: [cluster, k8s, privacy, instance]
|
||||
```
|
||||
|
||||
Follows ADR-001: SCOPE.md files are the origin of truth; the DB catalog is
|
||||
a derived index that can be fully reconstructed from repo files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
_YAML_AVAILABLE = True
|
||||
except ImportError:
|
||||
_YAML_AVAILABLE = False
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SCOPE.md parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CAPABILITY_BLOCK_RE = re.compile(r"```capability\s*\n(.*?)\n```", re.DOTALL)
|
||||
|
||||
|
||||
def parse_capabilities(scope_path: Path) -> list[dict]:
|
||||
"""Extract capability blocks from a SCOPE.md file."""
|
||||
if not scope_path.exists():
|
||||
return []
|
||||
text = scope_path.read_text()
|
||||
blocks = _CAPABILITY_BLOCK_RE.findall(text)
|
||||
capabilities = []
|
||||
for block in blocks:
|
||||
cap = _parse_yaml_block(block)
|
||||
if cap.get("type") and cap.get("title"):
|
||||
capabilities.append({
|
||||
"capability_type": cap["type"],
|
||||
"title": cap["title"],
|
||||
"description": cap.get("description", ""),
|
||||
"keywords": cap.get("keywords", []),
|
||||
})
|
||||
return capabilities
|
||||
|
||||
|
||||
def _parse_yaml_block(text: str) -> dict:
|
||||
"""Parse a YAML-like key: value block. Uses PyYAML if available, falls back to manual."""
|
||||
if _YAML_AVAILABLE:
|
||||
try:
|
||||
result = yaml.safe_load(text)
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
# Fallback: manual key: value parsing
|
||||
result = {}
|
||||
for line in text.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, val = line.partition(":")
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
if val.startswith("[") and val.endswith("]"):
|
||||
# Parse simple list: [a, b, c]
|
||||
inner = val[1:-1]
|
||||
result[key] = [v.strip().strip("'\"") for v in inner.split(",") if v.strip()]
|
||||
else:
|
||||
result[key] = val.strip("'\"")
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _api_get(path: str) -> dict | list | None:
|
||||
url = f"{API_BASE}{path}"
|
||||
# Add trailing slash before query params for FastAPI redirect avoidance
|
||||
if "?" in url:
|
||||
base, qs = url.split("?", 1)
|
||||
if not base.endswith("/"):
|
||||
base += "/"
|
||||
url = f"{base}?{qs}"
|
||||
elif not url.endswith("/"):
|
||||
url += "/"
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f" GET {url} failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _api_post(path: str, body: dict) -> dict | None:
|
||||
url = f"{API_BASE}{path}"
|
||||
if not url.endswith("/"):
|
||||
url += "/"
|
||||
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode()[:200]
|
||||
print(f" POST {url} → {e.code}: {body_text}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" POST {url} failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repo_path(repo: dict, override: str | None = None) -> str:
|
||||
if override:
|
||||
return override
|
||||
hostname = socket.gethostname()
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
return host_paths.get(hostname) or repo.get("local_path") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ingest logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ingest_repo(repo_slug: str, repo_path_override: str | None = None, dry_run: bool = False) -> int:
|
||||
"""Ingest capabilities from one repo's SCOPE.md. Returns count of capabilities found."""
|
||||
repo = _api_get(f"/repos/{repo_slug}")
|
||||
if repo is None:
|
||||
print(f" Repo '{repo_slug}' not found in state-hub", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
repo_path = resolve_repo_path(repo, repo_path_override)
|
||||
if not repo_path:
|
||||
print(f" Repo '{repo_slug}' has no local path on this host", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
scope_path = Path(repo_path) / "SCOPE.md"
|
||||
if not scope_path.exists():
|
||||
print(f" {repo_slug}: no SCOPE.md at {scope_path}")
|
||||
return 0
|
||||
|
||||
capabilities = parse_capabilities(scope_path)
|
||||
if not capabilities:
|
||||
print(f" {repo_slug}: no capability blocks in SCOPE.md")
|
||||
return 0
|
||||
|
||||
# Resolve domain slug for this repo
|
||||
domain_slug = repo.get("domain_slug")
|
||||
if not domain_slug:
|
||||
# Fetch domain from repo's domain_id
|
||||
domains = _api_get("/domains/") or []
|
||||
domain_map = {d["id"]: d["slug"] for d in domains if isinstance(d, dict)}
|
||||
domain_slug = domain_map.get(repo.get("domain_id"), "")
|
||||
if not domain_slug:
|
||||
print(f" {repo_slug}: cannot resolve domain slug", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Get existing catalog entries for this domain to avoid duplicates
|
||||
existing = _api_get(f"/capability-catalog/?domain={domain_slug}&status=all") or []
|
||||
existing_keys = {(e["capability_type"], e["title"]) for e in existing if isinstance(e, dict)}
|
||||
|
||||
count = 0
|
||||
for cap in capabilities:
|
||||
key = (cap["capability_type"], cap["title"])
|
||||
if key in existing_keys:
|
||||
print(f" {repo_slug}: skip (exists) {cap['capability_type']}/{cap['title']}")
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" {repo_slug}: [dry-run] would create {cap['capability_type']}/{cap['title']}")
|
||||
else:
|
||||
result = _api_post("/capability-catalog", {
|
||||
"domain": domain_slug,
|
||||
"capability_type": cap["capability_type"],
|
||||
"title": cap["title"],
|
||||
"description": cap["description"],
|
||||
"keywords": cap["keywords"],
|
||||
})
|
||||
if result:
|
||||
print(f" {repo_slug}: created {cap['capability_type']}/{cap['title']} → {result.get('id', '?')[:8]}")
|
||||
else:
|
||||
print(f" {repo_slug}: FAILED to create {cap['capability_type']}/{cap['title']}")
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def ingest_all(dry_run: bool = False) -> None:
|
||||
"""Ingest capabilities from all registered repos."""
|
||||
repos = _api_get("/repos/") or []
|
||||
total = 0
|
||||
for repo in repos:
|
||||
slug = repo.get("slug", "")
|
||||
if not slug:
|
||||
continue
|
||||
print(f"\n[{slug}]")
|
||||
total += ingest_repo(slug, dry_run=dry_run)
|
||||
print(f"\nDone. {total} capability entries {'would be ' if dry_run else ''}ingested.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Ingest capabilities from SCOPE.md into state-hub catalog")
|
||||
parser.add_argument("--repo", help="Repo slug to ingest")
|
||||
parser.add_argument("--repo-path", help="Override repo filesystem path")
|
||||
parser.add_argument("--all", action="store_true", help="Ingest from all registered repos")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print what would be ingested without writing")
|
||||
parser.add_argument("--api-base", help="Override API base URL")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.api_base:
|
||||
global API_BASE
|
||||
API_BASE = args.api_base.rstrip("/")
|
||||
|
||||
if args.all:
|
||||
ingest_all(dry_run=args.dry_run)
|
||||
elif args.repo:
|
||||
print(f"[{args.repo}]")
|
||||
count = ingest_repo(args.repo, repo_path_override=args.repo_path, dry_run=args.dry_run)
|
||||
print(f"\nDone. {count} capability entries {'would be ' if args.dry_run else ''}ingested.")
|
||||
else:
|
||||
parser.error("Specify --repo <slug> or --all")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -115,6 +115,23 @@
|
||||
|
||||
---
|
||||
|
||||
## Provided Capabilities
|
||||
|
||||
<!-- What can this repo's domain provide to other domains on request? -->
|
||||
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
|
||||
<!-- Remove the examples and add your own, or leave empty if none. -->
|
||||
|
||||
<!--
|
||||
```capability
|
||||
type: infrastructure
|
||||
title: Example capability title
|
||||
description: What this capability provides, in one or two sentences.
|
||||
keywords: [keyword1, keyword2, keyword3]
|
||||
```
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Anything else worth knowing. Keep it short. -->
|
||||
|
||||
337
state-hub/tests/test_capability_requests.py
Normal file
337
state-hub/tests/test_capability_requests.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Capability Request system tests: catalog CRUD, request lifecycle, routing,
|
||||
auto-notifications, and task unblocking.
|
||||
|
||||
All tests use a real PostgreSQL test database (no mocking).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _create_domain(client, slug="testdomain", name="Test Domain"):
|
||||
r = await client.post("/domains/", json={"slug": slug, "name": name})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_topic(client, domain_slug="testdomain"):
|
||||
r = await client.post("/topics/", json={
|
||||
"slug": "testtopic", "title": "Test Topic", "domain": domain_slug,
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_workstream(client, topic_id):
|
||||
r = await client.post("/workstreams/", json={
|
||||
"topic_id": topic_id, "slug": "test-ws", "title": "Test WS",
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_task(client, workstream_id, title="Test task", status="blocked"):
|
||||
r = await client.post("/tasks/", json={
|
||||
"workstream_id": workstream_id, "title": title,
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
task = r.json()
|
||||
if status != "todo":
|
||||
patch = {"status": status}
|
||||
if status == "blocked":
|
||||
patch["blocking_reason"] = "Waiting for capability request"
|
||||
r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
|
||||
assert r2.status_code == 200, r2.text
|
||||
return r2.json()
|
||||
return task
|
||||
|
||||
|
||||
async def _setup_two_domains(client):
|
||||
"""Create two domains: 'custodian' (requester) and 'railiance' (provider)."""
|
||||
req_domain = await _create_domain(client, "custodian", "Custodian")
|
||||
ful_domain = await _create_domain(client, "railiance", "Railiance")
|
||||
return req_domain, ful_domain
|
||||
|
||||
|
||||
async def _register_catalog(client, domain="railiance", cap_type="infrastructure",
|
||||
title="Cluster provisioning", keywords=None):
|
||||
r = await client.post("/capability-catalog/", json={
|
||||
"domain": domain,
|
||||
"capability_type": cap_type,
|
||||
"title": title,
|
||||
"keywords": keywords or ["cluster", "k8s", "privacy"],
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_request(client, title="Privacy idea instance",
|
||||
description="Need a privacy idea instance on the cluster",
|
||||
cap_type="infrastructure", agent="net-kingdom-worker",
|
||||
domain="custodian", **kwargs):
|
||||
r = await client.post("/capability-requests/", json={
|
||||
"title": title,
|
||||
"description": description,
|
||||
"capability_type": cap_type,
|
||||
"requesting_agent": agent,
|
||||
"requesting_domain": domain,
|
||||
**kwargs,
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCapabilityCatalog:
|
||||
async def test_register_and_list(self, client):
|
||||
await _setup_two_domains(client)
|
||||
entry = await _register_catalog(client)
|
||||
assert entry["capability_type"] == "infrastructure"
|
||||
assert entry["domain_slug"] == "railiance"
|
||||
|
||||
r = await client.get("/capability-catalog/")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
|
||||
async def test_duplicate_entry_rejected(self, client):
|
||||
await _setup_two_domains(client)
|
||||
await _register_catalog(client)
|
||||
r = await client.post("/capability-catalog/", json={
|
||||
"domain": "railiance",
|
||||
"capability_type": "infrastructure",
|
||||
"title": "Cluster provisioning",
|
||||
"keywords": [],
|
||||
})
|
||||
assert r.status_code == 409
|
||||
|
||||
async def test_filter_by_domain_and_type(self, client):
|
||||
await _setup_two_domains(client)
|
||||
await _register_catalog(client, domain="railiance", cap_type="infrastructure")
|
||||
await _register_catalog(client, domain="railiance", cap_type="security",
|
||||
title="TLS cert provisioning", keywords=["tls", "cert"])
|
||||
await _register_catalog(client, domain="custodian", cap_type="api",
|
||||
title="MCP tool registration", keywords=["mcp"])
|
||||
|
||||
# Filter by domain
|
||||
r = await client.get("/capability-catalog/", params={"domain": "railiance"})
|
||||
assert len(r.json()) == 2
|
||||
|
||||
# Filter by type
|
||||
r = await client.get("/capability-catalog/", params={"capability_type": "api"})
|
||||
assert len(r.json()) == 1
|
||||
assert r.json()[0]["domain_slug"] == "custodian"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request lifecycle tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCapabilityRequestLifecycle:
|
||||
async def test_create_auto_routes_single_match(self, client):
|
||||
await _setup_two_domains(client)
|
||||
await _register_catalog(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
assert req["status"] == "requested"
|
||||
assert req["fulfilling_domain_slug"] == "railiance"
|
||||
assert req["catalog_entry_id"] is not None
|
||||
|
||||
async def test_create_broadcasts_when_no_catalog(self, client):
|
||||
await _setup_two_domains(client)
|
||||
req = await _create_request(client, cap_type="documentation")
|
||||
|
||||
assert req["status"] == "requested"
|
||||
assert req["fulfilling_domain_slug"] is None
|
||||
assert req["catalog_entry_id"] is None
|
||||
|
||||
async def test_create_broadcasts_when_ambiguous(self, client):
|
||||
await _setup_two_domains(client)
|
||||
# Two entries for same type, equal keyword scores
|
||||
await _register_catalog(client, domain="railiance", cap_type="infrastructure",
|
||||
title="K8s clusters", keywords=["k8s"])
|
||||
await _register_catalog(client, domain="custodian", cap_type="infrastructure",
|
||||
title="Local infra", keywords=["local"])
|
||||
|
||||
req = await _create_request(client, description="Need something generic")
|
||||
# Neither keyword matches, should broadcast
|
||||
assert req["fulfilling_domain_slug"] is None
|
||||
|
||||
async def test_valid_transitions(self, client):
|
||||
await _setup_two_domains(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
# accepted
|
||||
r = await client.post(f"/capability-requests/{req['id']}/accept", json={
|
||||
"fulfilling_agent": "railiance-worker",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "accepted"
|
||||
assert r.json()["accepted_at"] is not None
|
||||
|
||||
# in_progress
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "in_progress",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "in_progress"
|
||||
|
||||
# ready_for_review
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "ready_for_review", "note": "Privacy instance is up",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ready_for_review"
|
||||
|
||||
# completed
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "completed", "note": "Verified and running",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "completed"
|
||||
assert r.json()["completed_at"] is not None
|
||||
|
||||
async def test_invalid_transitions_422(self, client):
|
||||
await _setup_two_domains(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
# requested → in_progress (must accept first)
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "in_progress",
|
||||
})
|
||||
assert r.status_code == 422
|
||||
|
||||
# requested → completed (skip steps)
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "completed",
|
||||
})
|
||||
assert r.status_code == 422
|
||||
|
||||
async def test_accept_sets_fulfilling_fields(self, client):
|
||||
await _setup_two_domains(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
r = await client.post(f"/capability-requests/{req['id']}/accept", json={
|
||||
"fulfilling_agent": "railiance-worker",
|
||||
})
|
||||
data = r.json()
|
||||
assert data["fulfilling_agent"] == "railiance-worker"
|
||||
assert data["accepted_at"] is not None
|
||||
|
||||
async def test_complete_unblocks_blocking_task(self, client):
|
||||
await _setup_two_domains(client)
|
||||
topic = await _create_topic(client, "custodian")
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
task = await _create_task(client, ws["id"], status="blocked")
|
||||
|
||||
req = await _create_request(client, blocking_task_id=task["id"])
|
||||
|
||||
# Walk through lifecycle
|
||||
await client.post(f"/capability-requests/{req['id']}/accept", json={
|
||||
"fulfilling_agent": "railiance-worker",
|
||||
})
|
||||
await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "in_progress",
|
||||
})
|
||||
await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "ready_for_review",
|
||||
})
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "completed",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Verify task was unblocked
|
||||
r = await client.get(f"/tasks/{task['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "todo"
|
||||
|
||||
async def test_complete_without_blocking_task(self, client):
|
||||
await _setup_two_domains(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
await client.post(f"/capability-requests/{req['id']}/accept", json={
|
||||
"fulfilling_agent": "railiance-worker",
|
||||
})
|
||||
await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "in_progress",
|
||||
})
|
||||
await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "ready_for_review",
|
||||
})
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "completed",
|
||||
})
|
||||
# Should succeed without error (no task to unblock)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "completed"
|
||||
|
||||
async def test_notification_created_on_each_transition(self, client):
|
||||
await _setup_two_domains(client)
|
||||
await _register_catalog(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
# Check notification was sent on creation (to railiance since it was auto-routed)
|
||||
r = await client.get("/messages/", params={"to_agent": "railiance"})
|
||||
msgs = r.json()
|
||||
assert any("[capability-request]" in m["subject"] for m in msgs)
|
||||
|
||||
# Accept
|
||||
await client.post(f"/capability-requests/{req['id']}/accept", json={
|
||||
"fulfilling_agent": "railiance-worker",
|
||||
})
|
||||
r = await client.get("/messages/", params={"to_agent": "net-kingdom-worker"})
|
||||
msgs = r.json()
|
||||
assert any("[capability-accepted]" in m["subject"] for m in msgs)
|
||||
|
||||
# ready_for_review
|
||||
await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "in_progress",
|
||||
})
|
||||
await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "ready_for_review",
|
||||
})
|
||||
r = await client.get("/messages/", params={"to_agent": "net-kingdom-worker"})
|
||||
msgs = r.json()
|
||||
assert any("[capability-ready]" in m["subject"] for m in msgs)
|
||||
|
||||
async def test_list_filters_by_domain_status_type(self, client):
|
||||
await _setup_two_domains(client)
|
||||
await _create_request(client, title="Req A")
|
||||
await _create_request(client, title="Req B", cap_type="security")
|
||||
|
||||
# Filter by type
|
||||
r = await client.get("/capability-requests/", params={"capability_type": "security"})
|
||||
assert len(r.json()) == 1
|
||||
assert r.json()[0]["title"] == "Req B"
|
||||
|
||||
# Filter by status
|
||||
r = await client.get("/capability-requests/", params={"status": "requested"})
|
||||
assert len(r.json()) == 2
|
||||
|
||||
# Filter by domain
|
||||
r = await client.get("/capability-requests/", params={"domain": "custodian"})
|
||||
assert len(r.json()) == 2 # requesting domain is custodian for both
|
||||
|
||||
async def test_withdrawn_transition(self, client):
|
||||
await _setup_two_domains(client)
|
||||
req = await _create_request(client)
|
||||
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "withdrawn", "note": "No longer needed",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "withdrawn"
|
||||
|
||||
# Terminal — cannot transition further
|
||||
r = await client.patch(f"/capability-requests/{req['id']}/status", json={
|
||||
"status": "requested",
|
||||
})
|
||||
assert r.status_code == 422
|
||||
146
workplans/CUST-WP-0022-capability-requests.md
Normal file
146
workplans/CUST-WP-0022-capability-requests.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
id: CUST-WP-0022
|
||||
type: workplan
|
||||
title: Capability Request System
|
||||
domain: custodian
|
||||
status: done
|
||||
owner: custodian
|
||||
topic_slug: the-custodian
|
||||
created: "2026-03-19"
|
||||
updated: "2026-03-19"
|
||||
state_hub_workstream_id: "7cc173c6-f63e-43b0-af8e-04df7f95b96c"
|
||||
---
|
||||
|
||||
# CUST-WP-0022 — Capability Request System
|
||||
|
||||
## Purpose
|
||||
|
||||
Enable cross-domain capability requests without requiring the requester to know
|
||||
which domain is responsible. A net-kingdom dev-worker can say "I need a privacy
|
||||
idea instance on the cluster" and the system routes it to railiance, manages the
|
||||
fulfillment lifecycle, and auto-notifies when work is ready for review.
|
||||
|
||||
## Scope
|
||||
|
||||
- `capability_catalog` table — domains register capabilities they can provide
|
||||
- `capability_requests` table — lifecycle from requested → completed with auto-routing
|
||||
- Soft routing via keyword matching; broadcast fallback when ambiguous
|
||||
- Auto-notifications via AgentMessage on every lifecycle transition
|
||||
- Auto-unblock of blocking tasks on completion
|
||||
- 7 MCP tools, 1 dashboard page, StateSummary integration
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `migrations/versions/i6d7e8f9a0b1_capability_requests.py` | New migration |
|
||||
| `api/models/capability_catalog.py` | New model |
|
||||
| `api/models/capability_request.py` | New model |
|
||||
| `api/models/__init__.py` | Export new models |
|
||||
| `api/schemas/capability_request.py` | New schemas (Catalog + Request CRUD) |
|
||||
| `api/routers/capability_requests.py` | New router (lifecycle guard, routing, auto-notify) |
|
||||
| `api/main.py` | Mount new router |
|
||||
| `api/schemas/state.py` | Added `open_capability_requests` to StateSummary |
|
||||
| `api/routers/state.py` | Count open requests in summary builder |
|
||||
| `mcp_server/server.py` | 7 new MCP tools |
|
||||
| `dashboard/src/capability-requests.md` | New dashboard page (Kanban + KPIs) |
|
||||
| `dashboard/observablehq.config.js` | Nav entry |
|
||||
| `tests/test_capability_requests.py` | 14 test cases |
|
||||
| `scripts/ingest_capabilities.py` | Ingest `capability` blocks from SCOPE.md → catalog API |
|
||||
| `scripts/project_rules/scope.template` | Added `## Provided Capabilities` section |
|
||||
| `SCOPE.md` (repo root) | Added 3 capability blocks for custodian domain |
|
||||
| `dashboard/src/docs/capabilities.md` | Reference doc for capabilities |
|
||||
| `dashboard/src/docs/scope.md` | Reference doc for SCOPE.md |
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01: Write TDD test suite
|
||||
```task
|
||||
id: CUST-WP-0022-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "34f40ede-3396-4ef2-ae5a-9af4edbfc17d"
|
||||
```
|
||||
|
||||
### T02: Alembic migration
|
||||
```task
|
||||
id: CUST-WP-0022-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "094fd9de-e4fa-4b9f-bc70-31f049907fa9"
|
||||
```
|
||||
|
||||
### T03: SQLAlchemy models
|
||||
```task
|
||||
id: CUST-WP-0022-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5fadabfb-100e-493e-a950-2b5c3d286c6b"
|
||||
```
|
||||
|
||||
### T04: Pydantic schemas
|
||||
```task
|
||||
id: CUST-WP-0022-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1efabfde-e9de-41d9-a864-2eb1e9e2dae6"
|
||||
```
|
||||
|
||||
### T05: Router with lifecycle + routing + auto-notify
|
||||
```task
|
||||
id: CUST-WP-0022-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "57f754b6-022c-4829-adb5-45629567d575"
|
||||
```
|
||||
|
||||
### T06: Run tests, iterate until green
|
||||
```task
|
||||
id: CUST-WP-0022-T06
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "41ef3722-ffbe-41bb-9ef4-8485db37bb0a"
|
||||
```
|
||||
|
||||
### T07: MCP tools
|
||||
```task
|
||||
id: CUST-WP-0022-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "12f28272-2767-4d8e-80a0-a28bb172d6af"
|
||||
```
|
||||
|
||||
### T08: Dashboard page
|
||||
```task
|
||||
id: CUST-WP-0022-T08
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "d6596ac4-53ac-4b44-bdd2-635b5c109372"
|
||||
```
|
||||
|
||||
### T09: StateSummary integration
|
||||
```task
|
||||
id: CUST-WP-0022-T09
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "964e7f8b-e9f6-41f9-9eb1-ba35e48adf55"
|
||||
```
|
||||
|
||||
### T10: Seed catalog entries (file-first via SCOPE.md)
|
||||
```task
|
||||
id: CUST-WP-0022-T10
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "2c747cde-bc00-4fd9-a3cd-625709ac6d6d"
|
||||
```
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Routing algorithm**: Exact match on capability_type in catalog. If 1 result → auto-assign.
|
||||
If multiple → keyword-score description against entry keywords, pick unambiguous winner.
|
||||
If 0 or tied → fulfilling_domain_id=NULL, broadcast AgentMessage.
|
||||
- **Lifecycle transitions** guarded by `_VALID_TRANSITIONS` dict (same pattern as contributions).
|
||||
- **Auto-unblock**: On `completed`, if `blocking_task_id` set and task status is `blocked`,
|
||||
patches it to `todo` and clears `blocking_reason`.
|
||||
- **Status uses String(20)** not SA Enum — avoids ALTER TYPE pain in future migrations.
|
||||
- **Third sanctioned write use case** — alongside resolve_decision and get_next_steps.
|
||||
Reference in New Issue
Block a user