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:
2026-02-24 17:47:49 +01:00
commit 0ea2788943
48 changed files with 8567 additions and 0 deletions

16
api/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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"
)

View 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
View 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
View 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
View 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
View File

110
api/routers/decisions.py Normal file
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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