Complete workplan state model cleanup

This commit is contained in:
2026-05-18 01:31:36 +02:00
parent 98b2cb6484
commit d6522a9a40
42 changed files with 789 additions and 310 deletions

View File

@@ -19,6 +19,7 @@ from api.models.contribution import Contribution
from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.workplan_status import normalize_workstream_status
router = APIRouter(prefix="/flows", tags=["flows"])
@@ -104,11 +105,12 @@ async def _flow_object(
) -> dict[str, Any]:
entity = await _entity(entity_type, entity_id, session)
status = _value(entity.status)
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
obj: dict[str, Any] = {
"id": str(entity.id),
"status": status,
"workstation": status,
"previous_workstation": status,
"status": current_status,
"workstation": current_status,
"previous_workstation": current_status,
}
if entity_type == "workstream":
@@ -127,7 +129,7 @@ async def _flow_object(
select(Workstream).where(Workstream.id.in_(dependency_ids))
)).scalars().all())
dependency_workstations = [
{"id": str(ws.id), "workstation": ws.status}
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
for ws in dep_ws
]
obj.update({

View File

@@ -38,6 +38,11 @@ from api.schemas.task import TaskRead
from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES,
OPEN_WORKSTREAM_STATUSES,
normalize_workstream_status,
)
from task_flow_engine import FlowEngine
router = APIRouter(prefix="/state", tags=["state"])
@@ -119,7 +124,7 @@ async def get_summary(
open_ws_rows = await session.execute(
select(Workstream)
.options(noload("*"))
.where(Workstream.status.in_(["active", "blocked"]))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -211,7 +216,7 @@ async def get_summary(
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": ws_lookup[d.to_workstream_id].status}
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_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
],
@@ -244,9 +249,16 @@ async def get_summary(
total=sum(topic_counts.values()),
),
workstreams=WorkstreamTotals(
proposed=ws_counts.get("proposed", 0),
ready=ws_counts.get("ready", 0) + ws_counts.get("todo", 0),
active=sum(1 for status in effective_status.values() if status == "active"),
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
completed=ws_counts.get("completed", 0),
backlog=ws_counts.get("backlog", 0),
finished=(
ws_counts.get("finished", 0)
+ ws_counts.get("completed", 0)
+ ws_counts.get("accepted", 0)
),
archived=ws_counts.get("archived", 0),
total=sum(ws_counts.values()),
),
@@ -366,7 +378,7 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
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 == "active")
.where(Workstream.status.in_(["active", "blocked"]))
.group_by(Topic.domain_id)
):
ws_per_domain[domain_id] = cnt
@@ -405,7 +417,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
open_ws_rows = await session.execute(
select(Workstream)
.options(noload("*"))
.where(Workstream.status.in_(["active", "blocked"]))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -488,7 +500,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
Two signal sources:
1. Recently resolved decisions (last 7 days) → first open task in same workstream
2. Workstreams whose every dependency is now completed first todo task in that workstream
2. Workstreams whose every dependency is now finished -> first todo task in that workstream
"""
steps: list[NextStep] = []
seen_task_ids: set = set()
@@ -575,8 +587,11 @@ 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 ws_info.get(from_ws_id, {}).get("status") in ("active", "blocked")
and all(ws_info.get(to_id, {}).get("status") == "completed" for to_id in to_ws_ids)
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
and all(
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
for to_id in to_ws_ids
)
]
todo_by_ws: dict = {}
@@ -613,7 +628,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
task_id=task.id,
task_title=task.title,
message=(
f"All dependencies of '{from_ws['title']}' are completed ({blocker_slugs}) "
f"All dependencies of '{from_ws['title']}' are finished ({blocker_slugs}) -> "
f"'{task.title}' is ready to start"
),
))
@@ -650,7 +665,7 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
Returns suggestions based on:
- Recently resolved decisions → first open task in the same workstream
- Workstreams whose every dependency workstream is now completed first todo task
- Workstreams whose every dependency workstream is now finished -> first todo task
"""
return await _derive_next_steps(session)

View File

@@ -5,6 +5,7 @@ import time
from pathlib import Path
from typing import Any
import yaml
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,9 +17,13 @@ from api.models.workstream import Workstream
from api.schemas.workstream import (
WorkstreamCreate,
WorkstreamRead,
WorkstreamStatus,
WorkstreamUpdate,
)
from api.workplan_status import (
is_supported_workstream_status,
normalize_workstream_status,
ready_review_status,
)
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
@@ -53,17 +58,10 @@ def _frontmatter(path: Path) -> dict[str, Any]:
if end == -1:
return {}
data: dict[str, Any] = {}
for raw_line in text[4:end].splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
data[key.strip()] = value
return data
try:
return yaml.safe_load(text[4:end].strip()) or {}
except yaml.YAMLError:
return {}
@router.get("/", response_model=list[WorkstreamRead])
@@ -71,7 +69,7 @@ async def list_workstreams(
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None,
status: WorkstreamStatus | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
@@ -84,7 +82,10 @@ async def list_workstreams(
if repo_goal_id:
q = q.where(Workstream.repo_goal_id == repo_goal_id)
if status:
q = q.where(Workstream.status == status)
normalised_status = normalize_workstream_status(status)
if not is_supported_workstream_status(status):
raise HTTPException(status_code=422, detail=f"Unsupported workstream status '{status}'")
q = q.where(Workstream.status == normalised_status)
if owner:
q = q.where(Workstream.owner == owner)
if slug:
@@ -127,11 +128,24 @@ async def workplan_index(
workstream_id = data.get("state_hub_workstream_id")
if not workstream_id:
continue
file_status = normalize_workstream_status(data.get("status", ""))
review = (
ready_review_status(
root,
data.get("reviewed_against_commit"),
data.get("context_paths"),
)
if file_status == "ready"
else None
)
index[str(workstream_id)] = {
"filename": path.name,
"relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug,
"archived": archived,
"status": file_status or None,
"needs_review": bool(review and review.needs_review),
"health_labels": ["needs_review"] if review and review.needs_review else [],
}
_INDEX_CACHE = {"workstreams": index}
_INDEX_CACHE_AT = time.monotonic()
@@ -176,7 +190,7 @@ async def update_workstream(
await session.commit()
await session.refresh(ws)
if prev_status != "completed" and ws.status == "completed":
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
subject = "org.statehub.workstream.completed"
envelope = EventEnvelope.new(
subject,