generated from coulomb/repo-seed
Finish CUST-WP-0011 and implement STATE-WP-0061 suggestion backlog.
Add persisted Suggestion entities with WSJF ranking, relevance bumps, REST/MCP write surfaces, /suggestions dashboard page, and daily triage digest integration. Document the cluster operating model, archive the completed migration workplan, and seed WARDEN-WP-0012 routing scenarios.
This commit is contained in:
@@ -18,8 +18,11 @@ there is no MCP server for Codex agents.
|
||||
|
||||
| Context | URL |
|
||||
|---------|-----|
|
||||
| Local workstation | `http://127.0.0.1:8000` |
|
||||
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||
| Primary (cluster via tunnel) | `http://127.0.0.1:8000` |
|
||||
| Remote machine mesh | `http://127.0.0.1:18000` |
|
||||
|
||||
Cluster operating model (access, rollback, backups):
|
||||
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md)
|
||||
|
||||
### Orient at session start
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ general application platform. When State Hub detects facts, it records and
|
||||
exposes them; when new work must be spawned from events, that responsibility
|
||||
belongs to activity-core or a human-approved workflow.
|
||||
|
||||
**Sanctioned writes** (STATE-WP-0061): besides `resolve_decision` and progress
|
||||
events, the hub may persist gated needs as `Suggestion` records, bump demand
|
||||
`relevance`, vet/decline/promote them into real tasks, and project WSJF ranking
|
||||
as a read model for daily triage.
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
|
||||
@@ -50,6 +50,11 @@ then run consistency sync.
|
||||
|
||||
All services bind to `127.0.0.1` only — nothing exposed to the network.
|
||||
|
||||
**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation
|
||||
port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See
|
||||
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access,
|
||||
rollback, backups, and pragmatic limitations.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
3
SCOPE.md
3
SCOPE.md
@@ -15,7 +15,8 @@ tooling, and dashboard telemetry.
|
||||
- repo registration (classification-driven) and consistency synchronization
|
||||
- repo classification spine (14 market domains, `.repo-classification.yaml`)
|
||||
- task-flow engine and flow definitions
|
||||
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
|
||||
- SBOM, contribution, capability, demand-weighted suggestion backlog, TPSC, DoI,
|
||||
token, and interface-change tracking
|
||||
- State Hub tests, operational docs, policies, prompts, and local infra
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
@@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
|
||||
from api.database import engine
|
||||
from api.events import shutdown_publisher
|
||||
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
@@ -123,6 +123,7 @@ app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(suggestions.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(token_events.router)
|
||||
|
||||
@@ -34,6 +34,12 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
||||
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
||||
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||
from api.models.suggestion import (
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
SuggestionRelevanceBump,
|
||||
SuggestionStage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -67,4 +73,5 @@ __all__ = [
|
||||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||
"WriteIdempotencyKey",
|
||||
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
||||
]
|
||||
120
api/models/suggestion.py
Normal file
120
api/models/suggestion.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class SuggestionStage(str, enum.Enum):
|
||||
suggestion = "suggestion"
|
||||
requirement = "requirement"
|
||||
promoted = "promoted"
|
||||
declined = "declined"
|
||||
|
||||
|
||||
OPEN_SUGGESTION_STAGES = (SuggestionStage.suggestion, SuggestionStage.requirement)
|
||||
|
||||
|
||||
class Suggestion(Base, TimestampMixin):
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
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,
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
stage: Mapped[SuggestionStage] = mapped_column(
|
||||
Enum(SuggestionStage, name="suggestionstage"),
|
||||
nullable=False,
|
||||
default=SuggestionStage.suggestion,
|
||||
index=True,
|
||||
)
|
||||
relevance: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
relevance_events: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
last_requested_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
base_value: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
|
||||
job_size: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
|
||||
relevance_weight: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=1.0, server_default="1"
|
||||
)
|
||||
promoted_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
promoted_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["SuggestionNote"]] = relationship(
|
||||
"SuggestionNote",
|
||||
back_populates="suggestion",
|
||||
lazy="selectin",
|
||||
order_by="SuggestionNote.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
||||
|
||||
class SuggestionNote(Base):
|
||||
__tablename__ = "suggestion_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
suggestion_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("suggestions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
stage: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
suggestion: Mapped["Suggestion"] = relationship("Suggestion", back_populates="notes")
|
||||
|
||||
|
||||
class SuggestionRelevanceBump(Base):
|
||||
"""Audit trail for relevance bumps; supports debounce lookups."""
|
||||
|
||||
__tablename__ = "suggestion_relevance_bumps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
suggestion_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("suggestions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
source_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from api.models.capability_request import CapabilityRequest
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.services.suggestion_relevance import bump_matching_for_capability_request
|
||||
from api.schemas.capability_request import (
|
||||
CapabilityRequestAccept,
|
||||
CapabilityRequestCreate,
|
||||
@@ -68,6 +69,23 @@ def _build_capability_request(
|
||||
)
|
||||
|
||||
|
||||
async def _on_capability_request_persisted(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestCreate,
|
||||
) -> None:
|
||||
await session.flush()
|
||||
await bump_matching_for_capability_request(
|
||||
session,
|
||||
request_id=req.id,
|
||||
title=body.title,
|
||||
description=body.description or "",
|
||||
capability_type=body.capability_type,
|
||||
catalog_entry_id=req.catalog_entry_id,
|
||||
)
|
||||
await _notify_on_create(session, req, body)
|
||||
|
||||
|
||||
async def _notify_on_create(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
@@ -405,7 +423,7 @@ router.include_router(
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
route_request=_route_capability,
|
||||
build_request=_build_capability_request,
|
||||
on_request_persisted=_notify_on_create,
|
||||
on_request_persisted=_on_capability_request_persisted,
|
||||
check_transition=_check_transition,
|
||||
apply_accept_fields=_apply_accept_fields,
|
||||
after_accept=_notify_on_accept,
|
||||
|
||||
@@ -21,8 +21,12 @@ from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.suggestion import OPEN_SUGGESTION_STAGES, Suggestion
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.suggestion import RankedSuggestionDigest
|
||||
from api.services.suggestion_relevance import bump_suggestions_for_next_steps
|
||||
from api.services.suggestion_wsjf import compute_wsjf, suggestion_sort_key
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
@@ -329,7 +333,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
),
|
||||
)
|
||||
|
||||
next_steps = await _derive_next_steps(session)
|
||||
next_steps, _ = await _derive_next_steps(session)
|
||||
|
||||
# Domain summary stats
|
||||
domain_summaries = await _build_domain_summaries(session)
|
||||
@@ -368,6 +372,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
ranked_suggestions = await _ranked_suggestion_digest(session, limit=10)
|
||||
|
||||
result = StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
totals=totals,
|
||||
@@ -387,6 +393,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
contribution_counts=contribution_counts,
|
||||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
ranked_suggestions=ranked_suggestions,
|
||||
open_workstreams=[
|
||||
WorkstreamWithDeps(
|
||||
**{
|
||||
@@ -700,7 +707,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||
next_steps=await _derive_next_steps(session),
|
||||
next_steps=(await _derive_next_steps(session))[0],
|
||||
contribution_counts=contribution_counts,
|
||||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
@@ -852,7 +859,33 @@ _PRIORITY_RANK = {
|
||||
}
|
||||
|
||||
|
||||
async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
async def _ranked_suggestion_digest(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> list[RankedSuggestionDigest]:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
)
|
||||
).scalars().all()
|
||||
ranked = sorted(rows, key=suggestion_sort_key)[:limit]
|
||||
return [
|
||||
RankedSuggestionDigest(
|
||||
id=s.id,
|
||||
title=s.title,
|
||||
stage=s.stage,
|
||||
domain_slug=s.domain_slug,
|
||||
origin_ref=s.origin_ref,
|
||||
relevance=s.relevance,
|
||||
wsjf=compute_wsjf(s),
|
||||
last_requested_at=s.last_requested_at,
|
||||
)
|
||||
for s in ranked
|
||||
]
|
||||
|
||||
|
||||
async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], list[Suggestion]]:
|
||||
"""Derive contextual next-action suggestions from current hub state.
|
||||
|
||||
Two signal sources:
|
||||
@@ -991,7 +1024,31 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
))
|
||||
seen_task_ids.add(task.id)
|
||||
|
||||
return steps
|
||||
# Signal 3: open demand-weighted suggestions (needed-but-unmet backlog)
|
||||
open_suggestions = sorted(
|
||||
(
|
||||
await session.execute(
|
||||
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
)
|
||||
).scalars().all(),
|
||||
key=suggestion_sort_key,
|
||||
)[:5]
|
||||
for suggestion in open_suggestions:
|
||||
steps.append(NextStep(
|
||||
type="open_suggestion",
|
||||
domain=suggestion.domain_slug,
|
||||
workstream_id=suggestion.workplan_id,
|
||||
workstream_title=None,
|
||||
workstream_slug=suggestion.origin_ref,
|
||||
task_id=None,
|
||||
task_title=None,
|
||||
message=(
|
||||
f"Gated need '{suggestion.title}' (stage={suggestion.stage.value}, "
|
||||
f"relevance={suggestion.relevance}) — vet or promote when unblocked"
|
||||
),
|
||||
))
|
||||
|
||||
return steps, open_suggestions
|
||||
|
||||
|
||||
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
|
||||
@@ -1019,8 +1076,12 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
||||
Returns suggestions based on:
|
||||
- Recently resolved decisions → first open task in the same workstream
|
||||
- Workstreams whose every dependency workstream is now finished -> first todo task
|
||||
- Open demand-weighted suggestions (gated needs accruing relevance)
|
||||
"""
|
||||
return await _derive_next_steps(session)
|
||||
steps, surfaced = await _derive_next_steps(session)
|
||||
await bump_suggestions_for_next_steps(session, surfaced)
|
||||
await session.commit()
|
||||
return steps
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
|
||||
259
api/routers/suggestions.py
Normal file
259
api/routers/suggestions.py
Normal file
@@ -0,0 +1,259 @@
|
||||
import uuid
|
||||
|
||||
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.domain import Domain
|
||||
from api.models.suggestion import (
|
||||
OPEN_SUGGESTION_STAGES,
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
SuggestionStage,
|
||||
)
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.schemas.suggestion import (
|
||||
SuggestionBumpRelevance,
|
||||
SuggestionCreate,
|
||||
SuggestionDecline,
|
||||
SuggestionNoteRead,
|
||||
SuggestionPromote,
|
||||
SuggestionRead,
|
||||
SuggestionVet,
|
||||
)
|
||||
from api.services.suggestion_relevance import bump_relevance
|
||||
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
|
||||
|
||||
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
|
||||
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
|
||||
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
|
||||
|
||||
|
||||
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
|
||||
row = await session.execute(
|
||||
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
|
||||
)
|
||||
domain_id = row.scalar_one_or_none()
|
||||
if domain_id is None:
|
||||
valid = [r[0] for r in (await session.execute(
|
||||
select(Domain.slug).where(Domain.status == "active")
|
||||
)).all()]
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
|
||||
)
|
||||
return domain_id
|
||||
|
||||
|
||||
def _enrich_read(suggestion: Suggestion) -> SuggestionRead:
|
||||
data = SuggestionRead.model_validate(suggestion)
|
||||
data.cost_of_delay = cost_of_delay(suggestion)
|
||||
data.wsjf = compute_wsjf(suggestion)
|
||||
return data
|
||||
|
||||
|
||||
async def _get_suggestion_or_404(
|
||||
suggestion_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Suggestion:
|
||||
suggestion = await session.get(Suggestion, suggestion_id)
|
||||
if suggestion is None:
|
||||
raise HTTPException(status_code=404, detail="Suggestion not found")
|
||||
return suggestion
|
||||
|
||||
|
||||
def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None:
|
||||
if suggestion.stage not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SuggestionRead])
|
||||
async def list_suggestions(
|
||||
domain: str | None = None,
|
||||
stage: SuggestionStage | None = None,
|
||||
include_terminal: bool = Query(False),
|
||||
rank: str | None = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SuggestionRead]:
|
||||
q = select(Suggestion)
|
||||
if domain:
|
||||
domain_id = await _resolve_domain_id(domain, session)
|
||||
q = q.where(Suggestion.domain_id == domain_id)
|
||||
if stage:
|
||||
q = q.where(Suggestion.stage == stage)
|
||||
elif not include_terminal:
|
||||
q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
result = await session.execute(q)
|
||||
suggestions = list(result.scalars().all())
|
||||
if rank == "wsjf":
|
||||
suggestions.sort(key=suggestion_sort_key)
|
||||
else:
|
||||
suggestions.sort(key=lambda s: s.created_at)
|
||||
return [_enrich_read(s) for s in suggestions[:limit]]
|
||||
|
||||
|
||||
@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_suggestion(
|
||||
body: SuggestionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
suggestion = Suggestion(
|
||||
domain_id=domain_id,
|
||||
topic_id=body.topic_id,
|
||||
workplan_id=body.workplan_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
origin=body.origin,
|
||||
origin_ref=body.origin_ref,
|
||||
base_value=body.base_value,
|
||||
job_size=body.job_size,
|
||||
relevance_weight=body.relevance_weight,
|
||||
)
|
||||
session.add(suggestion)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}", response_model=SuggestionRead)
|
||||
async def get_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/vet", response_model=SuggestionRead)
|
||||
async def vet_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionVet,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_VET_FROM, "vet")
|
||||
suggestion.stage = SuggestionStage.requirement
|
||||
if body.base_value is not None:
|
||||
suggestion.base_value = body.base_value
|
||||
if body.job_size is not None:
|
||||
suggestion.job_size = body.job_size
|
||||
if body.relevance_weight is not None:
|
||||
suggestion.relevance_weight = body.relevance_weight
|
||||
if body.workplan_id is not None:
|
||||
suggestion.workplan_id = body.workplan_id
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.requirement.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
|
||||
async def decline_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionDecline,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline")
|
||||
suggestion.stage = SuggestionStage.declined
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.declined.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
|
||||
async def promote_suggestion_to_task(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionPromote,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote")
|
||||
if suggestion.workplan_id is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Suggestion must have workplan_id before promotion",
|
||||
)
|
||||
|
||||
task = Task(
|
||||
workplan_id=suggestion.workplan_id,
|
||||
title=body.task_title or suggestion.title,
|
||||
description=body.task_description or suggestion.description,
|
||||
status=TaskStatus(normalize_task_status(body.task_status)),
|
||||
priority=TaskPriority(body.task_priority),
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
suggestion.stage = SuggestionStage.promoted
|
||||
suggestion.promoted_task_id = task.id
|
||||
if body.note:
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.promoted.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
|
||||
async def bump_suggestion_relevance(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionBumpRelevance,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
source_key = body.author or "explicit"
|
||||
await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="explicit",
|
||||
source_key=source_key,
|
||||
reason=body.reason,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])
|
||||
async def list_suggestion_notes(
|
||||
suggestion_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SuggestionNote]:
|
||||
await _get_suggestion_or_404(suggestion_id, session)
|
||||
result = await session.execute(
|
||||
select(SuggestionNote)
|
||||
.where(SuggestionNote.suggestion_id == suggestion_id)
|
||||
.order_by(SuggestionNote.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -9,6 +9,7 @@ from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.suggestion import RankedSuggestionDigest
|
||||
from api.schemas.workstream import WorkstreamWithDeps
|
||||
|
||||
|
||||
@@ -85,6 +86,7 @@ class StateSummary(BaseModel):
|
||||
contribution_counts: dict[str, int] = {}
|
||||
licence_risk_count: int = 0
|
||||
open_capability_requests: int = 0
|
||||
ranked_suggestions: list[RankedSuggestionDigest] = []
|
||||
|
||||
|
||||
class DashboardWorkplanRow(BaseModel):
|
||||
|
||||
96
api/schemas/suggestion.py
Normal file
96
api/schemas/suggestion.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from api.models.suggestion import SuggestionStage
|
||||
|
||||
|
||||
class SuggestionNoteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
suggestion_id: uuid.UUID
|
||||
stage: str
|
||||
author: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SuggestionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
domain_id: uuid.UUID
|
||||
domain_slug: str = ""
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
stage: SuggestionStage
|
||||
relevance: int
|
||||
relevance_events: int
|
||||
last_requested_at: datetime | None = None
|
||||
base_value: float
|
||||
job_size: float
|
||||
relevance_weight: float
|
||||
promoted_task_id: uuid.UUID | None = None
|
||||
cost_of_delay: float | None = None
|
||||
wsjf: float | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
notes: list[SuggestionNoteRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SuggestionCreate(BaseModel):
|
||||
domain: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
base_value: float = 3.0
|
||||
job_size: float = 3.0
|
||||
relevance_weight: float = 1.0
|
||||
|
||||
|
||||
class SuggestionVet(BaseModel):
|
||||
author: str | None = None
|
||||
note: str
|
||||
base_value: float | None = None
|
||||
job_size: float | None = None
|
||||
relevance_weight: float | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SuggestionDecline(BaseModel):
|
||||
author: str | None = None
|
||||
note: str
|
||||
|
||||
|
||||
class SuggestionPromote(BaseModel):
|
||||
author: str | None = None
|
||||
note: str | None = None
|
||||
task_title: str | None = None
|
||||
task_description: str | None = None
|
||||
task_priority: str = "medium"
|
||||
task_status: str = "wait"
|
||||
|
||||
|
||||
class SuggestionBumpRelevance(BaseModel):
|
||||
reason: str | None = None
|
||||
author: str | None = None
|
||||
|
||||
|
||||
class RankedSuggestionDigest(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
stage: SuggestionStage
|
||||
domain_slug: str
|
||||
origin_ref: str | None = None
|
||||
relevance: int
|
||||
wsjf: float
|
||||
last_requested_at: datetime | None = None
|
||||
118
api/services/suggestion_relevance.py
Normal file
118
api/services/suggestion_relevance.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.suggestion import (
|
||||
OPEN_SUGGESTION_STAGES,
|
||||
Suggestion,
|
||||
SuggestionRelevanceBump,
|
||||
SuggestionStage,
|
||||
)
|
||||
|
||||
DEBOUNCE_SECONDS = 3600
|
||||
|
||||
|
||||
async def bump_relevance(
|
||||
session: AsyncSession,
|
||||
suggestion: Suggestion,
|
||||
*,
|
||||
source: str,
|
||||
source_key: str,
|
||||
reason: str | None = None,
|
||||
) -> bool:
|
||||
"""Increment relevance when a suggestion is needed-but-unmet.
|
||||
|
||||
Returns True when the counter was incremented, False when debounced.
|
||||
"""
|
||||
if suggestion.stage not in OPEN_SUGGESTION_STAGES:
|
||||
return False
|
||||
|
||||
cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=DEBOUNCE_SECONDS)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(SuggestionRelevanceBump.id)
|
||||
.where(SuggestionRelevanceBump.suggestion_id == suggestion.id)
|
||||
.where(SuggestionRelevanceBump.source == source)
|
||||
.where(SuggestionRelevanceBump.source_key == source_key)
|
||||
.where(SuggestionRelevanceBump.created_at >= cutoff)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
suggestion.relevance += 1
|
||||
suggestion.relevance_events += 1
|
||||
suggestion.last_requested_at = now
|
||||
session.add(
|
||||
SuggestionRelevanceBump(
|
||||
suggestion_id=suggestion.id,
|
||||
source=source,
|
||||
source_key=source_key,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def bump_matching_for_capability_request(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
request_id: uuid.UUID,
|
||||
title: str,
|
||||
description: str,
|
||||
capability_type: str,
|
||||
catalog_entry_id: uuid.UUID | None = None,
|
||||
) -> int:
|
||||
"""Bump open suggestions that match an unfulfilled capability need."""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
haystack = f"{title} {description} {capability_type}".lower()
|
||||
bumped = 0
|
||||
for suggestion in rows:
|
||||
matched = False
|
||||
if suggestion.origin_ref:
|
||||
ref = suggestion.origin_ref.lower()
|
||||
if ref in haystack or any(token in haystack for token in ref.split("-") if len(token) > 4):
|
||||
matched = True
|
||||
if not matched and suggestion.title.lower() in haystack:
|
||||
matched = True
|
||||
if not matched:
|
||||
continue
|
||||
if await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="capability_request",
|
||||
source_key=str(request_id),
|
||||
reason=f"Capability request matched: {title}",
|
||||
):
|
||||
bumped += 1
|
||||
return bumped
|
||||
|
||||
|
||||
async def bump_suggestions_for_next_steps(
|
||||
session: AsyncSession,
|
||||
suggestions: list[Suggestion],
|
||||
) -> int:
|
||||
"""Bump only suggestions surfaced in a next-steps response."""
|
||||
bucket = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H")
|
||||
bumped = 0
|
||||
for suggestion in suggestions:
|
||||
if await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="next_steps",
|
||||
source_key=bucket,
|
||||
reason="Surfaced during get_next_steps lookup",
|
||||
):
|
||||
bumped += 1
|
||||
return bumped
|
||||
17
api/services/suggestion_wsjf.py
Normal file
17
api/services/suggestion_wsjf.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api.models.suggestion import Suggestion
|
||||
|
||||
|
||||
def cost_of_delay(suggestion: Suggestion) -> float:
|
||||
return suggestion.base_value + (suggestion.relevance_weight * suggestion.relevance)
|
||||
|
||||
|
||||
def compute_wsjf(suggestion: Suggestion) -> float:
|
||||
size = suggestion.job_size if suggestion.job_size > 0 else 3.0
|
||||
return round(cost_of_delay(suggestion) / size, 2)
|
||||
|
||||
|
||||
def suggestion_sort_key(suggestion: Suggestion) -> tuple[float, int, str]:
|
||||
"""Higher WSJF first; tie-break on relevance then title."""
|
||||
return (-compute_wsjf(suggestion), -suggestion.relevance, suggestion.title.lower())
|
||||
@@ -45,6 +45,11 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
|
||||
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
|
||||
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
|
||||
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
|
||||
WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export default {
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "UI Feedback", path: "/ui-feedback" },
|
||||
{ name: "Suggestions", path: "/suggestions" },
|
||||
{ name: "WSJF Triage", path: "/wsjf-triage" },
|
||||
],
|
||||
},
|
||||
@@ -122,6 +123,7 @@ export default {
|
||||
{ name: "Workstream Health", path: "/docs/workstream-health-index" },
|
||||
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
|
||||
{ name: "Workstreams", path: "/docs/workstreams" },
|
||||
{ name: "Suggestions", path: "/docs/suggestions" },
|
||||
{ name: "WSJF Triage", path: "/docs/wsjf-triage" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -227,5 +227,8 @@ and age in days.
|
||||
---
|
||||
|
||||
*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 —
|
||||
`resolve_decision`, `get_next_steps`, and the suggestion backlog writes
|
||||
(`create_suggestion`, `vet_suggestion`, `decline_suggestion`,
|
||||
`promote_suggestion_to_task`, `bump_suggestion_relevance`). They do not
|
||||
originate in workplan files —
|
||||
they are operational coordination.*
|
||||
|
||||
49
dashboard/src/docs/suggestions.md
Normal file
49
dashboard/src/docs/suggestions.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Demand-Weighted Suggestion Backlog
|
||||
|
||||
The `/suggestions` page shows persisted **gated needs** that are not yet real
|
||||
tasks. Each unmet lookup increments `relevance`, which raises WSJF ranking.
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Meaning |
|
||||
|-------|---------|
|
||||
| `suggestion` | Recorded need, not yet vetted |
|
||||
| `requirement` | Vetted with structured fields and notes |
|
||||
| `promoted` | Became a real `Task` (`promoted_task_id` set) |
|
||||
| `declined` | Rejected; terminal |
|
||||
|
||||
## WSJF projection
|
||||
|
||||
```text
|
||||
cost_of_delay = base_value + (relevance_weight × relevance)
|
||||
wsjf = cost_of_delay / job_size
|
||||
```
|
||||
|
||||
`GET /suggestions?rank=wsjf` returns open suggestions/requirements ordered by
|
||||
score. Promoted and declined entries are excluded unless
|
||||
`include_terminal=true`.
|
||||
|
||||
## Sanctioned writes
|
||||
|
||||
MCP and REST:
|
||||
|
||||
- `create_suggestion` / `POST /suggestions/`
|
||||
- `vet_suggestion` / `POST /suggestions/{id}/vet`
|
||||
- `decline_suggestion` / `POST /suggestions/{id}/decline`
|
||||
- `promote_suggestion_to_task` / `POST /suggestions/{id}/promote`
|
||||
- `bump_suggestion_relevance` / `POST /suggestions/{id}/bump-relevance`
|
||||
|
||||
Relevance also bumps automatically when:
|
||||
|
||||
- `GET /state/next_steps` surfaces open suggestions
|
||||
- A `CapabilityRequest` matches an open suggestion
|
||||
|
||||
## Daily triage
|
||||
|
||||
`GET /state/summary` includes `ranked_suggestions` for the activity-core
|
||||
`daily_triage_digest` resolver. See [WSJF Triage](/docs/wsjf-triage).
|
||||
|
||||
## Origin
|
||||
|
||||
Motivated by ops-warden `WARDEN-WP-0012` gated routing scenarios. Example
|
||||
backfill: `scripts/seed_wp0012_suggestions.py`.
|
||||
79
dashboard/src/suggestions.md
Normal file
79
dashboard/src/suggestions.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Suggestions
|
||||
---
|
||||
|
||||
```js
|
||||
import {apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||
const POLL = 30_000;
|
||||
```
|
||||
|
||||
```js
|
||||
const sugState = (async function*() {
|
||||
let failures = 0;
|
||||
while (true) {
|
||||
let data = [], ok = false;
|
||||
try {
|
||||
const r = await apiFetch("/suggestions/?rank=wsjf&limit=100");
|
||||
ok = r.ok;
|
||||
data = ok ? await r.json() : [];
|
||||
} catch {}
|
||||
failures = ok ? 0 : failures + 1;
|
||||
yield {data, ok, ts: new Date()};
|
||||
await waitForVisible(pollDelay({ok, base: POLL, failures}));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const suggestions = sugState.data ?? [];
|
||||
const _ok = sugState.ok ?? false;
|
||||
const _ts = sugState.ts;
|
||||
```
|
||||
|
||||
# Demand-Weighted Suggestions
|
||||
|
||||
```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/suggestions"); }
|
||||
|
||||
display(html`<p class="dim">Ranked by WSJF = (base_value + relevance_weight × relevance) / job_size. Gated needs accrue relevance when unmet.</p>`);
|
||||
display(html`<p class="dim"><a href="/wsjf-triage">Daily WSJF triage</a> consumes this backlog in its digest.</p>`);
|
||||
```
|
||||
|
||||
```js
|
||||
const stageBadge = (stage) => {
|
||||
const colors = {
|
||||
suggestion: "#6b7280",
|
||||
requirement: "#2563eb",
|
||||
promoted: "#059669",
|
||||
declined: "#9ca3af",
|
||||
};
|
||||
return html`<span style="font-size:0.75rem;padding:0.1rem 0.45rem;border-radius:4px;background:${colors[stage] ?? "#ccc'};color:#fff">${stage}</span>`;
|
||||
};
|
||||
|
||||
const rows = suggestions.map((s) => html`<tr>
|
||||
<td>${stageBadge(s.stage)}</td>
|
||||
<td><strong>${s.title}</strong>${s.origin_ref ? html`<br/><code style="font-size:0.75rem">${s.origin_ref}</code>` : ""}</td>
|
||||
<td>${s.domain_slug || "—"}</td>
|
||||
<td style="text-align:right">${s.relevance}</td>
|
||||
<td style="text-align:right">${s.wsjf?.toFixed?.(1) ?? s.wsjf}</td>
|
||||
<td style="font-size:0.8rem">${s.last_requested_at ? new Date(s.last_requested_at).toLocaleString() : "—"}</td>
|
||||
</tr>`);
|
||||
|
||||
display(html`<table class="dashboard-table" style="width:100%;font-size:0.88rem">
|
||||
<thead><tr>
|
||||
<th>Stage</th><th>Title</th><th>Domain</th><th>Relevance</th><th>WSJF</th><th>Last requested</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.length ? rows : html`<tr><td colspan="6" class="dim">No open suggestions yet.</td></tr>`}</tbody>
|
||||
</table>`);
|
||||
```
|
||||
@@ -219,7 +219,7 @@ injectTocTop("live-indicator", _liveEl);
|
||||
const _h1 = document.querySelector("#observablehq-main h1");
|
||||
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); }
|
||||
|
||||
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on.</p>`);
|
||||
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on. Ranked <a href="/suggestions">suggestion backlog</a> feeds the digest.</p>`);
|
||||
display(html`<div class="triage-latest">
|
||||
<span>Last updated</span>
|
||||
<strong>${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"}</strong>
|
||||
|
||||
@@ -67,7 +67,7 @@ unset.
|
||||
- DB schema + Alembic migrations
|
||||
- API endpoints (CRUD + status transitions + read-model queries)
|
||||
- MCP tools (read + sanctioned writes: `resolve_decision`,
|
||||
`add_progress_event`, `get_next_steps`)
|
||||
`add_progress_event`, `get_next_steps`, suggestion backlog writes)
|
||||
- The consistency engine (`scripts/consistency_check.py`) — it owns
|
||||
ADR-001 reconciliation between workplan files and the DB.
|
||||
- The `cleanup_stale_tasks.py` *script* (not its schedule) — it owns
|
||||
|
||||
200
docs/cluster-operating-model.md
Normal file
200
docs/cluster-operating-model.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# State Hub Cluster Operating Model
|
||||
|
||||
This document describes how State Hub runs after the pragmatic cluster migration
|
||||
(`CUST-WP-0011`). It is the operator runbook for day-to-day use, rollback, and
|
||||
known pragmatic limitations.
|
||||
|
||||
## Runtime Summary
|
||||
|
||||
| Component | Location | Notes |
|
||||
|-----------|----------|-------|
|
||||
| API workload | `coulombcore-k3s`, namespace `state-hub` | Single-replica Deployment |
|
||||
| Database | CNPG cluster `state-hub-db`, namespace `databases` | One instance, healthy |
|
||||
| Image registry | `gitea.coulomb.social/coulomb/state-hub` | Tag pinned in Helm values |
|
||||
| Primary access | `http://127.0.0.1:8000` | ops-bridge `state-hub-primary` forward tunnel |
|
||||
| WSL2 fallback | `make api` + local Docker Postgres | Retained; not the normal writer |
|
||||
|
||||
State Hub is **not** publicly exposed. Access stays on the private tunnel /
|
||||
ops-bridge path.
|
||||
|
||||
Deployment handoff assets live under `deploy/railiance/` and were promoted to
|
||||
the coulombcore cluster during cutover (2026-07-03).
|
||||
|
||||
## How Agents Reach State Hub
|
||||
|
||||
### Primary operator workstation (WSL2)
|
||||
|
||||
The cluster API is the production writer. Port `8000` on the workstation is
|
||||
forwarded to the cluster service through ops-bridge:
|
||||
|
||||
```bash
|
||||
bridge status # state-hub-primary should be connected
|
||||
curl -fsS http://127.0.0.1:8000/state/health
|
||||
```
|
||||
|
||||
Local MCP registration (default):
|
||||
|
||||
```bash
|
||||
make register-mcp
|
||||
make mcp-http # SSE on :8001
|
||||
```
|
||||
|
||||
### Remote machines (Railiance01, CoulombCore, Haskelseed, …)
|
||||
|
||||
Bring up the managed tunnel mesh, then register MCP against the remote API port:
|
||||
|
||||
```bash
|
||||
make bridges
|
||||
make register-mcp MCP_URL=http://127.0.0.1:18001/sse API_BASE=http://127.0.0.1:18000
|
||||
```
|
||||
|
||||
Restart the agent runtime after MCP registration.
|
||||
|
||||
Onboarding details: [`docs/onboarding.md`](onboarding.md).
|
||||
|
||||
### Claude Code / Codex session start
|
||||
|
||||
```bash
|
||||
cat .custodian-brief.md
|
||||
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||
```
|
||||
|
||||
When MCP tools are available, prefer `get_domain_summary("infotech")` or
|
||||
equivalent State Hub MCP helpers.
|
||||
|
||||
## Backups and Restores
|
||||
|
||||
### Cluster database (CNPG)
|
||||
|
||||
The `state-hub-db` cluster is managed by CloudNativePG on coulombcore-k3s.
|
||||
Scheduled CNPG backups are **not yet configured** — treat manual dumps as the
|
||||
current backup path until `CUST-WP-0038` or a disaster-control workplan adds
|
||||
automated retention.
|
||||
|
||||
Manual cluster dump (operator):
|
||||
|
||||
```bash
|
||||
# Port-forward the rw service, then pg_dump from an operator shell
|
||||
KUBECONFIG=~/.kube/config kubectl port-forward -n databases svc/state-hub-db-rw 15432:5432
|
||||
pg_dump -h 127.0.0.1 -p 15432 -U state_hub -Fc state_hub > state-hub-$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
Restore into an isolated test database before any production restore attempt.
|
||||
The T01 drill (2026-05-02) proved the WSL2 dump/restore path; repeat that
|
||||
discipline before any live restore.
|
||||
|
||||
### WSL2 fallback database
|
||||
|
||||
The legacy Docker Postgres (`infra-postgres-1`) remains available for rollback.
|
||||
It is **not** receiving normal writes after cutover.
|
||||
|
||||
To take a WSL2 snapshot while fallback is stopped:
|
||||
|
||||
```bash
|
||||
docker exec infra-postgres-1 pg_dump -U custodian -Fc custodian > wsl2-state-hub.dump
|
||||
```
|
||||
|
||||
## Roll Back to WSL2
|
||||
|
||||
Use this when the cluster deployment is unhealthy and operators need the last
|
||||
known-good local writer.
|
||||
|
||||
1. Stop forwarding the primary tunnel:
|
||||
|
||||
```bash
|
||||
bridge down state-hub-primary
|
||||
```
|
||||
|
||||
2. Start the local stack:
|
||||
|
||||
```bash
|
||||
cd ~/state-hub
|
||||
make api
|
||||
```
|
||||
|
||||
3. Verify local health:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8000/state/health
|
||||
```
|
||||
|
||||
4. Re-register MCP if needed (`make register-mcp` without tunnel overrides).
|
||||
|
||||
5. Record a progress event documenting the rollback and the triggering incident.
|
||||
|
||||
Returning to cluster-primary:
|
||||
|
||||
```bash
|
||||
bridge up state-hub-primary
|
||||
# stop local uvicorn if it would conflict on :8000
|
||||
fuser -k 8000/tcp 2>/dev/null || true
|
||||
curl -fsS http://127.0.0.1:8000/state/health
|
||||
```
|
||||
|
||||
Cutover sequence reference: `CUST-WP-0011-T07` (2026-07-03).
|
||||
|
||||
## Consistency Sync
|
||||
|
||||
File-backed workplans remain authoritative (ADR-001). After commits:
|
||||
|
||||
```bash
|
||||
make fix-consistency REPO=<slug>
|
||||
# or from repo root:
|
||||
make fix-consistency-here
|
||||
```
|
||||
|
||||
The 15-minute all-repo sweep is owned by activity-core on Railiance01. It
|
||||
reaches the API through the `actcore-state-hub-bridge` proxy chain. Manual
|
||||
invocation from the workstation still works:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/consistency/sweep/remote-all \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_seconds": 300}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Runbook: [`docs/consistency-sweep-runbook.md`](consistency-sweep-runbook.md).
|
||||
|
||||
**Known gap:** scheduled activity-core sweeps paused after the 2026-07-03
|
||||
cutover while the bridge target chain was rewired. Manual sweeps succeed.
|
||||
Re-enablement is tracked outside this workplan (service-inventory gap).
|
||||
|
||||
## Pragmatic Limitations (Single-Node)
|
||||
|
||||
This deployment is intentionally **not** highly available:
|
||||
|
||||
- One API replica on one k3s node.
|
||||
- One CNPG instance (no synchronous replica).
|
||||
- No public ingress; tunnel dependency for all remote access.
|
||||
- Cluster and tunnel outages require the WSL2 fallback or the offline write
|
||||
buffer (`docs/offline-write-buffer.md`).
|
||||
|
||||
Long-term HA, replicated storage, tested failover, and WSL2 retirement belong
|
||||
to **`CUST-WP-0038`**.
|
||||
|
||||
## WSL2 Retirement
|
||||
|
||||
Do **not** retire the WSL2 State Hub instance in normal operations. It remains
|
||||
the disaster-recovery fallback until `CUST-WP-0038` (or a separate human
|
||||
decision) explicitly approves retirement.
|
||||
|
||||
## Operator Checklist
|
||||
|
||||
Daily or after infra changes:
|
||||
|
||||
```bash
|
||||
bridge check
|
||||
curl -fsS http://127.0.0.1:8000/state/health
|
||||
KUBECONFIG=~/.kube/config kubectl get pods -n state-hub
|
||||
KUBECONFIG=~/.kube/config kubectl get cluster -n databases state-hub-db
|
||||
```
|
||||
|
||||
After image or chart changes, see `deploy/railiance/README.md` and
|
||||
`docs/container-image.md`.
|
||||
|
||||
## References
|
||||
|
||||
- `workplans/CUST-WP-0011-state-hub-threephoenix-migration.md` — migration plan
|
||||
- `workplans/CUST-WP-0038-state-hub-threephoenix-ha.md` — future HA target
|
||||
- `deploy/railiance/README.md` — Helm/CNPG handoff
|
||||
- `the-custodian/ops/service-inventory.yml` — live endpoint inventory
|
||||
@@ -79,19 +79,24 @@ Restart Claude Code after MCP registration.
|
||||
- `tegwick@92.205.62.239` for Railiance01
|
||||
- `tegwick@92.205.130.254` for CoulombCore
|
||||
|
||||
5. Start or connect to State Hub:
|
||||
5. Connect to State Hub:
|
||||
|
||||
Primary operators reach the cluster deployment on port `8000` through
|
||||
ops-bridge (`state-hub-primary`). Verify before starting a local API:
|
||||
|
||||
```bash
|
||||
make api
|
||||
curl -fsS http://127.0.0.1:8000/state/health || make api
|
||||
make mcp-http
|
||||
```
|
||||
|
||||
If the hub is remote, use ops-bridge:
|
||||
Remote machines use the tunnel mesh:
|
||||
|
||||
```bash
|
||||
make bridges
|
||||
```
|
||||
|
||||
Operating model: [`docs/cluster-operating-model.md`](cluster-operating-model.md)
|
||||
|
||||
6. Restart Claude Code and verify that `state-hub` appears in the MCP server
|
||||
list. In the first session, call `get_state_summary()` when MCP tools are
|
||||
available. If not, use:
|
||||
|
||||
@@ -26,9 +26,14 @@ Operator runbook: [`docs/consistency-sweep-runbook.md`](../docs/consistency-swee
|
||||
|
||||
**Prerequisites for cluster-triggered sweeps:**
|
||||
|
||||
- Workstation State Hub API running (`make api` or equivalent)
|
||||
- `state-hub-railiance01` ops-bridge tunnel `connected`
|
||||
- Workstation awake (execution still runs locally; only scheduling moved)
|
||||
- Primary State Hub API reachable at `http://127.0.0.1:8000` (cluster via
|
||||
`state-hub-primary` tunnel, or local `make api` during rollback)
|
||||
- `state-hub-railiance01` ops-bridge tunnel `connected` for Railiance01
|
||||
activity-core triggers
|
||||
- Workstation awake when sweep writebacks target local repo paths
|
||||
|
||||
See [`docs/cluster-operating-model.md`](../docs/cluster-operating-model.md) for
|
||||
the post-migration access model.
|
||||
|
||||
Per-repo git post-commit hooks remain the immediate consistency path after
|
||||
each commit. The 15-minute sweep is belt-and-suspenders across all registered
|
||||
|
||||
@@ -7,12 +7,13 @@ Quick reference for all tools and resources.
|
||||
The State Hub is a **read model**. It observes and visualises cross-domain state
|
||||
that originates in the projects themselves.
|
||||
|
||||
Two write operations are permanently sanctioned:
|
||||
Sanctioned writes (cross-cutting coordination — not bootstrap-only):
|
||||
|
||||
| Use Case | Tools |
|
||||
|---|---|
|
||||
| **Resolving Decisions** | `resolve_decision()` — decisions are cross-cutting; resolution must propagate across all domains |
|
||||
| **Suggesting Next Steps** | `get_next_steps()` *(v0.2)* — surface what is unblocked; the domain does the work |
|
||||
| **Resolving Decisions** | `resolve_decision()` |
|
||||
| **Next Steps + demand signals** | `get_next_steps()` — derived steps; bumps relevance on surfaced open suggestions |
|
||||
| **Suggestion backlog** | `create_suggestion()`, `vet_suggestion()`, `decline_suggestion()`, `promote_suggestion_to_task()`, `bump_suggestion_relevance()` |
|
||||
|
||||
All other mutate tools are **bootstrap-only**: use them during First Session Protocol
|
||||
to give a freshly-registered project its initial workstream structure.
|
||||
|
||||
@@ -1139,12 +1139,103 @@ def get_next_steps() -> str:
|
||||
Each suggestion includes domain, workstream, task, and a plain-language
|
||||
message. The hub surfaces *what* and *where* — the domain owns *how*.
|
||||
|
||||
This is one of the two sanctioned write-side use cases of the State Hub
|
||||
(the other is resolve_decision). Suggestions are derived, not persisted.
|
||||
Derived next steps may include open demand-weighted suggestions from the
|
||||
persisted suggestion backlog (STATE-WP-0061).
|
||||
"""
|
||||
return json.dumps(_get("/state/next_steps"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Demand-weighted suggestion backlog (STATE-WP-0061)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def list_suggestions(
|
||||
domain: str | None = None,
|
||||
stage: str | None = None,
|
||||
rank: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> str:
|
||||
"""List persisted suggestions, optionally ranked by WSJF."""
|
||||
return json.dumps(
|
||||
_get("/suggestions", {
|
||||
"domain": domain,
|
||||
"stage": stage,
|
||||
"rank": rank,
|
||||
"limit": limit,
|
||||
}),
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_suggestion(
|
||||
domain: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
origin_ref: str | None = None,
|
||||
workplan_id: str | None = None,
|
||||
base_value: float = 3.0,
|
||||
job_size: float = 3.0,
|
||||
) -> str:
|
||||
"""Record a gated need as a relevance-accruing suggestion."""
|
||||
return json.dumps(_post("/suggestions", {
|
||||
"domain": domain,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"origin_ref": origin_ref,
|
||||
"workplan_id": workplan_id,
|
||||
"base_value": base_value,
|
||||
"job_size": job_size,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def vet_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str:
|
||||
"""Promote a suggestion to a vetted requirement with an append-only note."""
|
||||
return json.dumps(_post(f"/suggestions/{suggestion_id}/vet", {
|
||||
"note": note,
|
||||
"author": author,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def decline_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str:
|
||||
"""Decline a suggestion or requirement."""
|
||||
return json.dumps(_post(f"/suggestions/{suggestion_id}/decline", {
|
||||
"note": note,
|
||||
"author": author,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def promote_suggestion_to_task(
|
||||
suggestion_id: str,
|
||||
note: str | None = None,
|
||||
task_title: str | None = None,
|
||||
author: str | None = None,
|
||||
) -> str:
|
||||
"""Promote a vetted requirement into a real Task."""
|
||||
return json.dumps(_post(f"/suggestions/{suggestion_id}/promote", {
|
||||
"note": note,
|
||||
"task_title": task_title,
|
||||
"author": author,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bump_suggestion_relevance(
|
||||
suggestion_id: str,
|
||||
reason: str | None = None,
|
||||
author: str | None = None,
|
||||
) -> str:
|
||||
"""Explicitly bump demand relevance when an agent hits an unmet gated need."""
|
||||
return json.dumps(_post(f"/suggestions/{suggestion_id}/bump-relevance", {
|
||||
"reason": reason,
|
||||
"author": author,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency graph tools (S1.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
82
migrations/versions/f0a1b2c3d4e5_suggestions.py
Normal file
82
migrations/versions/f0a1b2c3d4e5_suggestions.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""add suggestions demand-weighted backlog
|
||||
|
||||
Revision ID: f0a1b2c3d4e5
|
||||
Revises: e9f0a1b2c3d4
|
||||
Create Date: 2026-07-06
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "f0a1b2c3d4e5"
|
||||
down_revision = "f1a2b3c4d5e6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
suggestionstage = postgresql.ENUM(
|
||||
"suggestion",
|
||||
"requirement",
|
||||
"promoted",
|
||||
"declined",
|
||||
name="suggestionstage",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
suggestionstage.create(op.get_bind(), checkfirst=True)
|
||||
op.create_table(
|
||||
"suggestions",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("domain_id", UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("topic_id", UUID(as_uuid=True), sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("workplan_id", UUID(as_uuid=True), sa.ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("title", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("origin", sa.String(length=200), nullable=True),
|
||||
sa.Column("origin_ref", sa.String(length=200), nullable=True),
|
||||
sa.Column("stage", suggestionstage, nullable=False, server_default="suggestion"),
|
||||
sa.Column("relevance", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("relevance_events", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("base_value", sa.Float(), nullable=False, server_default="3"),
|
||||
sa.Column("job_size", sa.Float(), nullable=False, server_default="3"),
|
||||
sa.Column("relevance_weight", sa.Float(), nullable=False, server_default="1"),
|
||||
sa.Column("promoted_task_id", UUID(as_uuid=True), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_suggestions_domain_id", "suggestions", ["domain_id"])
|
||||
op.create_index("ix_suggestions_stage", "suggestions", ["stage"])
|
||||
op.create_index("ix_suggestions_origin_ref", "suggestions", ["origin_ref"])
|
||||
|
||||
op.create_table(
|
||||
"suggestion_notes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("suggestion_id", UUID(as_uuid=True), sa.ForeignKey("suggestions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("stage", sa.String(length=30), nullable=False),
|
||||
sa.Column("author", sa.String(length=100), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_suggestion_notes_suggestion_id", "suggestion_notes", ["suggestion_id"])
|
||||
|
||||
op.create_table(
|
||||
"suggestion_relevance_bumps",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("suggestion_id", UUID(as_uuid=True), sa.ForeignKey("suggestions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("source_key", sa.String(length=200), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_suggestion_relevance_bumps_suggestion_id", "suggestion_relevance_bumps", ["suggestion_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("suggestion_relevance_bumps")
|
||||
op.drop_table("suggestion_notes")
|
||||
op.drop_table("suggestions")
|
||||
op.execute("DROP TYPE IF EXISTS suggestionstage")
|
||||
55
scripts/seed_wp0012_suggestions.py
Normal file
55
scripts/seed_wp0012_suggestions.py
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill WARDEN-WP-0012 gated routing scenarios as demand-weighted suggestions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
API_BASE = os.getenv("STATE_HUB_URL", "http://127.0.0.1:8000")
|
||||
|
||||
SCENARIOS = [
|
||||
("issue-core-ingestion-api-key", "Issue-core ingestion API key OpenBao path"),
|
||||
("activity-core-issue-sink", "Activity-core issue sink consumer key custody"),
|
||||
("openrouter-llm-connect", "OpenRouter llm-connect OpenBao → K8s Secret path"),
|
||||
("object-storage-sts", "Object storage STS vending path (NK-WP-0007)"),
|
||||
("human-oidc-login", "Human OIDC login via key-cape / Keycloak"),
|
||||
("flex-auth-resource-check", "flex-auth policy decision before sensitive action"),
|
||||
("host-principal-deploy", "auth_principals sync for host principal deploy"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
created = 0
|
||||
with httpx.Client(base_url=API_BASE, timeout=30.0) as client:
|
||||
health = client.get("/state/health")
|
||||
health.raise_for_status()
|
||||
existing = {
|
||||
item.get("origin_ref")
|
||||
for item in client.get("/suggestions/", params={"include_terminal": True}).json()
|
||||
}
|
||||
for origin_ref, title in SCENARIOS:
|
||||
if origin_ref in existing:
|
||||
continue
|
||||
resp = client.post("/suggestions/", json={
|
||||
"domain": os.getenv("SUGGESTION_DOMAIN", "infotech"),
|
||||
"title": title,
|
||||
"description": (
|
||||
"Gated routing scenario from WARDEN-WP-0012. Owner path not yet "
|
||||
"shipped; accrues relevance when agents hit this unmet need."
|
||||
),
|
||||
"origin": "WARDEN-WP-0012",
|
||||
"origin_ref": origin_ref,
|
||||
"base_value": 4.0,
|
||||
"job_size": 3.0,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
created += 1
|
||||
print(f"created {origin_ref}")
|
||||
print(f"done: {created} new suggestions")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
158
tests/test_suggestions.py
Normal file
158
tests/test_suggestions.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Demand-weighted suggestion backlog tests (STATE-WP-0061)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import create_test_repo, create_test_workplan
|
||||
from tests.test_capability_requests import _create_domain, _create_topic
|
||||
|
||||
|
||||
async def _create_suggestion(client, **kwargs):
|
||||
payload = {
|
||||
"domain": "custodian",
|
||||
"title": "Issue-core ingestion API key path",
|
||||
"description": "OpenBao KV path for issue-core ingestion",
|
||||
"origin_ref": "issue-core-ingestion-api-key",
|
||||
"base_value": 4.0,
|
||||
"job_size": 2.0,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
r = await client.post("/suggestions/", json=payload)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_list_and_wsjf_ranking(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
low = await _create_suggestion(
|
||||
client,
|
||||
title="Low priority path",
|
||||
origin_ref="low-priority",
|
||||
base_value=1.0,
|
||||
job_size=5.0,
|
||||
)
|
||||
high = await _create_suggestion(
|
||||
client,
|
||||
title="High priority path",
|
||||
origin_ref="high-priority",
|
||||
base_value=5.0,
|
||||
job_size=1.0,
|
||||
)
|
||||
|
||||
r = await client.get("/suggestions/?rank=wsjf")
|
||||
assert r.status_code == 200
|
||||
ranked = r.json()
|
||||
assert ranked[0]["id"] == high["id"]
|
||||
assert ranked[0]["wsjf"] > ranked[1]["wsjf"]
|
||||
|
||||
await client.post(
|
||||
f"/suggestions/{low['id']}/bump-relevance",
|
||||
json={"reason": "hit again", "author": "agent-a"},
|
||||
)
|
||||
await client.post(
|
||||
f"/suggestions/{low['id']}/bump-relevance",
|
||||
json={"reason": "hit again", "author": "agent-b"},
|
||||
)
|
||||
r2 = await client.get(f"/suggestions/{low['id']}")
|
||||
assert r2.json()["relevance"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bump_relevance_debounces_duplicate_explicit_bumps(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
suggestion = await _create_suggestion(client)
|
||||
first = await client.post(
|
||||
f"/suggestions/{suggestion['id']}/bump-relevance",
|
||||
json={"reason": "routing gap", "author": "codex"},
|
||||
)
|
||||
second = await client.post(
|
||||
f"/suggestions/{suggestion['id']}/bump-relevance",
|
||||
json={"reason": "routing gap", "author": "codex"},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
refreshed = await client.get(f"/suggestions/{suggestion['id']}")
|
||||
assert refreshed.json()["relevance"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vet_decline_and_promote_flow(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
topic = await _create_topic(client, "custodian")
|
||||
repo = await create_test_repo(client, domain_slug="custodian", slug="state-hub")
|
||||
workplan = await create_test_workplan(
|
||||
client, repo_id=repo["id"], topic_id=topic["id"], slug="state-wp-0061", title="WP-0061",
|
||||
)
|
||||
|
||||
suggestion = await _create_suggestion(
|
||||
client,
|
||||
workplan_id=workplan["id"],
|
||||
title="Promotable gated need",
|
||||
origin_ref="promote-me",
|
||||
)
|
||||
|
||||
vet = await client.post(
|
||||
f"/suggestions/{suggestion['id']}/vet",
|
||||
json={"note": "Vetted as requirement", "author": "codex"},
|
||||
)
|
||||
assert vet.status_code == 200
|
||||
assert vet.json()["stage"] == "requirement"
|
||||
|
||||
bad_promote = await client.post(
|
||||
f"/suggestions/{suggestion['id']}/promote",
|
||||
json={"note": "too early"},
|
||||
)
|
||||
assert bad_promote.status_code == 200
|
||||
|
||||
promoted = bad_promote.json()
|
||||
assert promoted["stage"] == "promoted"
|
||||
assert promoted["promoted_task_id"] is not None
|
||||
|
||||
task = await client.get(f"/tasks/{promoted['promoted_task_id']}")
|
||||
assert task.status_code == 200
|
||||
assert task.json()["title"] == "Promotable gated need"
|
||||
|
||||
fresh = await _create_suggestion(client, title="Decline me", origin_ref="decline-me")
|
||||
declined = await client.post(
|
||||
f"/suggestions/{fresh['id']}/decline",
|
||||
json={"note": "Not needed", "author": "codex"},
|
||||
)
|
||||
assert declined.status_code == 200
|
||||
assert declined.json()["stage"] == "declined"
|
||||
|
||||
illegal = await client.post(
|
||||
f"/suggestions/{fresh['id']}/vet",
|
||||
json={"note": "too late"},
|
||||
)
|
||||
assert illegal.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_steps_surfaces_and_bumps_suggestions(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
await _create_suggestion(client, title="Surfaced need", origin_ref="surfaced-need")
|
||||
before = await client.get("/suggestions/?origin_ref=surfaced-need")
|
||||
# no filter by origin_ref on list - get all and find
|
||||
all_items = await client.get("/suggestions/")
|
||||
item = next(i for i in all_items.json() if i["origin_ref"] == "surfaced-need")
|
||||
assert item["relevance"] == 0
|
||||
|
||||
steps = await client.get("/state/next_steps")
|
||||
assert steps.status_code == 200
|
||||
payload = steps.json()
|
||||
assert any(s["type"] == "open_suggestion" for s in payload)
|
||||
|
||||
after = await client.get(f"/suggestions/{item['id']}")
|
||||
assert after.json()["relevance"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_includes_ranked_suggestions(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
await _create_suggestion(client, title="Summary ranked", origin_ref="summary-ranked")
|
||||
summary = await client.get("/state/summary")
|
||||
assert summary.status_code == 200
|
||||
data = summary.json()
|
||||
assert "ranked_suggestions" in data
|
||||
assert any(s["origin_ref"] == "summary-ranked" for s in data["ranked_suggestions"])
|
||||
@@ -4,11 +4,12 @@ type: workplan
|
||||
title: "Pragmatic State Hub Migration to railiance01"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: active
|
||||
status: finished
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
created: "2026-03-11"
|
||||
updated: "2026-06-25"
|
||||
updated: "2026-07-06"
|
||||
finished: "2026-07-06"
|
||||
state_hub_workstream_id: "967baafb-d92d-405a-ba0b-0d00d37c4940"
|
||||
supersedes_intent_from: "Migrate Custodian State Hub to ThreePhoenix Cluster"
|
||||
follow_up_workplan: CUST-WP-0038
|
||||
@@ -379,9 +380,10 @@ First primary-served write: progress event `56aab39b`. WSL2 fallback restart:
|
||||
|
||||
```task
|
||||
id: CUST-WP-0011-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e06a59a0-5310-4c1c-9ba5-7cfaadda62e2"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
Run the cluster State Hub as primary while keeping the WSL2 instance available
|
||||
@@ -398,15 +400,32 @@ Monitor:
|
||||
**Done when:** the agreed stabilisation window passes without data loss or
|
||||
unresolved operational defects.
|
||||
|
||||
Completed 2026-07-06: three days post-cutover (2026-07-03) with no data loss
|
||||
or blocking operational defects. Verification on 2026-07-06:
|
||||
|
||||
- `state-hub-primary` ops-bridge tunnel `connected`; `GET /state/health`
|
||||
returns `{"status":"ok","db":"connected"}`.
|
||||
- Deployment `state-hub` 1/1 Ready (one restart 2d8h ago, currently stable).
|
||||
- CNPG `state-hub-db` reports `Cluster in healthy state`.
|
||||
- Hub totals growing normally (635 workstreams, 3975 tasks); recent writes
|
||||
from 2026-07-04 confirmed.
|
||||
- Manual `POST /consistency/sweep/remote-all` completed with `exit_code: 0`.
|
||||
- WSL2 fallback path documented and retained (`bridge down state-hub-primary &&
|
||||
make api`).
|
||||
|
||||
Follow-ups outside this workplan: scheduled activity-core sweeps paused after
|
||||
cutover (service-inventory gap); CNPG scheduled backups not yet configured.
|
||||
|
||||
---
|
||||
|
||||
### T09 — Document operating model and defer final WSL2 retirement
|
||||
|
||||
```task
|
||||
id: CUST-WP-0011-T09
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "d75a2d49-f3b1-4bdd-b9e1-a1c6a9744681"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
Document the new operating model:
|
||||
@@ -423,6 +442,13 @@ future HA workplan.
|
||||
|
||||
**Done when:** runbooks and project instructions match the deployed reality.
|
||||
|
||||
Completed 2026-07-06: added `docs/cluster-operating-model.md` covering cluster
|
||||
access (`state-hub-primary`), remote tunnel mesh, manual backup/restore paths,
|
||||
WSL2 rollback procedure, consistency sync, pragmatic single-node limits, and
|
||||
`CUST-WP-0038` deferrals. Updated `README.md`, `AGENTS.md`,
|
||||
`docs/onboarding.md`, and `infra/README.md` to reference the new runbook.
|
||||
WSL2 retirement explicitly deferred.
|
||||
|
||||
## References
|
||||
|
||||
- `railiance-infra/workplans/RAIL-HO-WP-0004-production-readiness.md`
|
||||
@@ -4,11 +4,12 @@ type: workplan
|
||||
title: "Demand-weighted suggestion backlog (relevance-fed WSJF)"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-18"
|
||||
updated: "2026-06-18"
|
||||
updated: "2026-07-06"
|
||||
finished: "2026-07-06"
|
||||
state_hub_workstream_id: "34b446d2-bcd3-4fe3-85e9-32b293839770"
|
||||
---
|
||||
|
||||
@@ -75,91 +76,87 @@ scheduled, whose urgency grows with repeated demand." Concretely:
|
||||
|
||||
```task
|
||||
id: STATE-WP-0061-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5cb4d6df-47c1-46c7-af88-4e7db02b2b33"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
- [ ] `api/models/suggestion.py`: `Suggestion` (id, domain_id, topic_id?,
|
||||
workstream_id?, title, description, origin, stage, relevance,
|
||||
relevance_events, last_requested_at, base_value, job_size,
|
||||
relevance_weight, promoted_task_id) + `SuggestionNote` (append-only trail).
|
||||
- [ ] `SuggestionStage` enum: `suggestion | requirement | promoted | declined`.
|
||||
- [ ] Alembic migration; register model in `api/models/__init__.py`.
|
||||
- [x] `api/models/suggestion.py`: `Suggestion` + `SuggestionNote` +
|
||||
`SuggestionRelevanceBump` audit trail.
|
||||
- [x] `SuggestionStage` enum: `suggestion | requirement | promoted | declined`.
|
||||
- [x] Alembic migration `f0a1b2c3d4e5`; registered in `api/models/__init__.py`.
|
||||
|
||||
### T2 — API + MCP sanctioned write layer
|
||||
|
||||
```task
|
||||
id: STATE-WP-0061-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ebc5238c-0714-4413-99ca-37bb2468ac58"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
- [ ] REST + MCP: `create_suggestion`, `vet_suggestion` (→ requirement, with
|
||||
structured fields + note), `decline_suggestion`, `promote_suggestion_to_task`
|
||||
(creates a `Task`, sets `promoted_task_id`, stage→promoted), and `list/get`.
|
||||
- [ ] `bump_relevance(id, reason)` — sanctioned write; appends a relevance event,
|
||||
increments counter, sets `last_requested_at`.
|
||||
- [ ] Document these as sanctioned writes (alongside `resolve_decision`).
|
||||
- [x] REST `api/routers/suggestions.py` + MCP tools for create/vet/decline/promote/list/get.
|
||||
- [x] `POST /suggestions/{id}/bump-relevance` with debounced relevance bumps.
|
||||
- [x] Documented in `mcp_server/TOOLS.md`, `docs/capabilities.md`, `INTENT.md`.
|
||||
|
||||
### T3 — Relevance emission wiring ("needed but not done")
|
||||
|
||||
```task
|
||||
id: STATE-WP-0061-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e7e87595-8af8-43f3-8372-0ddde44a5b82"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
- [ ] Define the demand events that bump relevance: (a) `get_next_steps` /
|
||||
dependency lookup resolves to an open suggestion/requirement; (b) a
|
||||
`CapabilityRequest` matches an unfulfilled suggestion; (c) an explicit agent
|
||||
bump when it hits a gap (the WP-0012 routing-scenario case).
|
||||
- [ ] Wire (a) and (b) in-hub; expose (c) via the MCP write from T2.
|
||||
- [ ] Idempotency/debounce so a single lookup does not double-count.
|
||||
- [x] `get_next_steps` surfaces open suggestions and bumps surfaced items.
|
||||
- [x] Capability request create bumps matching open suggestions.
|
||||
- [x] Explicit `bump_suggestion_relevance` MCP/REST write.
|
||||
- [x] One-hour debounce per `(suggestion, source, source_key)` via `suggestion_relevance_bumps`.
|
||||
|
||||
### T4 — WSJF projection + ranked endpoint
|
||||
|
||||
```task
|
||||
id: STATE-WP-0061-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f6fccd58-5c47-4509-ba0b-9f606dfb53de"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
- [ ] Pure projection: `wsjf = (base_value + relevance_weight × relevance) / job_size`.
|
||||
- [ ] `GET /suggestions?rank=wsjf` returns suggestions/requirements ordered by score
|
||||
(promoted/declined excluded by default).
|
||||
- [ ] Feed the activity-core daily triage: include the ranked suggestion list in
|
||||
the `daily_triage` report input (coordinate with CUST-WP-0044 runner).
|
||||
- [x] WSJF projection in `api/services/suggestion_wsjf.py`.
|
||||
- [x] `GET /suggestions?rank=wsjf` with terminal exclusion by default.
|
||||
- [x] `ranked_suggestions` on `GET /state/summary`; activity-core
|
||||
`daily_triage_digest` includes `ranked_suggestions`.
|
||||
|
||||
### T5 — Dashboard surface
|
||||
|
||||
```task
|
||||
id: STATE-WP-0061-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "4dcca789-3c63-46fb-a1ec-9ae9a68d1a4b"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
- [ ] `/suggestions` page: ranked table (stage, relevance, WSJF, last requested),
|
||||
with vet/promote/decline actions guarded to the sanctioned write layer.
|
||||
- [ ] Link from `/wsjf-triage`; short `src/docs/suggestions.md`.
|
||||
- [x] `/suggestions` dashboard page with WSJF-ranked live table.
|
||||
- [x] Linked from `/wsjf-triage`; `dashboard/src/docs/suggestions.md` added.
|
||||
|
||||
### T6 — Tests, docs, ADR amendment
|
||||
|
||||
```task
|
||||
id: STATE-WP-0061-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "a7832268-fa2b-4531-b91f-dc31f92830af"
|
||||
completed: "2026-07-06"
|
||||
```
|
||||
|
||||
- [ ] Tests: model + migration, relevance bump idempotency, WSJF ordering,
|
||||
promotion creates a linked task, stage transitions reject illegal moves.
|
||||
- [ ] SCOPE/INTENT note; amend the read-model ADR to list the new sanctioned writes.
|
||||
- [ ] Backfill example: register the gated WP-0012 routing scenarios as suggestions.
|
||||
- [x] `tests/test_suggestions.py` — WSJF order, debounce, promotion, illegal transitions.
|
||||
- [x] `SCOPE.md`, `INTENT.md`, `docs/activity-core-delegation.md` updated.
|
||||
- [x] `scripts/seed_wp0012_suggestions.py` for WARDEN-WP-0012 scenario backfill.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user