STATE-WP-0069 T04/T06: body metering and workplan-first state internals
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Share LegacyWorkstreamIdBodyMixin across create schemas; meter POST /tasks/
and /decisions/ workstream_id bodies. State summary uses workplan flow;
NextStep dual-writes workplan_* fields alongside legacy workstream_*.
This commit is contained in:
tegwick 2026-07-08 23:13:28 +02:00
parent 6e5e150803
commit 388b330809
11 changed files with 157 additions and 36 deletions

View file

@ -53,6 +53,22 @@ from api.services.summary_cache import (
get_summary_cache,
register_summary_cache_invalidation,
)
def _dual_workplan_refs(
workplan_id,
title: str | None,
slug: str | None,
) -> dict:
"""Dual-write workplan-first and legacy workstream fields for NextStep."""
return {
"workplan_id": workplan_id,
"workplan_title": title,
"workplan_slug": slug,
"workstream_id": workplan_id,
"workstream_title": title,
"workstream_slug": slug,
}
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import (
CLOSED_WORKPLAN_STATUSES,
@ -183,7 +199,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
)
open_ws = list(open_ws_rows.scalars().all())
# Task counts per workstream (used to enrich open_workstreams)
# Task counts per workplan (used to enrich open_workplans / open_workstreams)
task_per_ws: dict = {}
task_statuses_per_ws: dict = {}
for ws_id, tstat, cnt in await session.execute(
@ -260,7 +276,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
description=d.description,
))
workstream_flow = load_flow("workstream")
workplan_flow = load_flow("workplan")
flow_engine = FlowEngine()
effective_status: dict = {}
blocked_reasons: dict = {}
@ -275,7 +291,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
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)
flow_result = flow_engine.evaluate(flow_obj, workplan_flow)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else w.status
blocked_reasons[w.id] = [
assertion_result_to_dict(item) for item in flow_result.blocking_assertions
@ -529,7 +545,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
dep_rows = list(dep_result.scalars().all())
ws_lookup = {w.id: w for w in workstreams_all}
workstream_flow = load_flow("workstream")
workplan_flow = load_flow("workplan")
flow_engine = FlowEngine()
effective_status: dict = {}
for w in open_ws:
@ -543,7 +559,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
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)
flow_result = flow_engine.evaluate(flow_obj, workplan_flow)
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(
@ -923,13 +939,15 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
if task.id in seen_task_ids:
continue
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
domain_slug = await _get_domain_slug_for_workstream(ws, session)
domain_slug = await _get_domain_slug_for_workplan(ws, session)
steps.append(NextStep(
type="resolved_decision",
domain=domain_slug,
workstream_id=ws.id if ws else None,
workstream_title=ws.title if ws else None,
workstream_slug=ws.slug if ws else None,
**_dual_workplan_refs(
ws.id if ws else None,
ws.title if ws else None,
ws.slug if ws else None,
),
task_id=task.id,
task_title=task.title,
message=(
@ -1015,9 +1033,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
steps.append(NextStep(
type="dependency_cleared",
domain=domain_slug,
workstream_id=from_ws_id,
workstream_title=from_ws["title"],
workstream_slug=from_ws["slug"],
**_dual_workplan_refs(from_ws_id, from_ws["title"], from_ws["slug"]),
task_id=task.id,
task_title=task.title,
message=(
@ -1040,9 +1056,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
steps.append(NextStep(
type="open_suggestion",
domain=suggestion.domain_slug,
workstream_id=suggestion.workplan_id,
workstream_title=None,
workstream_slug=suggestion.origin_ref,
**_dual_workplan_refs(suggestion.workplan_id, None, suggestion.origin_ref),
task_id=None,
task_title=None,
message=(
@ -1054,8 +1068,8 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
return steps, open_suggestions
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
"""Get the domain slug for a workstream via its topic."""
async def _get_domain_slug_for_workplan(ws: Workplan | None, session: AsyncSession) -> str | None:
"""Get the domain slug for a workplan via its topic."""
if ws is None or ws.topic_id is None:
return None
return await _get_domain_slug_for_topic(ws.topic_id, session)