From 2b83654ac33971a34c9a714c17f6638c68e4282d Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 8 Jul 2026 21:26:07 +0200 Subject: [PATCH] STATE-WP-0069 T06: add workplan flow entity and prefer open_workplans 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. --- README.md | 3 +- api/routers/flows.py | 16 ++++- dashboard/src/data/summary.json.py | 1 + dashboard/src/docs/dependencies.md | 2 +- dashboard/src/index.md | 2 +- dashboard/src/workstreams.md | 4 +- ...n-terminology-legacy-retirement-backlog.md | 2 +- flows/workplan.yaml | 59 +++++++++++++++++++ mcp_server/server.py | 4 +- tests/test_routers_core.py | 17 +++++- tests/test_task_flow_engine.py | 18 ++++++ ...-workplan-terminology-legacy-retirement.md | 7 ++- 12 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 flows/workplan.yaml diff --git a/README.md b/README.md index f73cf83..3c147a6 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,8 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar "blocking_decisions": [...], // pending decisions only "waiting_tasks": [...], "recent_progress": [...], // last 20 events - "open_workstreams": [...] + "open_workplans": [...], + "open_workstreams": [...] // legacy alias of open_workplans until STATE-WP-0069 T06 retires it } ``` diff --git a/api/routers/flows.py b/api/routers/flows.py index c017e3c..176fc1d 100644 --- a/api/routers/flows.py +++ b/api/routers/flows.py @@ -24,12 +24,19 @@ from api.workplan_status import normalize_workplan_status 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") async def list_flow_definitions() -> list[dict[str, Any]]: flows = [ load_flow(entity_type) for entity_type in ( + "workplan", "workstream", "task", "contribution", @@ -93,7 +100,7 @@ async def advance_workstation( ) entity = await _entity(entity_type, entity_id, session) - if entity_type == "workstream": + if _is_workplan_flow(entity_type): transition_workplan_status(entity, target_workstation) elif entity_type == "task": parent = await session.get(Workplan, entity.workplan_id) @@ -117,7 +124,9 @@ async def _flow_object( ) -> dict[str, Any]: entity = await _entity(entity_type, entity_id, session) 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] = { "id": str(entity.id), "status": current_status, @@ -125,7 +134,7 @@ async def _flow_object( "previous_workstation": current_status, } - if entity_type == "workstream": + if _is_workplan_flow(entity_type): tasks = list((await session.execute( select(Task).where(Task.workplan_id == entity_id) )).scalars().all()) @@ -163,6 +172,7 @@ async def _entity( session: AsyncSession, ): model_by_type = { + "workplan": Workplan, "workstream": Workplan, "task": Task, "contribution": Contribution, diff --git a/dashboard/src/data/summary.json.py b/dashboard/src/data/summary.json.py index 63fd613..b98f713 100644 --- a/dashboard/src/data/summary.json.py +++ b/dashboard/src/data/summary.json.py @@ -37,5 +37,6 @@ except urllib.error.URLError as e: "waiting_tasks": [], "blocked_tasks": [], "recent_progress": [], + "open_workplans": [], "open_workstreams": [], })) diff --git a/dashboard/src/docs/dependencies.md b/dashboard/src/docs/dependencies.md index 34fd5a6..518ce04 100644 --- a/dashboard/src/docs/dependencies.md +++ b/dashboard/src/docs/dependencies.md @@ -86,5 +86,5 @@ a cycle exists and the WHI is penalised by 50%. The WHI KPI card on the ## 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**. diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 5a37cab..83fc057 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -437,7 +437,7 @@ display(html`
[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 => e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length; const decCount = (decisions.open ?? 0) + (decisions.escalated ?? 0); diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index e3faa2c..16e699f 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -282,7 +282,7 @@ display(Plot.plot({ 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 _wsTable = buildEntityTable( filtered, @@ -304,7 +304,7 @@ display(_filtersForm); ## Dependencies ```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 domain = data.find(d => d.id === w.id)?.domain ?? "unknown"; return (filters.domain.length === 0 || filters.domain.includes(domain)) && diff --git a/docs/workplan-terminology-legacy-retirement-backlog.md b/docs/workplan-terminology-legacy-retirement-backlog.md index f946caa..8c76ab3 100644 --- a/docs/workplan-terminology-legacy-retirement-backlog.md +++ b/docs/workplan-terminology-legacy-retirement-backlog.md @@ -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/src/index.md` user-facing copy | workplan-first strings | Prose | T02 | | 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) diff --git a/flows/workplan.yaml b/flows/workplan.yaml new file mode 100644 index 0000000..11ba99d --- /dev/null +++ b/flows/workplan.yaml @@ -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: [] \ No newline at end of file diff --git a/mcp_server/server.py b/mcp_server/server.py index ef4e55d..ab195a0 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -577,7 +577,7 @@ def get_flow_state(entity_type: str, entity_id: str) -> str: """Return the declarative flow state for one entity. Args: - entity_type: workstream | task | contribution | capability_request + entity_type: workplan | workstream | task | contribution | capability_request entity_id: UUID of the entity 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. Args: - entity_type: workstream | task | contribution | capability_request + entity_type: workplan | workstream | task | contribution | capability_request entity_id: UUID of the entity target_workstation: desired workstation/status name diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index e5166a7..b211536 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -507,7 +507,22 @@ class TestFlowEndpoints: r = await client.get("/flows/definitions") assert r.status_code == 200 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): await _create_domain(client) diff --git a/tests/test_task_flow_engine.py b/tests/test_task_flow_engine.py index 187188b..9987d86 100644 --- a/tests/test_task_flow_engine.py +++ b/tests/test_task_flow_engine.py @@ -173,9 +173,27 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities(): "capability_request", "contribution", "task", + "workplan", "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( custom_ops={ "dependencies.any_incomplete": lambda assertion, obj, values: any( diff --git a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md index dd70357..30e29f5 100644 --- a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md +++ b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md @@ -10,7 +10,7 @@ topic_slug: custodian planning_priority: medium planning_order: 69 created: "2026-07-08" -updated: "2026-07-08" +updated: "2026-07-09" 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 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