generated from coulomb/repo-seed
feat(classification-spine): implement STATE-WP-0065 repo-anchored model
Replace the ad-hoc coordination-domain spine with the Repo Classification Standard: 14 market domains, classification columns on managed_repos, and workplans anchored by repo_id (topic_id optional). - Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename - Add api/classification.py validation and register-from-classification tooling - Expose workplan-first REST/MCP surface with legacy workstream aliases - Add C-24 consistency rule and legacy domain frontmatter mapping - Update dashboard repos page with category/capability/stake filters - Update orientation docs; mark STATE-WP-0065 finished
This commit is contained in:
@@ -21,8 +21,8 @@ from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
@@ -45,9 +45,9 @@ from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from api.routers.workstreams import _workplan_index
|
||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||
from api.workplan_status import (
|
||||
CLOSED_WORKSTREAM_STATUSES,
|
||||
OPEN_WORKSTREAM_STATUSES,
|
||||
normalize_workstream_status,
|
||||
CLOSED_WORKPLAN_STATUSES,
|
||||
OPEN_WORKPLAN_STATUSES,
|
||||
normalize_workplan_status,
|
||||
)
|
||||
from task_flow_engine import FlowEngine
|
||||
|
||||
@@ -82,7 +82,7 @@ async def get_summary(
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
@@ -96,16 +96,16 @@ async def get_summary(
|
||||
if topic_ids:
|
||||
topic_ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.topic_id,
|
||||
Workstream.id,
|
||||
Workstream.slug,
|
||||
Workstream.title,
|
||||
Workstream.status,
|
||||
Workstream.owner,
|
||||
Workstream.due_date,
|
||||
Workplan.topic_id,
|
||||
Workplan.id,
|
||||
Workplan.slug,
|
||||
Workplan.title,
|
||||
Workplan.status,
|
||||
Workplan.owner,
|
||||
Workplan.due_date,
|
||||
)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.order_by(Workstream.created_at)
|
||||
.where(Workplan.topic_id.in_(topic_ids))
|
||||
.order_by(Workplan.created_at)
|
||||
)
|
||||
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
||||
topic_workstreams.setdefault(topic_id, []).append({
|
||||
@@ -136,10 +136,10 @@ async def get_summary(
|
||||
recent = list(recent_rows.scalars().all())
|
||||
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
@@ -147,7 +147,7 @@ async def get_summary(
|
||||
task_per_ws: dict = {}
|
||||
task_statuses_per_ws: dict = {}
|
||||
for ws_id, tstat, cnt in await session.execute(
|
||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||
):
|
||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||
@@ -157,9 +157,9 @@ async def get_summary(
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -168,16 +168,16 @@ async def get_summary(
|
||||
dep_ws_ids = set()
|
||||
dep_task_ids = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
extra_rows = await session.execute(
|
||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
||||
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||
)
|
||||
for w in extra_rows.scalars():
|
||||
ws_lookup[w.id] = w
|
||||
@@ -189,7 +189,7 @@ async def get_summary(
|
||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id,
|
||||
@@ -230,9 +230,9 @@ async def get_summary(
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"dependencies": [
|
||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
||||
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||
for d in dep_rows
|
||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||
],
|
||||
}
|
||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||
@@ -246,7 +246,7 @@ async def get_summary(
|
||||
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)
|
||||
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||
)}
|
||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
@@ -407,7 +407,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
@@ -418,12 +418,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
topic_map = {topic.id: topic for topic in topics}
|
||||
|
||||
workstream_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.order_by(
|
||||
Workstream.planning_priority.asc().nullslast(),
|
||||
Workstream.planning_order.asc().nullslast(),
|
||||
Workstream.updated_at.desc(),
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
Workplan.updated_at.desc(),
|
||||
)
|
||||
)
|
||||
workstreams_all = list(workstream_rows.scalars().all())
|
||||
@@ -455,7 +455,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
task_statuses_per_ws: dict = {}
|
||||
task_totals_by_status: dict[str, int] = {}
|
||||
for ws_id, task_status, count in await session.execute(
|
||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||
):
|
||||
status = status_value(task_status)
|
||||
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
|
||||
@@ -467,15 +467,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
|
||||
open_ws = [
|
||||
w for w in workstreams_all
|
||||
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
|
||||
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
||||
]
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -490,19 +490,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"dependencies": [
|
||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
||||
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||
for d in dep_rows
|
||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||
],
|
||||
}
|
||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
|
||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status)
|
||||
|
||||
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)
|
||||
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||
)}
|
||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Decision.status, func.count()).group_by(Decision.status)
|
||||
@@ -631,7 +631,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
workplan_rows.append(DashboardWorkplanRow(
|
||||
id=w.id,
|
||||
title=w.title,
|
||||
status=normalize_workstream_status(w.status),
|
||||
status=normalize_workplan_status(w.status),
|
||||
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
||||
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
||||
workplan_filename=workplan.get("filename"),
|
||||
@@ -695,9 +695,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
||||
# Active workstream counts per domain (join through topics)
|
||||
ws_per_domain = {}
|
||||
for domain_id, cnt in await session.execute(
|
||||
select(Topic.domain_id, func.count(Workstream.id))
|
||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
select(Topic.domain_id, func.count(Workplan.id))
|
||||
.join(Workplan, Workplan.topic_id == Topic.id)
|
||||
.where(Workplan.status.in_(["active", "blocked"]))
|
||||
.group_by(Topic.domain_id)
|
||||
):
|
||||
ws_per_domain[domain_id] = cnt
|
||||
@@ -734,10 +734,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
Used by workstreams.md and dependencies.md which only need dep edges.
|
||||
"""
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
@@ -745,9 +745,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -755,9 +755,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
dep_ws_ids: set = set()
|
||||
dep_task_ids: set = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
|
||||
@@ -765,7 +765,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
extra_rows = await session.execute(
|
||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
||||
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||
)
|
||||
for w in extra_rows.scalars():
|
||||
ws_lookup[w.id] = w
|
||||
@@ -777,7 +777,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
||||
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||
@@ -831,7 +831,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
.options(noload("*"))
|
||||
.where(Decision.status == DecisionStatus.resolved)
|
||||
.where(Decision.decided_at >= cutoff)
|
||||
.where(Decision.workstream_id.isnot(None))
|
||||
.where(Decision.workplan_id.isnot(None))
|
||||
.order_by(Decision.decided_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
@@ -839,7 +839,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
open_tasks_rows = await session.execute(
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id == decision.workstream_id)
|
||||
.where(Task.workplan_id == decision.workplan_id)
|
||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
||||
)
|
||||
open_tasks = list(open_tasks_rows.scalars().all())
|
||||
@@ -848,7 +848,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||
if task.id in seen_task_ids:
|
||||
continue
|
||||
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
|
||||
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
||||
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
||||
steps.append(NextStep(
|
||||
type="resolved_decision",
|
||||
@@ -868,13 +868,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
||||
all_dep_rows = await session.execute(
|
||||
select(
|
||||
WorkstreamDependency.from_workstream_id,
|
||||
WorkstreamDependency.to_workstream_id,
|
||||
).where(WorkstreamDependency.to_workstream_id.isnot(None))
|
||||
WorkplanDependency.from_workplan_id,
|
||||
WorkplanDependency.to_workplan_id,
|
||||
).where(WorkplanDependency.to_workplan_id.isnot(None))
|
||||
)
|
||||
all_deps = all_dep_rows.all()
|
||||
|
||||
# Group from_workstream_id → set of to_workstream_ids
|
||||
# Group from_workplan_id → set of to_workplan_ids
|
||||
dep_map: dict = {}
|
||||
dep_ws_ids = set()
|
||||
for from_ws_id, to_ws_id in all_deps:
|
||||
@@ -886,12 +886,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
if dep_ws_ids:
|
||||
ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.id,
|
||||
Workstream.status,
|
||||
Workstream.title,
|
||||
Workstream.slug,
|
||||
Workstream.topic_id,
|
||||
).where(Workstream.id.in_(dep_ws_ids))
|
||||
Workplan.id,
|
||||
Workplan.status,
|
||||
Workplan.title,
|
||||
Workplan.slug,
|
||||
Workplan.topic_id,
|
||||
).where(Workplan.id.in_(dep_ws_ids))
|
||||
)
|
||||
ws_info = {
|
||||
ws_id: {
|
||||
@@ -906,9 +906,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
ready_from_ws_ids = [
|
||||
from_ws_id
|
||||
for from_ws_id, to_ws_ids in dep_map.items()
|
||||
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
|
||||
if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES
|
||||
and all(
|
||||
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
|
||||
normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES
|
||||
for to_id in to_ws_ids
|
||||
)
|
||||
]
|
||||
@@ -918,11 +918,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
todo_rows = await session.execute(
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id.in_(ready_from_ws_ids))
|
||||
.where(Task.workplan_id.in_(ready_from_ws_ids))
|
||||
.where(Task.status == TaskStatus.todo)
|
||||
)
|
||||
for task in todo_rows.scalars().all():
|
||||
todo_by_ws.setdefault(task.workstream_id, []).append(task)
|
||||
todo_by_ws.setdefault(task.workplan_id, []).append(task)
|
||||
|
||||
for from_ws_id in ready_from_ws_ids:
|
||||
from_ws = ws_info.get(from_ws_id, {})
|
||||
@@ -956,7 +956,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
return steps
|
||||
|
||||
|
||||
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
|
||||
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
|
||||
"""Get the domain slug for a workstream via its topic."""
|
||||
if ws is None or ws.topic_id is None:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user