STATE-WP-0069 T06: add workplan flow entity and prefer open_workplans
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Introduce flows/workplan.yaml and /flows/workplan/* routes alongside the
legacy workstream flow. Dashboard, README, and MCP flow docs now prefer
open_workplans with open_workstreams fallback until dual-key retirement.
This commit is contained in:
tegwick 2026-07-08 21:26:07 +02:00
parent f596fc27c7
commit 2b83654ac3
12 changed files with 122 additions and 13 deletions

View file

@ -186,7 +186,8 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
"blocking_decisions": [...], // pending decisions only "blocking_decisions": [...], // pending decisions only
"waiting_tasks": [...], "waiting_tasks": [...],
"recent_progress": [...], // last 20 events "recent_progress": [...], // last 20 events
"open_workstreams": [...] "open_workplans": [...],
"open_workstreams": [...] // legacy alias of open_workplans until STATE-WP-0069 T06 retires it
} }
``` ```

View file

@ -24,12 +24,19 @@ from api.workplan_status import normalize_workplan_status
router = APIRouter(prefix="/flows", tags=["flows"]) router = APIRouter(prefix="/flows", tags=["flows"])
_WORKPLAN_FLOW_ENTITY_TYPES = frozenset({"workplan", "workstream"})
def _is_workplan_flow(entity_type: str) -> bool:
return entity_type in _WORKPLAN_FLOW_ENTITY_TYPES
@router.get("/definitions") @router.get("/definitions")
async def list_flow_definitions() -> list[dict[str, Any]]: async def list_flow_definitions() -> list[dict[str, Any]]:
flows = [ flows = [
load_flow(entity_type) load_flow(entity_type)
for entity_type in ( for entity_type in (
"workplan",
"workstream", "workstream",
"task", "task",
"contribution", "contribution",
@ -93,7 +100,7 @@ async def advance_workstation(
) )
entity = await _entity(entity_type, entity_id, session) entity = await _entity(entity_type, entity_id, session)
if entity_type == "workstream": if _is_workplan_flow(entity_type):
transition_workplan_status(entity, target_workstation) transition_workplan_status(entity, target_workstation)
elif entity_type == "task": elif entity_type == "task":
parent = await session.get(Workplan, entity.workplan_id) parent = await session.get(Workplan, entity.workplan_id)
@ -117,7 +124,9 @@ async def _flow_object(
) -> dict[str, Any]: ) -> dict[str, Any]:
entity = await _entity(entity_type, entity_id, session) entity = await _entity(entity_type, entity_id, session)
status = _value(entity.status) status = _value(entity.status)
current_status = normalize_workplan_status(status) if entity_type == "workstream" else status current_status = (
normalize_workplan_status(status) if _is_workplan_flow(entity_type) else status
)
obj: dict[str, Any] = { obj: dict[str, Any] = {
"id": str(entity.id), "id": str(entity.id),
"status": current_status, "status": current_status,
@ -125,7 +134,7 @@ async def _flow_object(
"previous_workstation": current_status, "previous_workstation": current_status,
} }
if entity_type == "workstream": if _is_workplan_flow(entity_type):
tasks = list((await session.execute( tasks = list((await session.execute(
select(Task).where(Task.workplan_id == entity_id) select(Task).where(Task.workplan_id == entity_id)
)).scalars().all()) )).scalars().all())
@ -163,6 +172,7 @@ async def _entity(
session: AsyncSession, session: AsyncSession,
): ):
model_by_type = { model_by_type = {
"workplan": Workplan,
"workstream": Workplan, "workstream": Workplan,
"task": Task, "task": Task,
"contribution": Contribution, "contribution": Contribution,

View file

@ -37,5 +37,6 @@ except urllib.error.URLError as e:
"waiting_tasks": [], "waiting_tasks": [],
"blocked_tasks": [], "blocked_tasks": [],
"recent_progress": [], "recent_progress": [],
"open_workplans": [],
"open_workstreams": [], "open_workstreams": [],
})) }))

View file

@ -86,5 +86,5 @@ a cycle exists and the WHI is penalised by 50%. The WHI KPI card on the
## Data source ## Data source
Dependency edges are derived from the `depends_on` arrays on `open_workstreams` Dependency edges are derived from the `depends_on` arrays on `open_workplans`
in `GET /state/summary`. Polls every **15 seconds**. in `GET /state/summary`. Polls every **15 seconds**.

View file

@ -437,7 +437,7 @@ display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem"
```js ```js
const waitingTasks = summary.waiting_tasks ?? summary.blocked_tasks ?? []; const waitingTasks = summary.waiting_tasks ?? summary.blocked_tasks ?? [];
const wsById = Object.fromEntries((summary.open_workstreams ?? []).map(w => [w.id, w])); const wsById = Object.fromEntries((summary.open_workplans ?? summary.open_workstreams ?? []).map(w => [w.id, w]));
const todayCount = (summary.recent_progress ?? []).filter(e => const todayCount = (summary.recent_progress ?? []).filter(e =>
e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length; e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length;
const decCount = (decisions.open ?? 0) + (decisions.escalated ?? 0); const decCount = (decisions.open ?? 0) + (decisions.escalated ?? 0);

View file

@ -282,7 +282,7 @@ display(Plot.plot({
display(_filtersForm); display(_filtersForm);
{ {
// Enrich each workplan with tasks/deps data from open_workstreams summary // Enrich each workplan with tasks/deps data from open_workplans summary
const _openWsMap = Object.fromEntries(openWs.map(w => [w.id, w])); const _openWsMap = Object.fromEntries(openWs.map(w => [w.id, w]));
const _wsTable = buildEntityTable( const _wsTable = buildEntityTable(
filtered, filtered,
@ -304,7 +304,7 @@ display(_filtersForm);
## Dependencies ## Dependencies
```js ```js
// Build dep cards from the enriched open_workstreams in the summary // Build dep cards from the enriched open_workplans in the summary
const wsWithDeps = openWs.filter(w => { const wsWithDeps = openWs.filter(w => {
const domain = data.find(d => d.id === w.id)?.domain ?? "unknown"; const domain = data.find(d => d.id === w.id)?.domain ?? "unknown";
return (filters.domain.length === 0 || filters.domain.includes(domain)) && return (filters.domain.length === 0 || filters.domain.includes(domain)) &&

View file

@ -61,7 +61,7 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers.
| 5 | Dashboard nav label `Workstreams` | `Workplans` (URL compat retained) | Prose | T02 | | 5 | Dashboard nav label `Workstreams` | `Workplans` (URL compat retained) | Prose | T02 |
| 5 | `dashboard/src/index.md` user-facing copy | workplan-first strings | Prose | T02 | | 5 | `dashboard/src/index.md` user-facing copy | workplan-first strings | Prose | T02 |
| 6 | `open_workstreams` summary cache key | `open_workplans` | Internal | T06 | | 6 | `open_workstreams` summary cache key | `open_workplans` | Internal | T06 |
| 6 | `flows/workstream.yaml` entity id | workplan successor flow | Internal | T06 | | 6 | `flows/workstream.yaml` entity id | `flows/workplan.yaml` (`custodian.workplan.v1`) | Internal | T06 (workplan flow shipped; workstream yaml retained) |
### Query-param aliases (not separately metered today) ### Query-param aliases (not separately metered today)

59
flows/workplan.yaml Normal file
View file

@ -0,0 +1,59 @@
id: custodian.workplan.v1
entity_type: workplan
workstations:
- name: proposed
description: Plan exists but needs review against the current repo state.
entry_assertions: []
exit_assertions: []
- name: ready
description: Reviewed and ready to execute.
entry_assertions: []
exit_assertions: []
- name: active
description: Work is underway.
entry_assertions: []
exit_assertions:
- id: dependencies.all_complete
target: dependencies.*.workstation
op: all_eq
value:
- finished
- archived
description: Dependency workplans have reached a closed state.
- name: blocked
description: Work is blocked by incomplete dependencies or missing input.
entry_assertions:
- id: dependencies.any_incomplete
target: dependencies.*.workstation
op: custom
value:
- finished
- archived
description: At least one dependency is not finished or archived.
exit_assertions:
- id: dependencies.all_complete
target: dependencies.*.workstation
op: all_eq
value:
- finished
- archived
description: All dependency workplans have reached finished or archived.
- name: backlog
description: Intentionally parked for later.
entry_assertions: []
exit_assertions: []
- name: finished
description: Work is complete.
entry_assertions:
- id: tasks.all_done
target: tasks.*.status
op: all_eq
value:
- done
- cancel
description: All child tasks are done or canceled.
exit_assertions: []
- name: archived
description: Closed work has been moved out of the active set.
entry_assertions: []
exit_assertions: []

View file

@ -577,7 +577,7 @@ def get_flow_state(entity_type: str, entity_id: str) -> str:
"""Return the declarative flow state for one entity. """Return the declarative flow state for one entity.
Args: Args:
entity_type: workstream | task | contribution | capability_request entity_type: workplan | workstream | task | contribution | capability_request
entity_id: UUID of the entity entity_id: UUID of the entity
Returns current workstation, exit-blocking assertions, reachable Returns current workstation, exit-blocking assertions, reachable
@ -592,7 +592,7 @@ def advance_workstation(entity_type: str, entity_id: str, target_workstation: st
"""Attempt to move an entity to a target workstation. """Attempt to move an entity to a target workstation.
Args: Args:
entity_type: workstream | task | contribution | capability_request entity_type: workplan | workstream | task | contribution | capability_request
entity_id: UUID of the entity entity_id: UUID of the entity
target_workstation: desired workstation/status name target_workstation: desired workstation/status name

View file

@ -507,7 +507,22 @@ class TestFlowEndpoints:
r = await client.get("/flows/definitions") r = await client.get("/flows/definitions")
assert r.status_code == 200 assert r.status_code == 200
entity_types = {item["entity_type"] for item in r.json()} entity_types = {item["entity_type"] for item in r.json()}
assert {"workstream", "task", "contribution", "capability_request"} <= entity_types assert {"workplan", "workstream", "task", "contribution", "capability_request"} <= entity_types
async def test_get_flow_state_and_advance_workplan(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
r = await client.get(f"/flows/workplan/{ws['id']}")
assert r.status_code == 200
assert r.json()["current_workstation"] == "active"
r = await client.post(f"/flows/workplan/{ws['id']}/advance/finished")
assert r.status_code == 200
assert r.json()["current_workstation"] == "finished"
async def test_get_flow_state_and_advance_workstream(self, client): async def test_get_flow_state_and_advance_workstream(self, client):
await _create_domain(client) await _create_domain(client)

View file

@ -173,9 +173,27 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities():
"capability_request", "capability_request",
"contribution", "contribution",
"task", "task",
"workplan",
"workstream", "workstream",
] ]
workplan_result = FlowEngine(
custom_ops={
"dependencies.any_incomplete": lambda assertion, obj, values: any(
value not in assertion.value for value in values
)
}
).evaluate(
{
"status": "active",
"tasks": [{"status": "done"}],
"dependencies": [{"workstation": "finished"}],
},
flows["workplan"],
)
assert "finished" in workplan_result.reachable
assert "blocked" in [item.workstation for item in workplan_result.unreachable]
workstream_result = FlowEngine( workstream_result = FlowEngine(
custom_ops={ custom_ops={
"dependencies.any_incomplete": lambda assertion, obj, values: any( "dependencies.any_incomplete": lambda assertion, obj, values: any(

View file

@ -10,7 +10,7 @@ topic_slug: custodian
planning_priority: medium planning_priority: medium
planning_order: 69 planning_order: 69
created: "2026-07-08" created: "2026-07-08"
updated: "2026-07-08" updated: "2026-07-09"
state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c" state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c"
--- ---
@ -213,6 +213,11 @@ Done when internal code reads workplan-first and grep budget for
Progress 2026-07-08: `/state/summary` now dual-writes `open_workplans` alongside Progress 2026-07-08: `/state/summary` now dual-writes `open_workplans` alongside
legacy `open_workstreams`; MCP `get_domain_summary` prefers `open_workplans`. legacy `open_workstreams`; MCP `get_domain_summary` prefers `open_workplans`.
Progress 2026-07-09 (T06): added `flows/workplan.yaml` (`custodian.workplan.v1`) and
`/flows/workplan/{id}` advance path; dashboard and README now read `open_workplans`
first with `open_workstreams` fallback. Legacy `flows/workstream.yaml` and summary
dual-key remain until legacy-meter clears callers.
## Task: Closeout — registry cleanup and verification ## Task: Closeout — registry cleanup and verification
```task ```task