diff --git a/api/main.py b/api/main.py index f0949cf..e4e11da 100644 --- a/api/main.py +++ b/api/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.database import engine -from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies +from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies @asynccontextmanager @@ -32,6 +32,8 @@ app.include_router(workstreams.router) app.include_router(workstream_dependencies.router) app.include_router(tasks.router) app.include_router(decisions.router) +app.include_router(extension_points.router) +app.include_router(technical_debt.router) app.include_router(progress.router) app.include_router(state.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index f9c2107..562f0f8 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -5,6 +5,8 @@ from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority from api.models.decision import Decision, DecisionType, DecisionStatus from api.models.progress_event import ProgressEvent +from api.models.extension_point import ExtensionPoint, EPStatus +from api.models.technical_debt import TechnicalDebt, TDStatus __all__ = [ "Base", @@ -14,4 +16,6 @@ __all__ = [ "Task", "TaskStatus", "TaskPriority", "Decision", "DecisionType", "DecisionStatus", "ProgressEvent", + "ExtensionPoint", "EPStatus", + "TechnicalDebt", "TDStatus", ] diff --git a/api/models/extension_point.py b/api/models/extension_point.py new file mode 100644 index 0000000..745e898 --- /dev/null +++ b/api/models/extension_point.py @@ -0,0 +1,47 @@ +import enum +import uuid + +from sqlalchemy import 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 EPStatus(str, enum.Enum): + open = "open" + in_progress = "in_progress" + addressed = "addressed" + deferred = "deferred" + wont_fix = "wont_fix" + + +class ExtensionPoint(Base, TimestampMixin): + __tablename__ = "extension_points" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + ep_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. EP-CUST-001 + domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + ep_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # api | schema | mcp | dashboard | architecture | integration | other + status: Mapped[EPStatus] = mapped_column( + Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open + ) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py new file mode 100644 index 0000000..f7c954d --- /dev/null +++ b/api/models/technical_debt.py @@ -0,0 +1,47 @@ +import enum +import uuid + +from sqlalchemy import 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 TDStatus(str, enum.Enum): + open = "open" + in_progress = "in_progress" + resolved = "resolved" + deferred = "deferred" + wont_fix = "wont_fix" + + +class TechnicalDebt(Base, TimestampMixin): + __tablename__ = "technical_debt" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + td_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. TD-CUST-001 + domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + debt_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # design | implementation | test | docs | dependencies | performance | security | other + severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + status: Mapped[TDStatus] = mapped_column( + Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/routers/extension_points.py b/api/routers/extension_points.py new file mode 100644 index 0000000..e4c2a52 --- /dev/null +++ b/api/routers/extension_points.py @@ -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.extension_point import EPStatus, ExtensionPoint +from api.schemas.extension_point import EPCreate, EPRead, EPUpdate + +router = APIRouter(prefix="/extension-points", tags=["extension-points"]) + + +@router.get("/", response_model=list[EPRead]) +async def list_eps( + domain: str | None = None, + status: EPStatus | None = None, + ep_type: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ExtensionPoint]: + q = select(ExtensionPoint) + if domain: + q = q.where(ExtensionPoint.domain == domain) + if status: + q = q.where(ExtensionPoint.status == status) + if ep_type: + q = q.where(ExtensionPoint.ep_type == ep_type) + q = q.order_by(ExtensionPoint.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED) +async def create_ep( + body: EPCreate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = ExtensionPoint(**body.model_dump()) + session.add(ep) + await session.commit() + await session.refresh(ep) + return ep + + +@router.get("/{ep_id}", response_model=EPRead) +async def get_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + return ep + + +@router.patch("/{ep_id}", response_model=EPRead) +async def update_ep( + ep_id: uuid.UUID, + body: EPUpdate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(ep, field, value) + await session.commit() + await session.refresh(ep) + return ep + + +@router.delete("/{ep_id}", response_model=EPRead) +async def defer_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + ep.status = EPStatus.deferred + await session.commit() + await session.refresh(ep) + return ep diff --git a/api/routers/technical_debt.py b/api/routers/technical_debt.py new file mode 100644 index 0000000..5082328 --- /dev/null +++ b/api/routers/technical_debt.py @@ -0,0 +1,86 @@ +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.technical_debt import TDStatus, TechnicalDebt +from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate + +router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) + + +@router.get("/", response_model=list[TDRead]) +async def list_td( + domain: str | None = None, + status: TDStatus | None = None, + debt_type: str | None = None, + severity: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[TechnicalDebt]: + q = select(TechnicalDebt) + if domain: + q = q.where(TechnicalDebt.domain == domain) + if status: + q = q.where(TechnicalDebt.status == status) + if debt_type: + q = q.where(TechnicalDebt.debt_type == debt_type) + if severity: + q = q.where(TechnicalDebt.severity == severity) + q = q.order_by(TechnicalDebt.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED) +async def create_td( + body: TDCreate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = TechnicalDebt(**body.model_dump()) + session.add(td) + await session.commit() + await session.refresh(td) + return td + + +@router.get("/{td_id}", response_model=TDRead) +async def get_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + return td + + +@router.patch("/{td_id}", response_model=TDRead) +async def update_td( + td_id: uuid.UUID, + body: TDUpdate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(td, field, value) + await session.commit() + await session.refresh(td) + return td + + +@router.delete("/{td_id}", response_model=TDRead) +async def defer_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + td.status = TDStatus.deferred + await session.commit() + await session.refresh(td) + return td diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 16d68ee..139016d 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -4,6 +4,8 @@ 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 +from api.schemas.extension_point import EPCreate, EPUpdate, EPRead +from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead __all__ = [ "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", @@ -12,4 +14,6 @@ __all__ = [ "DecisionCreate", "DecisionUpdate", "DecisionRead", "ProgressEventCreate", "ProgressEventRead", "StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals", + "EPCreate", "EPUpdate", "EPRead", + "TDCreate", "TDUpdate", "TDRead", ] diff --git a/api/schemas/extension_point.py b/api/schemas/extension_point.py new file mode 100644 index 0000000..86c1918 --- /dev/null +++ b/api/schemas/extension_point.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.extension_point import EPStatus + +VALID_DOMAINS = { + "custodian", "railiance", "markitect", + "coulomb_social", "personhood", "foerster_capabilities", +} +VALID_PRIORITIES = {"low", "medium", "high", "critical"} + + +class EPCreate(BaseModel): + ep_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + ep_type: str = "other" + status: EPStatus = EPStatus.open + priority: str = "medium" + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + + +class EPUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + ep_type: str | None = None + status: EPStatus | None = None + priority: str | None = None + workstream_id: uuid.UUID | None = None + + +class EPRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + ep_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + ep_type: str + status: EPStatus + priority: str + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/technical_debt.py b/api/schemas/technical_debt.py new file mode 100644 index 0000000..615240d --- /dev/null +++ b/api/schemas/technical_debt.py @@ -0,0 +1,49 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.technical_debt import TDStatus + +VALID_SEVERITIES = {"low", "medium", "high", "critical"} + + +class TDCreate(BaseModel): + td_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + debt_type: str = "other" + severity: str = "medium" + status: TDStatus = TDStatus.open + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + + +class TDUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + debt_type: str | None = None + severity: str | None = None + status: TDStatus | None = None + workstream_id: uuid.UUID | None = None + + +class TDRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + td_id: str | None = None + domain: str + title: str + description: str | None = None + location: str | None = None + debt_type: str + severity: str + status: TDStatus + topic_id: uuid.UUID | None = None + workstream_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 800335c..6fe4d16 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -7,6 +7,8 @@ export default { { name: "Tasks", path: "/tasks" }, { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, + { name: "Extension Points", path: "/extensions" }, + { name: "Technical Debt", path: "/techdept" }, { name: "Reference", pages: [ diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md new file mode 100644 index 0000000..119cd22 --- /dev/null +++ b/dashboard/src/extensions.md @@ -0,0 +1,251 @@ +--- +title: Extension Points +--- + +```js +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; +``` + +```js +const epState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [re, rw, rt] = await Promise.all([ + fetch(`${API}/extension-points/`), + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + ok = re.ok && rw.ok && rt.ok; + if (ok) { + const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const wsMap = Object.fromEntries(wsList.map(w => [w.id, { + ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", + }])); + data = epList.map(e => ({ + ...e, + workstream_title: wsMap[e.workstream_id]?.title ?? null, + })).sort((a, b) => { + const pr = {critical: 0, high: 1, medium: 2, low: 3}; + const st = {open: 0, in_progress: 1, deferred: 2, addressed: 3, wont_fix: 4}; + const sd = (st[a.status] ?? 9) - (st[b.status] ?? 9); + return sd !== 0 ? sd : (pr[a.priority] ?? 9) - (pr[b.priority] ?? 9); + }); + } + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const data = epState.data ?? []; +const _ok = epState.ok ?? false; +const _ts = epState.ts; +``` + +```js +import {MultiSelect} from "./components/multiselect.js"; + +const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"]; +const PRIORITIES = ["critical", "high", "medium", "low"]; +const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"]; +const EP_TYPES = ["api", "schema", "mcp", "dashboard", "architecture", "integration", "other"]; + +const _filtersForm = Inputs.form( + { + status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), + priority: MultiSelect(PRIORITIES, {label: "Priority", placeholder: "All priorities"}), + domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), + ep_type: MultiSelect(EP_TYPES, {label: "Type", placeholder: "All types"}), + }, + { + template: ({status, priority, domain, ep_type}) => html`
`, + } +); +``` + +```js +const filters = Generators.input(_filtersForm); +``` + +```js +const filtered = data.filter(e => + (filters.status.length === 0 || filters.status.includes(e.status)) && + (filters.priority.length === 0 || filters.priority.includes(e.priority)) && + (filters.domain.length === 0 || filters.domain.includes(e.domain)) && + (filters.ep_type.length === 0 || filters.ep_type.includes(e.ep_type)) +); +``` + +# Extension Points + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +// ── KPI sidebar ─────────────────────────────────────────────────────────────── +const _open = data.filter(e => e.status === "open" || e.status === "in_progress"); +const _addressed = data.filter(e => e.status === "addressed"); +const _byType = EP_TYPES.map(t => [t, data.filter(e => e.ep_type === t && e.status === "open").length]) + .filter(([,n]) => n > 0); + +const _kpiBox = html`make api`}
+No extension points match the current filter.
`); +} else { + display(html`${filtered.length} extension points shown.
`); + +display(html`${ep.location}make api`}
+No technical debt items match the current filter.
`); +} else { + display(html`No critical or high severity open items in current filter. ✓
`); +} else { + display(html`${t.location}${filtered.length} items shown.
`); + +display(html`${t.location}