generated from coulomb/repo-seed
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure: PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable Framework telemetry dashboard. - state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard) - 5 DB tables: topics, workstreams, tasks, decisions, progress_events - 11 MCP tools + 5 resources registered in .mcp.json - Observable dashboard: Overview, Workstreams, Decisions, Progress pages - CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual) - ~/.claude/CLAUDE.md: global cross-project reference to the hub - scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Copy to .env and fill in values before running
|
||||
POSTGRES_DB=custodian
|
||||
POSTGRES_USER=custodian
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian
|
||||
|
||||
# pgAdmin (optional, only used with --profile tools)
|
||||
PGADMIN_EMAIL=admin@local.dev
|
||||
PGADMIN_PASSWORD=admin
|
||||
|
||||
# API
|
||||
API_BASE=http://127.0.0.1:8000
|
||||
35
Makefile
Normal file
35
Makefile
Normal file
@@ -0,0 +1,35 @@
|
||||
.PHONY: install db db-tools migrate seed api dashboard check start clean
|
||||
|
||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||
|
||||
install:
|
||||
uv sync
|
||||
|
||||
db:
|
||||
$(COMPOSE) up -d postgres
|
||||
|
||||
db-tools:
|
||||
$(COMPOSE) --profile tools up -d
|
||||
|
||||
migrate:
|
||||
uv run alembic upgrade head
|
||||
|
||||
seed:
|
||||
uv run python scripts/seed.py
|
||||
|
||||
api:
|
||||
uv run uvicorn api.main:app --reload --host 127.0.0.1 --port 8000
|
||||
|
||||
dashboard:
|
||||
cd dashboard && npm run dev
|
||||
|
||||
check:
|
||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||
|
||||
start: db
|
||||
sleep 3
|
||||
$(MAKE) migrate
|
||||
$(MAKE) api
|
||||
|
||||
clean:
|
||||
$(COMPOSE) down -v
|
||||
39
alembic.ini
Normal file
39
alembic.ini
Normal file
@@ -0,0 +1,39 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+psycopg2://custodian:changeme@127.0.0.1:5432/custodian
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
16
api/config.py
Normal file
16
api/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
|
||||
api_base: str = "http://127.0.0.1:8000"
|
||||
debug: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
24
api/database.py
Normal file
24
api/database.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from api.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
32
api/main.py
Normal file
32
api/main.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, progress, state, tasks, topics, workstreams
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Custodian State Hub",
|
||||
description="Local-first state API for the Custodian agent system.",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(progress.router)
|
||||
app.include_router(state.router)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return {"service": "state-hub", "docs": "/docs"}
|
||||
15
api/models/__init__.py
Normal file
15
api/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from api.models.base import Base
|
||||
from api.models.topic import Topic, TopicStatus, Domain
|
||||
from api.models.workstream import Workstream, WorkstreamStatus
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Topic", "TopicStatus", "Domain",
|
||||
"Workstream", "WorkstreamStatus",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"ProgressEvent",
|
||||
]
|
||||
26
api/models/base.py
Normal file
26
api/models/base.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
63
api/models/decision.py
Normal file
63
api/models/decision.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Enum, 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 DecisionType(str, enum.Enum):
|
||||
made = "made"
|
||||
pending = "pending"
|
||||
|
||||
|
||||
class DecisionStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
resolved = "resolved"
|
||||
escalated = "escalated"
|
||||
superseded = "superseded"
|
||||
|
||||
|
||||
class Decision(Base, TimestampMixin):
|
||||
__tablename__ = "decisions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workstream",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_type: Mapped[DecisionType] = mapped_column(
|
||||
Enum(DecisionType), nullable=False, default=DecisionType.pending
|
||||
)
|
||||
status: Mapped[DecisionStatus] = mapped_column(
|
||||
Enum(DecisionStatus), nullable=False, default=DecisionStatus.open
|
||||
)
|
||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
superseded_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||
)
|
||||
43
api/models/progress_event.py
Normal file
43
api/models/progress_event.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class ProgressEvent(Base):
|
||||
"""Append-only event log. No updated_at. No DELETE endpoint (constitution §5)."""
|
||||
|
||||
__tablename__ = "progress_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
|
||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||
57
api/models/task.py
Normal file
57
api/models/task.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, 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 TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
blocked = "blocked"
|
||||
done = "done"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
critical = "critical"
|
||||
|
||||
|
||||
class Task(Base, TimestampMixin):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
Enum(TaskStatus), nullable=False, default=TaskStatus.todo
|
||||
)
|
||||
priority: Mapped[TaskPriority] = mapped_column(
|
||||
Enum(TaskPriority), nullable=False, default=TaskPriority.medium
|
||||
)
|
||||
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
|
||||
subtasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="task", lazy="selectin"
|
||||
)
|
||||
48
api/models/topic.py
Normal file
48
api/models/topic.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, 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 TopicStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Domain(str, enum.Enum):
|
||||
custodian = "custodian"
|
||||
railiance = "railiance"
|
||||
markitect = "markitect"
|
||||
coulomb_social = "coulomb_social"
|
||||
personhood = "personhood"
|
||||
foerster_capabilities = "foerster_capabilities"
|
||||
|
||||
|
||||
class Topic(Base, TimestampMixin):
|
||||
__tablename__ = "topics"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
domain: Mapped[Domain] = mapped_column(Enum(Domain), nullable=False)
|
||||
status: Mapped[TopicStatus] = mapped_column(
|
||||
Enum(TopicStatus), nullable=False, default=TopicStatus.active
|
||||
)
|
||||
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
46
api/models/workstream.py
Normal file
46
api/models/workstream.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, 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 WorkstreamStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
blocked = "blocked"
|
||||
completed = "completed"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Workstream(Base, TimestampMixin):
|
||||
__tablename__ = "workstreams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[WorkstreamStatus] = mapped_column(
|
||||
Enum(WorkstreamStatus), nullable=False, default=WorkstreamStatus.active
|
||||
)
|
||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
|
||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||
"Task", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
0
api/routers/__init__.py
Normal file
0
api/routers/__init__.py
Normal file
110
api/routers/decisions.py
Normal file
110
api/routers/decisions.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionUpdate
|
||||
|
||||
router = APIRouter(prefix="/decisions", tags=["decisions"])
|
||||
|
||||
_FINANCIAL_LEGAL_KEYWORDS = (
|
||||
"financ", "legal", "payment", "purchas", "contract", "commit",
|
||||
"obligation", "external representation",
|
||||
)
|
||||
|
||||
|
||||
def _needs_escalation(body: DecisionCreate) -> str | None:
|
||||
if body.decision_type != DecisionType.pending:
|
||||
return None
|
||||
text = f"{body.title} {body.description or ''}".lower()
|
||||
for kw in _FINANCIAL_LEGAL_KEYWORDS:
|
||||
if kw in text:
|
||||
return (
|
||||
"Auto-escalated per constitution §4: this pending decision touches "
|
||||
"financial or legal territory and requires explicit human approval before action."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DecisionRead])
|
||||
async def list_decisions(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: DecisionStatus | None = None,
|
||||
decision_type: DecisionType | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Decision]:
|
||||
q = select(Decision)
|
||||
if topic_id:
|
||||
q = q.where(Decision.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(Decision.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Decision.status == status)
|
||||
if decision_type:
|
||||
q = q.where(Decision.decision_type == decision_type)
|
||||
q = q.order_by(Decision.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_decision(
|
||||
body: DecisionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
data = body.model_dump()
|
||||
note = _needs_escalation(body)
|
||||
if note:
|
||||
data["escalation_note"] = note
|
||||
data["status"] = DecisionStatus.escalated
|
||||
decision = Decision(**data)
|
||||
session.add(decision)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.get("/{decision_id}", response_model=DecisionRead)
|
||||
async def get_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
return decision
|
||||
|
||||
|
||||
@router.patch("/{decision_id}", response_model=DecisionRead)
|
||||
async def update_decision(
|
||||
decision_id: uuid.UUID,
|
||||
body: DecisionUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(decision, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.delete("/{decision_id}", response_model=DecisionRead)
|
||||
async def supersede_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
decision.status = DecisionStatus.superseded
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
50
api/routers/progress.py
Normal file
50
api/routers/progress.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
|
||||
router = APIRouter(prefix="/progress", tags=["progress"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ProgressEventRead])
|
||||
async def list_progress(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
task_id: uuid.UUID | None = None,
|
||||
event_type: str | None = None,
|
||||
since: datetime | None = None,
|
||||
limit: int = 100,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ProgressEvent]:
|
||||
q = select(ProgressEvent)
|
||||
if topic_id:
|
||||
q = q.where(ProgressEvent.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(ProgressEvent.workstream_id == workstream_id)
|
||||
if task_id:
|
||||
q = q.where(ProgressEvent.task_id == task_id)
|
||||
if event_type:
|
||||
q = q.where(ProgressEvent.event_type == event_type)
|
||||
if since:
|
||||
q = q.where(ProgressEvent.created_at >= since)
|
||||
q = q.order_by(ProgressEvent.created_at.desc()).limit(limit)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=ProgressEventRead, status_code=status.HTTP_201_CREATED)
|
||||
async def append_progress(
|
||||
body: ProgressEventCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProgressEvent:
|
||||
event = ProgressEvent(**body.model_dump())
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
return event
|
||||
132
api/routers/state.py
Normal file
132
api/routers/state.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session, engine
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.workstream import Workstream, WorkstreamStatus
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.state import (
|
||||
DecisionTotals,
|
||||
StateSummary,
|
||||
TaskTotals,
|
||||
Totals,
|
||||
TopicTotals,
|
||||
WorkstreamTotals,
|
||||
)
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead
|
||||
|
||||
router = APIRouter(prefix="/state", tags=["state"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=StateSummary)
|
||||
async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSummary:
|
||||
# Run all queries sequentially on one session.
|
||||
# AsyncSession does not support concurrent operations (no gather on same session).
|
||||
|
||||
topics_rows = await session.execute(
|
||||
select(Topic).where(Topic.status != TopicStatus.archived).order_by(Topic.created_at)
|
||||
)
|
||||
topics = list(topics_rows.scalars().all())
|
||||
|
||||
blocking_rows = await session.execute(
|
||||
select(Decision)
|
||||
.where(Decision.decision_type == DecisionType.pending)
|
||||
.where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated]))
|
||||
.order_by(Decision.deadline.asc().nullslast(), Decision.created_at)
|
||||
)
|
||||
blocking = list(blocking_rows.scalars().all())
|
||||
|
||||
blocked_rows = await session.execute(
|
||||
select(Task).where(Task.status == TaskStatus.blocked).order_by(Task.created_at)
|
||||
)
|
||||
blocked = list(blocked_rows.scalars().all())
|
||||
|
||||
recent_rows = await session.execute(
|
||||
select(ProgressEvent).order_by(ProgressEvent.created_at.desc()).limit(20)
|
||||
)
|
||||
recent = list(recent_rows.scalars().all())
|
||||
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
.where(Workstream.status.in_([WorkstreamStatus.active, WorkstreamStatus.blocked]))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
# Totals — one GROUP BY per table
|
||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
||||
)}
|
||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
)}
|
||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Decision.status, func.count()).group_by(Decision.status)
|
||||
)}
|
||||
|
||||
totals = Totals(
|
||||
topics=TopicTotals(
|
||||
active=topic_counts.get(TopicStatus.active, 0),
|
||||
paused=topic_counts.get(TopicStatus.paused, 0),
|
||||
archived=topic_counts.get(TopicStatus.archived, 0),
|
||||
total=sum(topic_counts.values()),
|
||||
),
|
||||
workstreams=WorkstreamTotals(
|
||||
active=ws_counts.get(WorkstreamStatus.active, 0),
|
||||
blocked=ws_counts.get(WorkstreamStatus.blocked, 0),
|
||||
completed=ws_counts.get(WorkstreamStatus.completed, 0),
|
||||
archived=ws_counts.get(WorkstreamStatus.archived, 0),
|
||||
total=sum(ws_counts.values()),
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
todo=task_counts.get(TaskStatus.todo, 0),
|
||||
in_progress=task_counts.get(TaskStatus.in_progress, 0),
|
||||
blocked=task_counts.get(TaskStatus.blocked, 0),
|
||||
done=task_counts.get(TaskStatus.done, 0),
|
||||
cancelled=task_counts.get(TaskStatus.cancelled, 0),
|
||||
total=sum(task_counts.values()),
|
||||
),
|
||||
decisions=DecisionTotals(
|
||||
open=dec_counts.get(DecisionStatus.open, 0),
|
||||
resolved=dec_counts.get(DecisionStatus.resolved, 0),
|
||||
escalated=dec_counts.get(DecisionStatus.escalated, 0),
|
||||
superseded=dec_counts.get(DecisionStatus.superseded, 0),
|
||||
total=sum(dec_counts.values()),
|
||||
),
|
||||
)
|
||||
|
||||
return StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
totals=totals,
|
||||
topics=[TopicWithWorkstreams.model_validate(t) for t in topics],
|
||||
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||
open_workstreams=[WorkstreamRead.model_validate(w) for w in open_ws],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "connected"}
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "error", "db": str(exc)},
|
||||
)
|
||||
83
api/routers/tasks.py
Normal file
83
api/routers/tasks.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TaskRead])
|
||||
async def list_tasks(
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
assignee: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Task.status == status)
|
||||
if assignee:
|
||||
q = q.where(Task.assignee == assignee)
|
||||
q = q.order_by(Task.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
body: TaskCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = Task(**body.model_dump())
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskRead)
|
||||
async def get_task(
|
||||
task_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskRead)
|
||||
async def update_task(
|
||||
task_id: uuid.UUID,
|
||||
body: TaskUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(task, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}", response_model=TaskRead)
|
||||
async def cancel_task(
|
||||
task_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task.status = TaskStatus.cancelled
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
77
api/routers/topics.py
Normal file
77
api/routers/topics.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams
|
||||
|
||||
router = APIRouter(prefix="/topics", tags=["topics"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TopicRead])
|
||||
async def list_topics(
|
||||
status: TopicStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Topic]:
|
||||
q = select(Topic)
|
||||
if status:
|
||||
q = q.where(Topic.status == status)
|
||||
q = q.order_by(Topic.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_topic(
|
||||
body: TopicCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = Topic(**body.model_dump())
|
||||
session.add(topic)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicWithWorkstreams)
|
||||
async def get_topic(
|
||||
topic_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
|
||||
@router.patch("/{topic_id}", response_model=TopicRead)
|
||||
async def update_topic(
|
||||
topic_id: uuid.UUID,
|
||||
body: TopicUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(topic, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.delete("/{topic_id}", response_model=TopicRead)
|
||||
async def archive_topic(
|
||||
topic_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
topic.status = TopicStatus.archived
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
80
api/routers/workstreams.py
Normal file
80
api/routers/workstreams.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.workstream import Workstream, WorkstreamStatus
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamRead, WorkstreamUpdate
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
async def list_workstreams(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
status: WorkstreamStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
q = select(Workstream)
|
||||
if topic_id:
|
||||
q = q.where(Workstream.topic_id == topic_id)
|
||||
if status:
|
||||
q = q.where(Workstream.status == status)
|
||||
q = q.order_by(Workstream.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workstream(
|
||||
body: WorkstreamCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = Workstream(**body.model_dump())
|
||||
session.add(ws)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.get("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def get_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
return ws
|
||||
|
||||
|
||||
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def update_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ws, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def archive_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
ws.status = WorkstreamStatus.archived
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
15
api/schemas/__init__.py
Normal file
15
api/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
|
||||
|
||||
__all__ = [
|
||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||
"ProgressEventCreate", "ProgressEventRead",
|
||||
"StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals",
|
||||
]
|
||||
58
api/schemas/decision.py
Normal file
58
api/schemas/decision.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.decision import DecisionStatus, DecisionType
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType = DecisionType.pending
|
||||
status: DecisionStatus = DecisionStatus.open
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workstream_id is None:
|
||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
decision_type: DecisionType | None = None
|
||||
status: DecisionStatus | None = None
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType
|
||||
status: DecisionStatus
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
32
api/schemas/progress_event.py
Normal file
32
api/schemas/progress_event.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ProgressEventCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
summary: str
|
||||
detail: dict[str, Any] | None = None
|
||||
author: str | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class ProgressEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
summary: str
|
||||
detail: dict[str, Any] | None = None
|
||||
author: str | None = None
|
||||
session_id: str | None = None
|
||||
created_at: datetime
|
||||
58
api/schemas/state.py
Normal file
58
api/schemas/state.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead
|
||||
|
||||
|
||||
class TopicTotals(BaseModel):
|
||||
active: int = 0
|
||||
paused: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class WorkstreamTotals(BaseModel):
|
||||
active: int = 0
|
||||
blocked: int = 0
|
||||
completed: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TaskTotals(BaseModel):
|
||||
todo: int = 0
|
||||
in_progress: int = 0
|
||||
blocked: int = 0
|
||||
done: int = 0
|
||||
cancelled: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class DecisionTotals(BaseModel):
|
||||
open: int = 0
|
||||
resolved: int = 0
|
||||
escalated: int = 0
|
||||
superseded: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class Totals(BaseModel):
|
||||
topics: TopicTotals
|
||||
workstreams: WorkstreamTotals
|
||||
tasks: TaskTotals
|
||||
decisions: DecisionTotals
|
||||
|
||||
|
||||
class StateSummary(BaseModel):
|
||||
generated_at: datetime
|
||||
totals: Totals
|
||||
topics: list[TopicWithWorkstreams]
|
||||
blocking_decisions: list[DecisionRead]
|
||||
blocked_tasks: list[TaskRead]
|
||||
recent_progress: list[ProgressEventRead]
|
||||
open_workstreams: list[WorkstreamRead]
|
||||
51
api/schemas/task.py
Normal file
51
api/schemas/task.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.task import TaskPriority, TaskStatus
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
priority: TaskPriority = TaskPriority.medium
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
priority: TaskPriority | None = None
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def blocking_reason_required_when_blocked(self) -> "TaskUpdate":
|
||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when status is blocked")
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
47
api/schemas/topic.py
Normal file
47
api/schemas/topic.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.topic import Domain, TopicStatus
|
||||
|
||||
|
||||
class TopicCreate(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
domain: Domain
|
||||
status: TopicStatus = TopicStatus.active
|
||||
|
||||
|
||||
class TopicUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
domain: Domain | None = None
|
||||
status: TopicStatus | None = None
|
||||
|
||||
|
||||
class WorkstreamStub(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
status: str
|
||||
owner: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
||||
|
||||
class TopicRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
domain: Domain
|
||||
status: TopicStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TopicWithWorkstreams(TopicRead):
|
||||
workstreams: list[WorkstreamStub] = []
|
||||
38
api/schemas/workstream.py
Normal file
38
api/schemas/workstream.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.workstream import WorkstreamStatus
|
||||
|
||||
|
||||
class WorkstreamCreate(BaseModel):
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus = WorkstreamStatus.active
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
|
||||
|
||||
class WorkstreamUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus | None = None
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
|
||||
|
||||
class WorkstreamRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
13
dashboard/observablehq.config.js
Normal file
13
dashboard/observablehq.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
root: "src",
|
||||
title: "Custodian State Hub",
|
||||
pages: [
|
||||
{ name: "Overview", path: "/" },
|
||||
{ name: "Workstreams", path: "/workstreams" },
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
],
|
||||
theme: ["air", "near-midnight"],
|
||||
head: `<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🗄️</text></svg>">`,
|
||||
footer: "Custodian State Hub — local-first, append-only, sovereignty-preserving.",
|
||||
};
|
||||
4184
dashboard/package-lock.json
generated
Normal file
4184
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
dashboard/package.json
Normal file
14
dashboard/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "custodian-state-hub-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "observable preview",
|
||||
"build": "observable build",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@observablehq/framework": "^1.13.3"
|
||||
}
|
||||
}
|
||||
15
dashboard/src/data/decisions.json.py
Normal file
15
dashboard/src/data/decisions.json.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: all decisions."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/decisions", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "decisions": []}))
|
||||
16
dashboard/src/data/progress.json.py
Normal file
16
dashboard/src/data/progress.json.py
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: recent progress events (last 200)."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
url = f"{API_BASE}/progress?limit=200"
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "events": []}))
|
||||
31
dashboard/src/data/summary.json.py
Normal file
31
dashboard/src/data/summary.json.py
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches /state/summary from the API."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/state/summary", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
# Return empty structure so the dashboard can show an error state
|
||||
print(json.dumps({
|
||||
"error": str(e),
|
||||
"generated_at": None,
|
||||
"totals": {
|
||||
"topics": {"active": 0, "paused": 0, "archived": 0, "total": 0},
|
||||
"workstreams": {"active": 0, "blocked": 0, "completed": 0, "archived": 0, "total": 0},
|
||||
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
|
||||
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
|
||||
},
|
||||
"topics": [],
|
||||
"blocking_decisions": [],
|
||||
"blocked_tasks": [],
|
||||
"recent_progress": [],
|
||||
"open_workstreams": [],
|
||||
}))
|
||||
15
dashboard/src/data/workstreams.json.py
Normal file
15
dashboard/src/data/workstreams.json.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: all workstreams."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/workstreams", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "workstreams": []}))
|
||||
70
dashboard/src/decisions.md
Normal file
70
dashboard/src/decisions.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Decisions
|
||||
---
|
||||
|
||||
# Decisions
|
||||
|
||||
```js
|
||||
const decisions = await FileAttachment("data/decisions.json").json();
|
||||
const data = Array.isArray(decisions) ? decisions : [];
|
||||
const pending = data.filter(d => d.decision_type === "pending");
|
||||
const made = data.filter(d => d.decision_type === "made");
|
||||
```
|
||||
|
||||
```js
|
||||
const tab = view(Inputs.select(["Pending", "Made"], { label: "View" }));
|
||||
```
|
||||
|
||||
```js
|
||||
const shown = tab === "Pending" ? pending : made;
|
||||
|
||||
display(Inputs.table(shown.map(d => ({
|
||||
Title: d.title,
|
||||
Status: d.status + (d.escalation_note ? " ⚠️" : ""),
|
||||
Decided_by: d.decided_by ?? "—",
|
||||
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
|
||||
Rationale: (d.rationale ?? "").slice(0, 80),
|
||||
Updated: new Date(d.updated_at).toLocaleDateString(),
|
||||
})), { rows: 30 }));
|
||||
```
|
||||
|
||||
## Resolution Velocity
|
||||
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const resolved = made.filter(d => d.decided_at);
|
||||
const byMonth = resolved.reduce((acc, d) => {
|
||||
const m = d.decided_at.slice(0, 7);
|
||||
acc[m] = (acc[m] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
display(Plot.plot({
|
||||
title: "Decisions Resolved per Month",
|
||||
x: { label: "Month", tickRotate: -30 },
|
||||
y: { label: "Count", grid: true },
|
||||
marks: [
|
||||
Plot.barY(
|
||||
Object.entries(byMonth).map(([month, count]) => ({ month, count })),
|
||||
{ x: "month", y: "count", fill: "steelblue", tip: true }
|
||||
),
|
||||
Plot.ruleY([0]),
|
||||
],
|
||||
marginBottom: 60,
|
||||
width: 700,
|
||||
}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) {
|
||||
display(html`<div class="escalation-box">
|
||||
<strong>⚠️ Escalated decisions require human approval before any action is taken (constitution §4).</strong>
|
||||
<ul>${pending.filter(d => d.escalation_note).map(d => html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
|
||||
</style>
|
||||
122
dashboard/src/index.md
Normal file
122
dashboard/src/index.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Overview
|
||||
---
|
||||
|
||||
# Custodian State Hub
|
||||
|
||||
```js
|
||||
const summary = await FileAttachment("data/summary.json").json();
|
||||
const totals = summary.totals ?? {};
|
||||
const ws = totals.workstreams ?? {};
|
||||
const tasks = totals.tasks ?? {};
|
||||
const decisions = totals.decisions ?? {};
|
||||
const topics = totals.topics ?? {};
|
||||
```
|
||||
|
||||
```js
|
||||
if (summary.error) display(html`<div class="warning">⚠️ API unreachable: ${summary.error}. Run <code>make api</code>.</div>`);
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
```js
|
||||
display(html`<div class="grid grid-cols-4" style="gap:1rem; margin-bottom:1.5rem;">
|
||||
<div class="card">
|
||||
<h3>Active Workstreams</h3>
|
||||
<p class="big-number">${ws.active ?? 0}</p>
|
||||
<small>${ws.blocked ?? 0} blocked</small>
|
||||
</div>
|
||||
<div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}">
|
||||
<h3>Blocking Decisions</h3>
|
||||
<p class="big-number">${(decisions.open ?? 0) + (decisions.escalated ?? 0)}</p>
|
||||
<small>${decisions.escalated ?? 0} escalated</small>
|
||||
</div>
|
||||
<div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}">
|
||||
<h3>Blocked Tasks</h3>
|
||||
<p class="big-number">${tasks.blocked ?? 0}</p>
|
||||
<small>of ${tasks.total ?? 0} total</small>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Progress Events Today</h3>
|
||||
<p class="big-number">${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p>
|
||||
<small>last 20 shown below</small>
|
||||
</div>
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Tasks by Domain
|
||||
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const tasksByDomain = [];
|
||||
for (const topic of (summary.topics ?? [])) {
|
||||
for (const ws of (topic.workstreams ?? [])) {
|
||||
// workstream stubs don't include tasks in summary — show per-topic WS count as proxy
|
||||
}
|
||||
tasksByDomain.push({ domain: topic.domain, status: topic.status, count: (topic.workstreams ?? []).length });
|
||||
}
|
||||
|
||||
display(Plot.plot({
|
||||
title: "Open Workstreams by Domain",
|
||||
x: { label: "Domain" },
|
||||
y: { label: "Count", grid: true },
|
||||
marks: [
|
||||
Plot.barY(tasksByDomain, { x: "domain", y: "count", fill: "domain", tip: true }),
|
||||
Plot.ruleY([0]),
|
||||
],
|
||||
marginBottom: 80,
|
||||
width: 700,
|
||||
}));
|
||||
```
|
||||
|
||||
## Blocking Decisions
|
||||
|
||||
```js
|
||||
const blocking = summary.blocking_decisions ?? [];
|
||||
if (blocking.length === 0) {
|
||||
display(html`<p style="color:green">✓ No blocking decisions.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(blocking.map(d => ({
|
||||
Title: d.title,
|
||||
Status: d.status,
|
||||
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
|
||||
Escalated: d.escalation_note ? "⚠️" : "",
|
||||
}))));
|
||||
}
|
||||
```
|
||||
|
||||
## Decisions Due Within 7 Days
|
||||
|
||||
```js
|
||||
const now = new Date();
|
||||
const in7 = new Date(now.getTime() + 7*24*60*60*1000);
|
||||
const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7);
|
||||
if (due.length === 0) {
|
||||
display(html`<p>No decisions due in next 7 days.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(due.map(d => ({
|
||||
Title: d.title,
|
||||
Deadline: new Date(d.deadline).toLocaleString(),
|
||||
Status: d.status,
|
||||
}))));
|
||||
}
|
||||
```
|
||||
|
||||
## Recent Activity
|
||||
|
||||
```js
|
||||
display(Inputs.table((summary.recent_progress ?? []).map(e => ({
|
||||
Time: new Date(e.created_at).toLocaleString(),
|
||||
Type: e.event_type,
|
||||
Author: e.author ?? "—",
|
||||
Summary: e.summary,
|
||||
})), { maxWidth: 900 }));
|
||||
```
|
||||
|
||||
<style>
|
||||
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
||||
.card.warn { border: 2px solid orange; }
|
||||
.big-number { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; }
|
||||
.warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; }
|
||||
</style>
|
||||
77
dashboard/src/progress.md
Normal file
77
dashboard/src/progress.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Progress
|
||||
---
|
||||
|
||||
# Progress Log
|
||||
|
||||
*Append-only per constitution §5 — no deletions.*
|
||||
|
||||
```js
|
||||
const events = await FileAttachment("data/progress.json").json();
|
||||
const data = Array.isArray(events) ? events : [];
|
||||
```
|
||||
|
||||
```js
|
||||
const authorFilter = view(Inputs.select(
|
||||
["(all)", ...new Set(data.map(e => e.author ?? "unknown"))],
|
||||
{ label: "Author" }
|
||||
));
|
||||
const typeFilter = view(Inputs.select(
|
||||
["(all)", ...new Set(data.map(e => e.event_type))],
|
||||
{ label: "Event type" }
|
||||
));
|
||||
const sinceFilter = view(Inputs.date({ label: "Since" }));
|
||||
```
|
||||
|
||||
```js
|
||||
const filtered = data.filter(e =>
|
||||
(authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) &&
|
||||
(typeFilter === "(all)" || e.event_type === typeFilter) &&
|
||||
(!sinceFilter || new Date(e.created_at) >= sinceFilter)
|
||||
);
|
||||
|
||||
display(html`<p><strong>${filtered.length}</strong> events shown (append-only, no deletions).</p>`);
|
||||
|
||||
display(Inputs.table(filtered.map(e => ({
|
||||
Time: new Date(e.created_at).toLocaleString(),
|
||||
Type: e.event_type,
|
||||
Author: e.author ?? "—",
|
||||
Summary: e.summary,
|
||||
})), { rows: 50 }));
|
||||
```
|
||||
|
||||
## Event Volume (Last 30 Days)
|
||||
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 30);
|
||||
|
||||
const byDay = data
|
||||
.filter(e => new Date(e.created_at) >= cutoff)
|
||||
.reduce((acc, e) => {
|
||||
const day = e.created_at.slice(0, 10);
|
||||
acc[day] = (acc[day] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
display(Plot.plot({
|
||||
title: "Progress Events per Day (30-day window)",
|
||||
x: { label: "Date", tickRotate: -30 },
|
||||
y: { label: "Events", grid: true },
|
||||
marks: [
|
||||
Plot.areaY(
|
||||
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })),
|
||||
{ x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3 }
|
||||
),
|
||||
Plot.lineY(
|
||||
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })),
|
||||
{ x: "day", y: "count", stroke: "steelblue" }
|
||||
),
|
||||
Plot.ruleY([0]),
|
||||
],
|
||||
marginBottom: 60,
|
||||
width: 750,
|
||||
}));
|
||||
```
|
||||
67
dashboard/src/workstreams.md
Normal file
67
dashboard/src/workstreams.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Workstreams
|
||||
---
|
||||
|
||||
# Workstreams
|
||||
|
||||
```js
|
||||
const workstreams = await FileAttachment("data/workstreams.json").json();
|
||||
const data = Array.isArray(workstreams) ? workstreams : [];
|
||||
```
|
||||
|
||||
```js
|
||||
const domainFilter = view(Inputs.select(
|
||||
["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))],
|
||||
{ label: "Domain" }
|
||||
));
|
||||
const statusFilter = view(Inputs.select(
|
||||
["(all)", "active", "blocked", "completed", "archived"],
|
||||
{ label: "Status" }
|
||||
));
|
||||
const ownerFilter = view(Inputs.text({ label: "Owner contains" }));
|
||||
```
|
||||
|
||||
```js
|
||||
const filtered = data.filter(w =>
|
||||
(domainFilter === "(all)" || w.domain === domainFilter) &&
|
||||
(statusFilter === "(all)" || w.status === statusFilter) &&
|
||||
(!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase()))
|
||||
);
|
||||
|
||||
const STATUS_COLOR = {
|
||||
active: "green",
|
||||
blocked: "orange",
|
||||
completed: "blue",
|
||||
archived: "gray",
|
||||
};
|
||||
|
||||
display(Inputs.table(filtered.map(w => ({
|
||||
Title: w.title,
|
||||
Domain: w.domain,
|
||||
Status: w.status,
|
||||
Owner: w.owner ?? "—",
|
||||
"Due": w.due_date ?? "—",
|
||||
"Updated": new Date(w.updated_at).toLocaleDateString(),
|
||||
})), {
|
||||
rows: 20,
|
||||
}));
|
||||
```
|
||||
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
display(Plot.plot({
|
||||
title: "Workstream Status Distribution",
|
||||
marks: [
|
||||
Plot.barX(
|
||||
Object.entries(
|
||||
filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
|
||||
).map(([status, count]) => ({ status, count })),
|
||||
{ y: "status", x: "count", fill: "status", tip: true }
|
||||
),
|
||||
Plot.ruleX([0]),
|
||||
],
|
||||
marginLeft: 80,
|
||||
width: 500,
|
||||
}));
|
||||
```
|
||||
33
infra/docker-compose.yml
Normal file
33
infra/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-custodian}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-custodian}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-custodian} -d ${POSTGRES_DB:-custodian}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
profiles: ["tools"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5050:80"
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@local.dev}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
0
mcp_server/__init__.py
Normal file
0
mcp_server/__init__.py
Normal file
360
mcp_server/server.py
Normal file
360
mcp_server/server.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Custodian State Hub MCP Server (stdio).
|
||||
|
||||
Thin HTTP client over the FastAPI service — no direct DB access.
|
||||
All business logic stays in the API; this layer is stateless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
mcp = FastMCP(
|
||||
name="state-hub",
|
||||
instructions=(
|
||||
"Custodian State Hub: tracks topics, workstreams, tasks, decisions, and progress events. "
|
||||
"Start every session with get_state_summary() for orientation. "
|
||||
"All writes emit a progress_event automatically."
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
return httpx.Client(base_url=API_BASE, timeout=30.0)
|
||||
|
||||
|
||||
def _get(path: str, params: dict | None = None) -> Any:
|
||||
with _client() as c:
|
||||
r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(path: str, body: dict) -> Any:
|
||||
if not path.endswith("/"):
|
||||
path = path + "/"
|
||||
with _client() as c:
|
||||
r = c.post(path, json={k: v for k, v in body.items() if v is not None})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _patch(path: str, body: dict) -> Any:
|
||||
with _client() as c:
|
||||
r = c.patch(path, json={k: v for k, v in body.items() if v is not None})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.resource("state://summary")
|
||||
def resource_summary() -> str:
|
||||
"""Full StateSummary JSON — primary orientation resource."""
|
||||
return json.dumps(_get("/state/summary"), indent=2)
|
||||
|
||||
|
||||
@mcp.resource("state://topics")
|
||||
def resource_topics() -> str:
|
||||
"""Active topics list."""
|
||||
return json.dumps(_get("/topics", {"status": "active"}), indent=2)
|
||||
|
||||
|
||||
@mcp.resource("state://workstreams/{topic_slug}")
|
||||
def resource_workstreams(topic_slug: str) -> str:
|
||||
"""Workstreams for a topic (by slug)."""
|
||||
topics = _get("/topics", {"status": "active"})
|
||||
match = next((t for t in topics if t["slug"] == topic_slug), None)
|
||||
if not match:
|
||||
return json.dumps({"error": f"Topic '{topic_slug}' not found"})
|
||||
return json.dumps(_get("/workstreams", {"topic_id": match["id"]}), indent=2)
|
||||
|
||||
|
||||
@mcp.resource("state://decisions/blocking")
|
||||
def resource_blocking_decisions() -> str:
|
||||
"""All pending/escalated decisions."""
|
||||
return json.dumps(
|
||||
_get("/decisions", {"decision_type": "pending", "status": "open"}),
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
@mcp.resource("state://tasks/blocked")
|
||||
def resource_blocked_tasks() -> str:
|
||||
"""All tasks with status=blocked."""
|
||||
return json.dumps(_get("/tasks", {"status": "blocked"}), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def get_state_summary() -> str:
|
||||
"""Primary orientation tool. Call at the start of every session.
|
||||
|
||||
Returns a full snapshot: topic/workstream/task/decision totals, blocking
|
||||
decisions, blocked tasks, open workstreams, and the 20 most recent events.
|
||||
"""
|
||||
return json.dumps(_get("/state/summary"), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_topic(slug: str) -> str:
|
||||
"""Return a topic (with workstreams) by slug, plus its recent progress events."""
|
||||
topics = _get("/topics")
|
||||
match = next((t for t in topics if t["slug"] == slug), None)
|
||||
if not match:
|
||||
return json.dumps({"error": f"Topic '{slug}' not found"})
|
||||
topic_detail = _get(f"/topics/{match['id']}")
|
||||
recent = _get("/progress", {"topic_id": match["id"], "limit": 10})
|
||||
return json.dumps({"topic": topic_detail, "recent_progress": recent}, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_blocked_tasks(workstream_id: str | None = None) -> str:
|
||||
"""List all tasks with status=blocked, optionally filtered by workstream_id."""
|
||||
return json.dumps(_get("/tasks", {"status": "blocked", "workstream_id": workstream_id}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_pending_decisions(topic_id: str | None = None) -> str:
|
||||
"""List pending decisions sorted by deadline (nulls last).
|
||||
|
||||
Optionally filter by topic_id. Escalated decisions are included and
|
||||
highlighted by their escalation_note.
|
||||
"""
|
||||
results = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_recent_progress(limit: int = 20, since: str | None = None) -> str:
|
||||
"""Retrieve recent progress events to reconstruct session history.
|
||||
|
||||
Args:
|
||||
limit: max events to return (default 20)
|
||||
since: ISO datetime string — only events after this timestamp
|
||||
"""
|
||||
return json.dumps(_get("/progress", {"limit": limit, "since": since}), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mutate tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def create_task(
|
||||
workstream_id: str,
|
||||
title: str,
|
||||
priority: str = "medium",
|
||||
description: str | None = None,
|
||||
assignee: str | None = None,
|
||||
due_date: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new task and emit a progress_event.
|
||||
|
||||
Args:
|
||||
workstream_id: UUID of the parent workstream
|
||||
title: task title
|
||||
priority: low | medium | high | critical
|
||||
description: optional longer description
|
||||
assignee: optional assignee name
|
||||
due_date: optional ISO date string (YYYY-MM-DD)
|
||||
"""
|
||||
task = _post("/tasks", {
|
||||
"workstream_id": workstream_id,
|
||||
"title": title,
|
||||
"priority": priority,
|
||||
"description": description,
|
||||
"assignee": assignee,
|
||||
"due_date": due_date,
|
||||
})
|
||||
_post("/progress", {
|
||||
"workstream_id": workstream_id,
|
||||
"task_id": task["id"],
|
||||
"event_type": "task_created",
|
||||
"summary": f"Task created: {title}",
|
||||
"author": "custodian",
|
||||
"detail": {"priority": priority, "assignee": assignee},
|
||||
})
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_task_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
blocking_reason: str | None = None,
|
||||
) -> str:
|
||||
"""Update a task's status. blocking_reason is required when status='blocked'.
|
||||
|
||||
Args:
|
||||
task_id: UUID of the task
|
||||
status: todo | in_progress | blocked | done | cancelled
|
||||
blocking_reason: required when status=blocked
|
||||
"""
|
||||
body: dict[str, Any] = {"status": status}
|
||||
if blocking_reason:
|
||||
body["blocking_reason"] = blocking_reason
|
||||
task = _patch(f"/tasks/{task_id}", body)
|
||||
_post("/progress", {
|
||||
"task_id": task_id,
|
||||
"workstream_id": task.get("workstream_id"),
|
||||
"event_type": "task_status_changed",
|
||||
"summary": f"Task status → {status}: {task['title']}",
|
||||
"author": "custodian",
|
||||
"detail": {"blocking_reason": blocking_reason},
|
||||
})
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def record_decision(
|
||||
title: str,
|
||||
decision_type: str = "pending",
|
||||
topic_id: str | None = None,
|
||||
workstream_id: str | None = None,
|
||||
description: str | None = None,
|
||||
rationale: str | None = None,
|
||||
decided_by: str | None = None,
|
||||
deadline: str | None = None,
|
||||
) -> str:
|
||||
"""Record a decision (made or pending).
|
||||
|
||||
Pending decisions touching financial/legal topics are auto-escalated per
|
||||
constitution §4.
|
||||
|
||||
Args:
|
||||
title: decision title
|
||||
decision_type: made | pending
|
||||
topic_id: optional topic UUID
|
||||
workstream_id: optional workstream UUID (at least one required)
|
||||
description: optional context
|
||||
rationale: reasoning behind the decision
|
||||
decided_by: person/agent who decided
|
||||
deadline: ISO datetime string for when decision is needed
|
||||
"""
|
||||
decision = _post("/decisions", {
|
||||
"title": title,
|
||||
"decision_type": decision_type,
|
||||
"topic_id": topic_id,
|
||||
"workstream_id": workstream_id,
|
||||
"description": description,
|
||||
"rationale": rationale,
|
||||
"decided_by": decided_by,
|
||||
"deadline": deadline,
|
||||
})
|
||||
_post("/progress", {
|
||||
"topic_id": topic_id,
|
||||
"workstream_id": workstream_id,
|
||||
"decision_id": decision["id"],
|
||||
"event_type": "decision_recorded",
|
||||
"summary": f"Decision recorded ({decision_type}): {title}",
|
||||
"author": "custodian",
|
||||
"detail": {"status": decision.get("status"), "escalation_note": decision.get("escalation_note")},
|
||||
})
|
||||
return json.dumps(decision, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def resolve_decision(
|
||||
decision_id: str,
|
||||
rationale: str,
|
||||
decided_by: str,
|
||||
) -> str:
|
||||
"""Mark a decision as resolved.
|
||||
|
||||
Args:
|
||||
decision_id: UUID of the decision
|
||||
rationale: final reasoning/outcome
|
||||
decided_by: who resolved it
|
||||
"""
|
||||
decision = _patch(f"/decisions/{decision_id}", {
|
||||
"status": "resolved",
|
||||
"decision_type": "made",
|
||||
"rationale": rationale,
|
||||
"decided_by": decided_by,
|
||||
"decided_at": datetime.utcnow().isoformat() + "Z",
|
||||
})
|
||||
_post("/progress", {
|
||||
"topic_id": decision.get("topic_id"),
|
||||
"workstream_id": decision.get("workstream_id"),
|
||||
"decision_id": decision_id,
|
||||
"event_type": "decision_resolved",
|
||||
"summary": f"Decision resolved by {decided_by}: {decision['title']}",
|
||||
"author": "custodian",
|
||||
"detail": {"rationale": rationale},
|
||||
})
|
||||
return json.dumps(decision, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_progress_event(
|
||||
summary: str,
|
||||
event_type: str = "note",
|
||||
topic_id: str | None = None,
|
||||
workstream_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> str:
|
||||
"""Append a progress event to the log.
|
||||
|
||||
Args:
|
||||
summary: human-readable summary of what happened
|
||||
event_type: free-form label, e.g. note | milestone | blocker | insight
|
||||
topic_id: optional topic UUID
|
||||
workstream_id: optional workstream UUID
|
||||
task_id: optional task UUID
|
||||
detail: optional structured data (JSONB)
|
||||
"""
|
||||
event = _post("/progress", {
|
||||
"topic_id": topic_id,
|
||||
"workstream_id": workstream_id,
|
||||
"task_id": task_id,
|
||||
"event_type": event_type,
|
||||
"summary": summary,
|
||||
"author": "custodian",
|
||||
"detail": detail,
|
||||
})
|
||||
return json.dumps(event, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_workstream_status(workstream_id: str, status: str) -> str:
|
||||
"""Update a workstream's status.
|
||||
|
||||
Args:
|
||||
workstream_id: UUID of the workstream
|
||||
status: active | blocked | completed | archived
|
||||
"""
|
||||
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
|
||||
_post("/progress", {
|
||||
"workstream_id": workstream_id,
|
||||
"topic_id": ws.get("topic_id"),
|
||||
"event_type": "workstream_status_changed",
|
||||
"summary": f"Workstream status → {status}: {ws['title']}",
|
||||
"author": "custodian",
|
||||
})
|
||||
return json.dumps(ws, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
0
migrations/__init__.py
Normal file
0
migrations/__init__.py
Normal file
56
migrations/env.py
Normal file
56
migrations/env.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
# Import all models so Alembic can detect them
|
||||
from api.models import Base # noqa: F401 — registers all ORM classes
|
||||
from api.models import ( # noqa: F401
|
||||
Decision, ProgressEvent, Task, Topic, Workstream,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Allow DATABASE_URL to override alembic.ini
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if db_url:
|
||||
# Alembic sync driver: replace asyncpg with psycopg2
|
||||
sync_url = db_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
config.set_main_option("sqlalchemy.url", sync_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
248
migrations/versions/0001_initial_schema.py
Normal file
248
migrations/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-02-24 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Enums
|
||||
topic_status = postgresql.ENUM(
|
||||
"active", "paused", "archived", name="topicstatus", create_type=True
|
||||
)
|
||||
domain = postgresql.ENUM(
|
||||
"custodian", "railiance", "markitect", "coulomb_social", "personhood",
|
||||
"foerster_capabilities", name="domain", create_type=True
|
||||
)
|
||||
workstream_status = postgresql.ENUM(
|
||||
"active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True
|
||||
)
|
||||
task_status = postgresql.ENUM(
|
||||
"todo", "in_progress", "blocked", "done", "cancelled", name="taskstatus", create_type=True
|
||||
)
|
||||
task_priority = postgresql.ENUM(
|
||||
"low", "medium", "high", "critical", name="taskpriority", create_type=True
|
||||
)
|
||||
decision_type = postgresql.ENUM(
|
||||
"made", "pending", name="decisiontype", create_type=True
|
||||
)
|
||||
decision_status = postgresql.ENUM(
|
||||
"open", "resolved", "escalated", "superseded", name="decisionstatus", create_type=True
|
||||
)
|
||||
|
||||
# topics
|
||||
op.create_table(
|
||||
"topics",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("domain", domain, nullable=False),
|
||||
sa.Column("status", topic_status, nullable=False, server_default="active"),
|
||||
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_topics_slug", "topics", ["slug"])
|
||||
|
||||
# workstreams
|
||||
op.create_table(
|
||||
"workstreams",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"topic_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("topics.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("status", workstream_status, nullable=False, server_default="active"),
|
||||
sa.Column("owner", sa.String(100), nullable=True),
|
||||
sa.Column("due_date", sa.Date, 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_workstreams_slug", "workstreams", ["slug"])
|
||||
op.create_index("ix_workstreams_topic_id", "workstreams", ["topic_id"])
|
||||
|
||||
# tasks
|
||||
op.create_table(
|
||||
"tasks",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"workstream_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workstreams.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("status", task_status, nullable=False, server_default="todo"),
|
||||
sa.Column("priority", task_priority, nullable=False, server_default="medium"),
|
||||
sa.Column("assignee", sa.String(100), nullable=True),
|
||||
sa.Column("due_date", sa.Date, nullable=True),
|
||||
sa.Column("blocking_reason", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"parent_task_id",
|
||||
postgresql.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_tasks_workstream_id", "tasks", ["workstream_id"])
|
||||
|
||||
# decisions
|
||||
op.create_table(
|
||||
"decisions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"topic_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("topics.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"workstream_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workstreams.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("decision_type", decision_type, nullable=False, server_default="pending"),
|
||||
sa.Column("status", decision_status, nullable=False, server_default="open"),
|
||||
sa.Column("rationale", sa.Text, nullable=True),
|
||||
sa.Column("decided_by", sa.String(100), nullable=True),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deadline", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("escalation_note", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"superseded_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("decisions.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,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workstream",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_decisions_topic_id", "decisions", ["topic_id"])
|
||||
op.create_index("ix_decisions_workstream_id", "decisions", ["workstream_id"])
|
||||
|
||||
# progress_events
|
||||
op.create_table(
|
||||
"progress_events",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"topic_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("topics.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"workstream_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workstreams.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"task_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"decision_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("decisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("event_type", sa.String(50), nullable=False),
|
||||
sa.Column("summary", sa.Text, nullable=False),
|
||||
sa.Column("detail", postgresql.JSONB, nullable=True),
|
||||
sa.Column("author", sa.String(100), nullable=True),
|
||||
sa.Column("session_id", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_progress_events_topic_id", "progress_events", ["topic_id"])
|
||||
op.create_index("ix_progress_events_workstream_id", "progress_events", ["workstream_id"])
|
||||
op.create_index("ix_progress_events_task_id", "progress_events", ["task_id"])
|
||||
op.create_index("ix_progress_events_decision_id", "progress_events", ["decision_id"])
|
||||
op.create_index("ix_progress_events_event_type", "progress_events", ["event_type"])
|
||||
op.create_index("ix_progress_events_created_at", "progress_events", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("progress_events")
|
||||
op.drop_table("decisions")
|
||||
op.drop_table("tasks")
|
||||
op.drop_table("workstreams")
|
||||
op.drop_table("topics")
|
||||
|
||||
for name in [
|
||||
"topicstatus", "domain", "workstreamstatus",
|
||||
"taskstatus", "taskpriority", "decisiontype", "decisionstatus",
|
||||
]:
|
||||
op.execute(f"DROP TYPE IF EXISTS {name}")
|
||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[project]
|
||||
name = "state-hub"
|
||||
version = "0.1.0"
|
||||
description = "Custodian State Hub — PostgreSQL + FastAPI + MCP server"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"httpx>=0.28.0",
|
||||
"fastmcp>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["api", "mcp_server"]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
185
scripts/pull_image.py
Normal file
185
scripts/pull_image.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pull a Docker Hub image via the registry v2 API using Python's SSL (OpenSSL),
|
||||
then import it via `docker load`. Bypasses Docker's Go TLS client entirely.
|
||||
|
||||
Usage: python pull_image.py <image:tag> [output.tar]
|
||||
e.g: python pull_image.py postgres:16-alpine postgres.tar
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import hashlib
|
||||
|
||||
|
||||
def get_token(repo: str) -> str:
|
||||
url = f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull"
|
||||
with urllib.request.urlopen(url, timeout=30) as r:
|
||||
return json.loads(r.read())["token"]
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Follow redirects but strip Authorization; keep Range and other headers."""
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
new_req = urllib.request.Request(newurl)
|
||||
# Forward Range header (needed for chunked downloads) but NOT Authorization
|
||||
for hdr in ("Range",):
|
||||
val = req.get_header(hdr.capitalize())
|
||||
if val:
|
||||
new_req.add_header(hdr, val)
|
||||
return new_req
|
||||
|
||||
|
||||
def _opener():
|
||||
return urllib.request.build_opener(_StripAuthOnRedirect())
|
||||
|
||||
|
||||
def registry_get(url: str, token: str, headers: dict | None = None) -> bytes:
|
||||
"""GET with Bearer auth; follows redirects WITHOUT auth (for S3/CDN blobs)."""
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", **(headers or {})})
|
||||
with _opener().open(req, timeout=60) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def pull_image(image: str, output_tar: str) -> None:
|
||||
if ":" in image:
|
||||
repo_name, tag = image.rsplit(":", 1)
|
||||
else:
|
||||
repo_name, tag = image, "latest"
|
||||
|
||||
if "/" not in repo_name:
|
||||
repo_name = f"library/{repo_name}"
|
||||
|
||||
print(f"Authenticating for {repo_name}:{tag} ...")
|
||||
token = get_token(repo_name)
|
||||
|
||||
# Fetch manifest (prefer OCI, fall back to v2 schema2)
|
||||
print("Fetching manifest ...")
|
||||
manifest_url = f"https://registry-1.docker.io/v2/{repo_name}/manifests/{tag}"
|
||||
manifest_bytes = registry_get(
|
||||
manifest_url,
|
||||
token,
|
||||
headers={"Accept": "application/vnd.docker.distribution.manifest.v2+json,"
|
||||
"application/vnd.oci.image.manifest.v1+json"},
|
||||
)
|
||||
manifest = json.loads(manifest_bytes)
|
||||
|
||||
# Handle manifest list (multi-arch) — pick linux/amd64
|
||||
media_type = manifest.get("mediaType", "") or manifest.get("schemaVersion", "")
|
||||
if "list" in str(media_type) or manifest.get("manifests"):
|
||||
print("Manifest list detected — selecting linux/amd64 ...")
|
||||
for m in manifest["manifests"]:
|
||||
plat = m.get("platform", {})
|
||||
if plat.get("os") == "linux" and plat.get("architecture") == "amd64":
|
||||
digest = m["digest"]
|
||||
manifest_bytes = registry_get(
|
||||
f"https://registry-1.docker.io/v2/{repo_name}/manifests/{digest}",
|
||||
token,
|
||||
headers={"Accept": "application/vnd.docker.distribution.manifest.v2+json"},
|
||||
)
|
||||
manifest = json.loads(manifest_bytes)
|
||||
break
|
||||
|
||||
config_digest = manifest["config"]["digest"]
|
||||
layers = manifest["layers"]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Download config blob
|
||||
print("Downloading config ...")
|
||||
config_data = registry_get(
|
||||
f"https://registry-1.docker.io/v2/{repo_name}/blobs/{config_digest}",
|
||||
token,
|
||||
)
|
||||
config_filename = config_digest.replace("sha256:", "") + ".json"
|
||||
config_path = os.path.join(tmpdir, config_filename)
|
||||
with open(config_path, "wb") as f:
|
||||
f.write(config_data)
|
||||
|
||||
# Download each layer
|
||||
layer_dirs = []
|
||||
for i, layer in enumerate(layers):
|
||||
digest = layer["digest"]
|
||||
size = layer["size"]
|
||||
short = digest[7:19]
|
||||
print(f"Downloading layer {i+1}/{len(layers)} ({short}..., {size//1024//1024}MB) ...")
|
||||
|
||||
blob_url = f"https://registry-1.docker.io/v2/{repo_name}/blobs/{digest}"
|
||||
req = urllib.request.Request(blob_url, headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
layer_dir = os.path.join(tmpdir, f"layer_{i}")
|
||||
os.makedirs(layer_dir)
|
||||
layer_tar = os.path.join(layer_dir, "layer.tar")
|
||||
version_file = os.path.join(layer_dir, "VERSION")
|
||||
json_file = os.path.join(layer_dir, "json")
|
||||
|
||||
# Stream download with Range-request chunking so a TCP corruption
|
||||
# only loses one 2MB chunk, not the whole download.
|
||||
CHUNK_SIZE = 2 * 1024 * 1024 # 2MB per Range request
|
||||
downloaded = 0
|
||||
with open(layer_tar, "wb") as f:
|
||||
while downloaded < size:
|
||||
end = min(downloaded + CHUNK_SIZE - 1, size - 1)
|
||||
while True:
|
||||
try:
|
||||
range_req = urllib.request.Request(
|
||||
blob_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Range": f"bytes={downloaded}-{end}",
|
||||
},
|
||||
)
|
||||
with _opener().open(range_req, timeout=60) as resp:
|
||||
data = resp.read()
|
||||
break
|
||||
except Exception as exc:
|
||||
print(f"\r retry at {downloaded//1024//1024}MB ({exc})...", end="", flush=True)
|
||||
import time; time.sleep(1)
|
||||
f.write(data)
|
||||
downloaded += len(data)
|
||||
pct = downloaded * 100 // size if size else 0
|
||||
print(f"\r {downloaded//1024//1024}MB / {size//1024//1024}MB ({pct}%)", end="", flush=True)
|
||||
print()
|
||||
|
||||
with open(version_file, "w") as f:
|
||||
f.write("1.0")
|
||||
with open(json_file, "w") as f:
|
||||
json.dump({"id": digest.replace("sha256:", "")}, f)
|
||||
|
||||
layer_dirs.append(f"layer_{i}/layer.tar")
|
||||
|
||||
# Write manifest.json
|
||||
manifest_json = [
|
||||
{
|
||||
"Config": config_filename,
|
||||
"RepoTags": [f"{repo_name.replace('library/', '')}:{tag}"],
|
||||
"Layers": layer_dirs,
|
||||
}
|
||||
]
|
||||
manifest_path = os.path.join(tmpdir, "manifest.json")
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump(manifest_json, f)
|
||||
|
||||
# Bundle into tar
|
||||
print(f"Building {output_tar} ...")
|
||||
with tarfile.open(output_tar, "w") as tar:
|
||||
for name in [config_filename, "manifest.json"]:
|
||||
tar.add(os.path.join(tmpdir, name), arcname=name)
|
||||
for i in range(len(layers)):
|
||||
for fname in ["layer.tar", "VERSION", "json"]:
|
||||
path = os.path.join(tmpdir, f"layer_{i}", fname)
|
||||
tar.add(path, arcname=f"layer_{i}/{fname}")
|
||||
|
||||
print(f"Done. Load with: docker load -i {output_tar}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: pull_image.py <image:tag> [output.tar]")
|
||||
sys.exit(1)
|
||||
image = sys.argv[1]
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else image.replace(":", "_").replace("/", "_") + ".tar"
|
||||
pull_image(image, output)
|
||||
97
scripts/seed.py
Normal file
97
scripts/seed.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Seed the 6 canonical topics from canon/projects/."""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow running from state-hub/ root
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import async_session_factory, engine
|
||||
from api.models.topic import Domain, Topic, TopicStatus
|
||||
|
||||
TOPICS = [
|
||||
{
|
||||
"slug": "custodian",
|
||||
"title": "The Custodian",
|
||||
"description": (
|
||||
"Master agent system: transgenerational cognitive infrastructure for "
|
||||
"co-creating and stewarding knowledge across all domains."
|
||||
),
|
||||
"domain": Domain.custodian,
|
||||
},
|
||||
{
|
||||
"slug": "railiance",
|
||||
"title": "Railiance",
|
||||
"description": (
|
||||
"DevOps & infrastructure reliability. Dependency for all other projects; "
|
||||
"provides the deployment and operational backbone."
|
||||
),
|
||||
"domain": Domain.railiance,
|
||||
},
|
||||
{
|
||||
"slug": "markitect",
|
||||
"title": "Markitect",
|
||||
"description": (
|
||||
"Knowledge artifact management: structured authoring, versioning, and "
|
||||
"retrieval of canonical documents."
|
||||
),
|
||||
"domain": Domain.markitect,
|
||||
},
|
||||
{
|
||||
"slug": "coulomb-social",
|
||||
"title": "Coulomb.social",
|
||||
"description": (
|
||||
"Co-creation marketplace experiment: connecting people around shared "
|
||||
"projects and complementary capabilities."
|
||||
),
|
||||
"domain": Domain.coulomb_social,
|
||||
},
|
||||
{
|
||||
"slug": "personhood",
|
||||
"title": "Personhood",
|
||||
"description": (
|
||||
"Rights and obligations framework: defining digital personhood, consent "
|
||||
"models, and data sovereignty."
|
||||
),
|
||||
"domain": Domain.personhood,
|
||||
},
|
||||
{
|
||||
"slug": "foerster-capabilities",
|
||||
"title": "Foerster Capabilities",
|
||||
"description": (
|
||||
"Agency capability taxonomy inspired by Heinz von Foerster: mapping the "
|
||||
"space of possible cognitive and social actions."
|
||||
),
|
||||
"domain": Domain.foerster_capabilities,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
async with async_session_factory() as session:
|
||||
for data in TOPICS:
|
||||
existing = await session.execute(
|
||||
select(Topic).where(Topic.slug == data["slug"])
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
print(f" skip (already exists): {data['slug']}")
|
||||
continue
|
||||
topic = Topic(
|
||||
slug=data["slug"],
|
||||
title=data["title"],
|
||||
description=data["description"],
|
||||
domain=data["domain"],
|
||||
status=TopicStatus.active,
|
||||
)
|
||||
session.add(topic)
|
||||
print(f" insert: {data['slug']}")
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
print("Seed complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
Reference in New Issue
Block a user