refactor(A1): Progression-Lanes aus Graph statt Strang=Project
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 4m52s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 31s
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 4m52s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 31s
Ersetzt strand_project_id/Stream-Projects in der A1-Steuerung durch graph-abgeleitete Lanes (requires-Ketten + Join). Recurring bindet nur noch am Gate; Re-Abnahme-Docs und ADP dokumentieren die Korrektur. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
88a03a65f2
commit
17add13d9d
|
|
@ -316,9 +316,10 @@ def _retire_legacy_linear_stages_for_progression(
|
|||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_gate_ids: frozenset[str],
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Starter-Kit-Stufen ohne Strang zurückstellen, wenn Join-Graph angelegt wird."""
|
||||
"""Starter-Kit-Stufen zurückstellen, wenn Join-Graph angelegt wird."""
|
||||
from services.maturity_practice import list_practices_for_stage, update_recurring_element
|
||||
from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item
|
||||
|
||||
|
|
@ -327,7 +328,7 @@ def _retire_legacy_linear_stages_for_progression(
|
|||
):
|
||||
if item.get("item_type") != "maturity_stage":
|
||||
continue
|
||||
if item.get("strand_project_id"):
|
||||
if str(item["id"]) in keep_gate_ids:
|
||||
continue
|
||||
if item.get("status") not in ("active", "at_risk"):
|
||||
continue
|
||||
|
|
@ -357,9 +358,8 @@ def apply_a1_progression_demo(
|
|||
initiative_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""PO-Kernbild: 2 Stränge → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
|
||||
"""PO-Kernbild: 2 parallele Gates → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
|
||||
from services.maturity_practice import seed_practices_for_stage
|
||||
from services.projects import create_project, list_projects_for_initiative
|
||||
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
|
||||
|
||||
existing = list_roadmap_items_for_initiative(
|
||||
|
|
@ -368,30 +368,6 @@ def apply_a1_progression_demo(
|
|||
if any(item.get("item_type") == "join_gate" for item in existing):
|
||||
return {"applied": False, "reason": "progression_exists"}
|
||||
|
||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
dehnung = _find_by_title(projects, "Dehnung")
|
||||
if not dehnung:
|
||||
dehnung = create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Dehnung",
|
||||
description="Strang Dehnung",
|
||||
container_kind="stream",
|
||||
sort_order=0,
|
||||
user_id=user_id,
|
||||
)
|
||||
huft = _find_by_title(projects, "Hüftrotation")
|
||||
if not huft:
|
||||
huft = create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Hüftrotation",
|
||||
description="Strang Hüftrotation",
|
||||
container_kind="stream",
|
||||
sort_order=1,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
d1 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
|
|
@ -399,7 +375,6 @@ def apply_a1_progression_demo(
|
|||
item_type="maturity_stage",
|
||||
status="active",
|
||||
sort_order=10,
|
||||
strand_project_id=dehnung["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
h1 = create_roadmap_item(
|
||||
|
|
@ -409,7 +384,6 @@ def apply_a1_progression_demo(
|
|||
item_type="maturity_stage",
|
||||
status="active",
|
||||
sort_order=20,
|
||||
strand_project_id=huft["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
join = create_roadmap_item(
|
||||
|
|
@ -428,7 +402,6 @@ def apply_a1_progression_demo(
|
|||
item_type="maturity_stage",
|
||||
status="planned",
|
||||
sort_order=40,
|
||||
strand_project_id=dehnung["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
h2 = create_roadmap_item(
|
||||
|
|
@ -438,7 +411,6 @@ def apply_a1_progression_demo(
|
|||
item_type="maturity_stage",
|
||||
status="planned",
|
||||
sort_order=50,
|
||||
strand_project_id=huft["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
|
@ -470,12 +442,14 @@ def apply_a1_progression_demo(
|
|||
_retire_legacy_linear_stages_for_progression(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_gate_ids=frozenset(
|
||||
str(gate["id"]) for gate in (d1, h1, d2, h2)
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"projects": ["Dehnung", "Hüftrotation"],
|
||||
"join": join["title"],
|
||||
"active_gates": [d1["title"], h1["title"]],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,14 +102,27 @@ def pause_all_practices(
|
|||
return paused
|
||||
|
||||
|
||||
def pause_practices_except_stage(
|
||||
def pause_practices_on_same_lane_except_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_stage_id: str,
|
||||
strand_project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
from services.progression_graph import progression_lanes_for_initiative
|
||||
|
||||
lanes = progression_lanes_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
keep_lane = lanes.get(str(keep_stage_id))
|
||||
if not keep_lane:
|
||||
return pause_all_practices(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
||||
)
|
||||
|
||||
lane_gate_ids = {
|
||||
gate_id for gate_id, lane in lanes.items() if lane == keep_lane
|
||||
}
|
||||
paused: list[str] = []
|
||||
for item in list_recurring_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
|
|
@ -118,7 +131,7 @@ def pause_practices_except_stage(
|
|||
continue
|
||||
if item.get("roadmap_item_id") == keep_stage_id:
|
||||
continue
|
||||
if strand_project_id is not None and item.get("project_id") != strand_project_id:
|
||||
if str(item.get("roadmap_item_id") or "") not in lane_gate_ids:
|
||||
continue
|
||||
update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -130,6 +143,23 @@ def pause_practices_except_stage(
|
|||
return paused
|
||||
|
||||
|
||||
def pause_practices_except_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_stage_id: str,
|
||||
strand_project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
del strand_project_id
|
||||
return pause_practices_on_same_lane_except_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_stage_id=keep_stage_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def seed_practices_for_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
|
@ -140,7 +170,6 @@ def seed_practices_for_stage(
|
|||
"""Idempotent: create missing exercises for a stage; activate existing."""
|
||||
stage_id = stage["id"]
|
||||
stage_title = stage.get("title") or ""
|
||||
strand_project_id = stage.get("strand_project_id")
|
||||
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
||||
if not defs:
|
||||
defs = [
|
||||
|
|
@ -185,7 +214,6 @@ def seed_practices_for_stage(
|
|||
interval_days=1,
|
||||
next_due_at=now,
|
||||
roadmap_item_id=stage_id,
|
||||
project_id=strand_project_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
created.append(row)
|
||||
|
|
@ -200,19 +228,12 @@ def activate_stage_practice_set(
|
|||
stage: dict[str, Any],
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
strand_project_id = stage.get("strand_project_id")
|
||||
if strand_project_id:
|
||||
paused = pause_practices_except_stage(
|
||||
paused = pause_practices_on_same_lane_except_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_stage_id=stage["id"],
|
||||
strand_project_id=strand_project_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
paused = pause_all_practices(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
||||
)
|
||||
exercises = seed_practices_for_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,101 @@ def successor_work_gate_ids(
|
|||
]
|
||||
|
||||
|
||||
def _work_gate_predecessor_ids(
|
||||
gate_id: str,
|
||||
*,
|
||||
work_gate_ids: frozenset[str],
|
||||
join_gate_ids: frozenset[str],
|
||||
dependencies: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""Direct requires predecessors that are work gates or join gates."""
|
||||
preds: list[str] = []
|
||||
for dep in _requires_deps(dependencies):
|
||||
if str(dep["from_item_id"]) != str(gate_id):
|
||||
continue
|
||||
pred_id = str(dep["to_item_id"])
|
||||
if pred_id in work_gate_ids or pred_id in join_gate_ids:
|
||||
preds.append(pred_id)
|
||||
return preds
|
||||
|
||||
|
||||
def compute_progression_lanes(
|
||||
*,
|
||||
items: list[dict[str, Any]],
|
||||
dependencies: list[dict[str, Any]],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Graph-derived progression lane per work gate (not persisted).
|
||||
|
||||
Parallel active gates live on different lanes. Sequential gates on the same
|
||||
requires-chain share a lane. First gate after a join starts a new lane.
|
||||
"""
|
||||
item_by_id = {str(item["id"]): item for item in items}
|
||||
work_gate_ids = frozenset(
|
||||
str(item["id"]) for item in items if is_work_gate(item)
|
||||
)
|
||||
join_gate_ids = frozenset(
|
||||
str(item["id"]) for item in items if is_join_gate(item)
|
||||
)
|
||||
work_pred: dict[str, Optional[str]] = {}
|
||||
for gate_id in work_gate_ids:
|
||||
work_preds = [
|
||||
pred_id
|
||||
for pred_id in _work_gate_predecessor_ids(
|
||||
gate_id,
|
||||
work_gate_ids=work_gate_ids,
|
||||
join_gate_ids=join_gate_ids,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
if pred_id in work_gate_ids
|
||||
]
|
||||
work_pred[gate_id] = work_preds[0] if len(work_preds) == 1 else None
|
||||
|
||||
lanes: dict[str, str] = {}
|
||||
|
||||
def resolve_lane(gate_id: str) -> str:
|
||||
cached = lanes.get(gate_id)
|
||||
if cached:
|
||||
return cached
|
||||
pred = work_pred.get(gate_id)
|
||||
if pred:
|
||||
lanes[gate_id] = resolve_lane(pred)
|
||||
return lanes[gate_id]
|
||||
join_preds = [
|
||||
pred_id
|
||||
for pred_id in _work_gate_predecessor_ids(
|
||||
gate_id,
|
||||
work_gate_ids=work_gate_ids,
|
||||
join_gate_ids=join_gate_ids,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
if pred_id in join_gate_ids
|
||||
]
|
||||
if join_preds:
|
||||
lanes[gate_id] = f"post-join:{gate_id}"
|
||||
return lanes[gate_id]
|
||||
lanes[gate_id] = f"source:{gate_id}"
|
||||
return lanes[gate_id]
|
||||
|
||||
for gate_id in work_gate_ids:
|
||||
resolve_lane(gate_id)
|
||||
return lanes
|
||||
|
||||
|
||||
def progression_lanes_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> dict[str, str]:
|
||||
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
||||
|
||||
items = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
dependencies = list_dependencies_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return compute_progression_lanes(items=items, dependencies=dependencies)
|
||||
|
||||
|
||||
def list_active_work_gates(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
|
|
@ -71,12 +166,10 @@ def list_active_work_gates(
|
|||
items = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
has_join_graph = any(is_join_gate(item) for item in items)
|
||||
active = [
|
||||
item
|
||||
for item in items
|
||||
if is_work_gate(item) and item.get("status") in ACTIVE_WORK_STATUSES
|
||||
and (not has_join_graph or item.get("strand_project_id") is not None)
|
||||
]
|
||||
active.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", "")))
|
||||
return active
|
||||
|
|
@ -87,7 +180,6 @@ def compute_join_states(
|
|||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
from services.projects import list_projects_for_initiative
|
||||
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
||||
|
||||
items = list_roadmap_items_for_initiative(
|
||||
|
|
@ -97,10 +189,6 @@ def compute_join_states(
|
|||
dependencies = list_dependencies_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
projects = list_projects_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
project_title = {str(p["id"]): p.get("title") or "" for p in projects}
|
||||
|
||||
joins = [item for item in items if is_join_gate(item)]
|
||||
states: list[dict[str, Any]] = []
|
||||
|
|
@ -115,13 +203,10 @@ def compute_join_states(
|
|||
if not gate:
|
||||
continue
|
||||
if gate.get("status") != "reached":
|
||||
strand_id = gate.get("strand_project_id")
|
||||
pending.append(
|
||||
{
|
||||
"gate_id": input_id,
|
||||
"title": gate.get("title") or input_id,
|
||||
"strand_project_id": strand_id,
|
||||
"strand_title": project_title.get(str(strand_id or ""), ""),
|
||||
"status": gate.get("status"),
|
||||
}
|
||||
)
|
||||
|
|
@ -137,9 +222,6 @@ def compute_join_states(
|
|||
{
|
||||
"gate_id": output_id,
|
||||
"title": (item_by_id.get(output_id) or {}).get("title") or output_id,
|
||||
"strand_project_id": (item_by_id.get(output_id) or {}).get(
|
||||
"strand_project_id"
|
||||
),
|
||||
}
|
||||
for output_id in output_ids
|
||||
if output_id in item_by_id
|
||||
|
|
@ -160,7 +242,6 @@ def compute_progression_state(
|
|||
{
|
||||
"id": gate["id"],
|
||||
"title": gate.get("title"),
|
||||
"strand_project_id": gate.get("strand_project_id"),
|
||||
"status": gate.get("status"),
|
||||
}
|
||||
for gate in active_gates
|
||||
|
|
@ -171,30 +252,33 @@ def compute_progression_state(
|
|||
}
|
||||
|
||||
|
||||
def enforce_single_active_work_gate_per_strand(
|
||||
def enforce_single_active_work_gate_per_lane(
|
||||
cur,
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_active_id: str,
|
||||
strand_project_id: Optional[str],
|
||||
) -> None:
|
||||
if strand_project_id:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE roadmap_items ri
|
||||
SET status = 'planned', updated_at = NOW()
|
||||
FROM roadmaps r
|
||||
WHERE ri.roadmap_id = r.id
|
||||
AND ri.tenant_id = %s
|
||||
AND r.initiative_id = %s
|
||||
AND ri.item_type = 'maturity_stage'
|
||||
AND ri.status IN ('active', 'at_risk')
|
||||
AND ri.strand_project_id = %s
|
||||
AND ri.id != %s
|
||||
""",
|
||||
(tenant_id, initiative_id, strand_project_id, keep_active_id),
|
||||
"""Revoke other active work gates on the same graph-derived progression lane."""
|
||||
from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative
|
||||
|
||||
items = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
dependencies = list_dependencies_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
lanes = compute_progression_lanes(items=items, dependencies=dependencies)
|
||||
keep_lane = lanes.get(str(keep_active_id))
|
||||
if not keep_lane:
|
||||
return
|
||||
|
||||
revoke_ids = [
|
||||
gate_id
|
||||
for gate_id, lane in lanes.items()
|
||||
if lane == keep_lane and gate_id != str(keep_active_id)
|
||||
]
|
||||
if not revoke_ids:
|
||||
return
|
||||
|
||||
cur.execute(
|
||||
|
|
@ -207,10 +291,27 @@ def enforce_single_active_work_gate_per_strand(
|
|||
AND r.initiative_id = %s
|
||||
AND ri.item_type = 'maturity_stage'
|
||||
AND ri.status IN ('active', 'at_risk')
|
||||
AND ri.strand_project_id IS NULL
|
||||
AND ri.id != %s
|
||||
AND ri.id = ANY(%s::uuid[])
|
||||
""",
|
||||
(tenant_id, initiative_id, keep_active_id),
|
||||
(tenant_id, initiative_id, revoke_ids),
|
||||
)
|
||||
|
||||
|
||||
def enforce_single_active_work_gate_per_strand(
|
||||
cur,
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_active_id: str,
|
||||
strand_project_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Deprecated — graph lanes replace strand/project grouping (ADP A1 lanes v0.1)."""
|
||||
del strand_project_id
|
||||
enforce_single_active_work_gate_per_lane(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_active_id=keep_active_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -400,17 +400,14 @@ def update_roadmap_item(
|
|||
and existing.get("item_type") == "maturity_stage"
|
||||
):
|
||||
from services.progression_graph import (
|
||||
enforce_single_active_work_gate_per_strand,
|
||||
enforce_single_active_work_gate_per_lane,
|
||||
)
|
||||
|
||||
enforce_single_active_work_gate_per_strand(
|
||||
enforce_single_active_work_gate_per_lane(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(existing["initiative_id"]),
|
||||
keep_active_id=item_id,
|
||||
strand_project_id=strand_project_id
|
||||
if strand_project_id is not None
|
||||
else existing.get("strand_project_id"),
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
|
|
@ -803,15 +800,14 @@ def reopen_roadmap_item(
|
|||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if item.get("item_type") == "maturity_stage":
|
||||
from services.progression_graph import (
|
||||
enforce_single_active_work_gate_per_strand,
|
||||
enforce_single_active_work_gate_per_lane,
|
||||
)
|
||||
|
||||
enforce_single_active_work_gate_per_strand(
|
||||
enforce_single_active_work_gate_per_lane(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
keep_active_id=item_id,
|
||||
strand_project_id=item.get("strand_project_id"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -74,14 +74,13 @@ def _reopen_item_if_reached(
|
|||
if item["status"] != "reached":
|
||||
return
|
||||
if item.get("item_type") == "maturity_stage":
|
||||
from services.progression_graph import enforce_single_active_work_gate_per_strand
|
||||
from services.progression_graph import enforce_single_active_work_gate_per_lane
|
||||
|
||||
enforce_single_active_work_gate_per_strand(
|
||||
enforce_single_active_work_gate_per_lane(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
keep_active_id=item["id"],
|
||||
strand_project_id=item.get("strand_project_id"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,10 +2,63 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.progression_graph import compute_progression_lanes
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||
|
||||
|
||||
def _item(item_id: str, item_type: str = "maturity_stage", **kwargs):
|
||||
return {
|
||||
"id": item_id,
|
||||
"item_type": item_type,
|
||||
"status": kwargs.get("status", "planned"),
|
||||
"title": kwargs.get("title", item_id),
|
||||
"sort_order": kwargs.get("sort_order", 0),
|
||||
}
|
||||
|
||||
|
||||
def _requires(from_id: str, to_id: str) -> dict:
|
||||
return {
|
||||
"from_item_id": from_id,
|
||||
"to_item_id": to_id,
|
||||
"dependency_type": "requires",
|
||||
}
|
||||
|
||||
|
||||
def test_progression_lanes_parallel_sources_and_post_join():
|
||||
items = [
|
||||
_item("d1", status="active", title="Dehnung — Einstieg"),
|
||||
_item("h1", status="active", title="Hüftrotation — Einstieg"),
|
||||
_item("join", item_type="join_gate", title="Join"),
|
||||
_item("d2", title="Dehnung — nach Join"),
|
||||
_item("h2", title="Hüftrotation — nach Join"),
|
||||
]
|
||||
deps = [
|
||||
_requires("join", "d1"),
|
||||
_requires("join", "h1"),
|
||||
_requires("d2", "join"),
|
||||
_requires("h2", "join"),
|
||||
]
|
||||
lanes = compute_progression_lanes(items=items, dependencies=deps)
|
||||
assert lanes["d1"].startswith("source:")
|
||||
assert lanes["h1"].startswith("source:")
|
||||
assert lanes["h1"] != lanes["d1"]
|
||||
assert lanes["d2"] == "post-join:d2"
|
||||
assert lanes["h2"] == "post-join:h2"
|
||||
assert lanes["d2"] != lanes["h2"]
|
||||
|
||||
|
||||
def test_progression_lanes_sequential_chain_shares_lane():
|
||||
items = [
|
||||
_item("g1", status="active"),
|
||||
_item("g2"),
|
||||
_item("g3"),
|
||||
]
|
||||
deps = [_requires("g2", "g1"), _requires("g3", "g2")]
|
||||
lanes = compute_progression_lanes(items=items, dependencies=deps)
|
||||
assert lanes["g1"] == lanes["g2"] == lanes["g3"]
|
||||
|
||||
|
||||
def test_progression_join_switches_and_activates_outputs(client):
|
||||
user = provision_user_in_tenant(tenant_role="admin")
|
||||
token = _login(client, user)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
# ADP — A1 Progression: Graph-abgeleitete Lanes statt Strang=Project
|
||||
|
||||
**Status:** PO/Architektur-Korrektur (2026-07-28)
|
||||
**Stand:** 2026-07-28
|
||||
**Auslöser:** AP2.1 — Strang=Project und `strand_project_id` vermischen Dimension A (Zielzustands-Graph) mit Dimension B (Project-Struktur)
|
||||
**Bezug:** `ADP_Execution_Plan_and_Work_Package_Dependencies_v0.1.md`, `SPEC_A1_Progression_Model_PO_Lock_v0.1.md`, `SPEC_D_maturity_progression_v0.1.md`
|
||||
|
||||
---
|
||||
|
||||
## Entscheidung
|
||||
|
||||
**A1 Multi-Gate-Progression braucht kein Project pro parallelem Pfad.**
|
||||
|
||||
| Thema | Lock |
|
||||
|-------|------|
|
||||
| **Ziel-Horizont (A)** | Gates + `requires`-Graph + Join — modelliert **Zielzustände**, nicht Pfade |
|
||||
| **Ist / Übungen** | `RecurringElement.roadmap_item_id` → aktives Gate — **eine** Bindung |
|
||||
| **Parallele aktive Gates** | Erlaubt; Horizon-Regel „max. 1 aktiv pro Lane“ aus **Graph-Topologie** ableiten |
|
||||
| **Lane** | Interner, nicht persistierter Schlüssel (`compute_progression_lanes`) — kein Nutzerobjekt |
|
||||
| **Project** | **Nicht** Träger paralleler Progression in A1; optional nur für echte Struktur-Hierarchie (ADP Dimension B) |
|
||||
| **`strand_project_id`** | **Deprecated** für Steuerlogik; Spalte bleibt nullable (Migration 032), wird nicht mehr gelesen |
|
||||
| **`recurring.project_id` für A1** | **Nicht** setzen / nicht auswerten für Ready-Menge |
|
||||
|
||||
---
|
||||
|
||||
## Begründung
|
||||
|
||||
1. **ADP-Leitentscheidung:** Gate-Designer = Zielzustände; Pfad-/Container-Semantik gehört nicht in dieselbe Pflichtmodellierung.
|
||||
2. **Nutzerbild:** Mehrere aktive Gates, Routinen am Gate, Join wertet Kanten aus — kein Pfad-Navigator nötig.
|
||||
3. **Archetyp-Isolation:** Project=Strang hätte andere Archetypen (A2, B1) semantisch verunreinigt.
|
||||
4. **Kein Doppelmodell:** Kanten im Designer genügen; kein Strang-Dropdown, keine Stream-Projects für A1-Kernbild.
|
||||
|
||||
---
|
||||
|
||||
## Lane-Algorithmus (MVP)
|
||||
|
||||
Aus `requires`-Kanten zwischen Work-Gates und Joins:
|
||||
|
||||
- Sequenzielle Work-Gates teilen eine Lane (Vorgänger-Kette).
|
||||
- Parallele Quellen (kein Work-Gate-Vorgänger) → getrennte Lanes.
|
||||
- Erstes Gate nach Join (`requires` Join) → neue Lane (`post-join:{gate_id}`).
|
||||
|
||||
Horizon: Beim Aktivieren eines Gates werden andere `active`/`at_risk` Gates **derselben Lane** auf `planned` gesetzt.
|
||||
|
||||
---
|
||||
|
||||
## Implementierung
|
||||
|
||||
| Modul | Änderung |
|
||||
|-------|----------|
|
||||
| `backend/services/progression_graph.py` | `compute_progression_lanes`, `enforce_single_active_work_gate_per_lane` |
|
||||
| `backend/services/maturity_practice.py` | Pause/Seed nur über `roadmap_item_id` + Lane |
|
||||
| `backend/services/archetype_starter_kit.py` | Demo ohne Stream-Projects / `strand_project_id` |
|
||||
| `frontend/.../WorkTodayPracticePanel.jsx` | Gruppierung nach Gate-Titel |
|
||||
|
||||
Steuerung bleibt in **`maturity_progression`** — nicht im generischen `roadmap_engine`.
|
||||
|
||||
---
|
||||
|
||||
## Spec-Nachzug
|
||||
|
||||
`SPEC_A1_Progression_Model_PO_Lock_v0.1.md` §1/§4: „Strang = Project“ → **ersetzt durch** „parallele Lanes = graph-abgeleitete Requires-Ketten“. Project-Spiegel `project.maturity_journey` optional für Struktur-IA, nicht für Horizon.
|
||||
|
||||
---
|
||||
|
||||
## Nicht in Scope
|
||||
|
||||
- Spalten-Drop in Migration ( später, wenn keine Legacy-Daten )
|
||||
- Join If/Else (Stufe B)
|
||||
- Blueprint / Starter-Kit als Produktweg
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# AP2.1 — MVP Validation Report
|
||||
## v0.3 (Stufe A)
|
||||
|
||||
**Status:** Entwurf — vom Product Owner auszufüllen
|
||||
**Stand:** 2026-07-28
|
||||
**Status:** Entwurf — Re-Abnahme A1 Progression ausstehend (2026-08-02)
|
||||
**Stand:** 2026-08-02 (§3.5 Progressions-Re-Abnahme ergänzt)
|
||||
**Bezug:** `Kairo_MVP_Definition_v0.3.md` §5–8
|
||||
**Assignment:** `Sprint1_AP2_1_MVP_Validation_Assignment_v0.1.md`
|
||||
**Findings (Architektur-Review):** `Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`
|
||||
|
|
@ -106,6 +106,79 @@ Produkt (PO): **A1 No-Go** — Cadence-Plumbing reicht nicht; Nutzerbild = **par
|
|||
|
||||
**Lücken / UX-Schmerz:** PO No-Go — Register F-A1-02…10; Minimum Slice §6.1 vor Re-Test. Kein Architektur-Neudesign — fehlender A1-Kern (CadenceInstance, Today=Übung, Gate-Fortschritt, IA).
|
||||
|
||||
### 3.5 Re-Abnahme A1 — Progressionsmodell (PO-Lock, ohne Starter-Kit)
|
||||
|
||||
**Zweck:** PO-Kernbild prüfen gemäß [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §7 — **≥2 parallele Gates + 1 Join + Activity Sets + Today**, manuell modelliert (kein Spagat-Starter-Kit). Horizon/Lanes: [`ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md`](../architecture/ADP_A1_Progression_Graph_Derived_Lanes_v0.1.md).
|
||||
|
||||
**Vorbedingungen**
|
||||
|
||||
| # | Check | erledigt |
|
||||
|---|--------|----------|
|
||||
| V1 | Deploy `develop` grün (pytest inkl. `test_a1_progression_join.py`) | ☐ |
|
||||
| V2 | Health: `GET https://dev.kairo.jinkendo.de/api/health` | ☐ |
|
||||
| V3 | PO-Entscheid: **kein Starter-Kit** — Anlage mit `"apply_starter_kit": false` (API) oder Spagat-Stufen nach Anlage löschen | ☐ |
|
||||
|
||||
**Referenz-Graph (MVP, 2 parallele Pfade — reicht für Go):**
|
||||
|
||||
```text
|
||||
[Dehnung — Einstieg] ──requires──▶ [Join: Mawashi …] ◀──requires── [Hüftrotation — Einstieg]
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
▼ ▼
|
||||
[Dehnung — nach Join] [Hüftrotation — nach Join]
|
||||
```
|
||||
|
||||
Alle Kanten: **Voraussetzung** (`requires`). Kein Parallelgruppe/Blockiert nötig. **Keine** Stream-Projects / `strand_project_id` nötig — parallele Pfade ergeben sich aus dem Graph.
|
||||
|
||||
---
|
||||
|
||||
**9 Smoke-Schritte**
|
||||
|
||||
| # | Schritt | Route / Aktion | Erwartung | ✓/✗ | Notiz |
|
||||
|---|---------|----------------|-----------|-----|-------|
|
||||
| **1** | Vorhaben anlegen | API `POST /api/initiatives` mit `archetype_key: initiative.maturity_journey`, **`apply_starter_kit: false`** | Archetyp A1, Methode `maturity_progression`, **keine** Spagat-Stufen | | |
|
||||
| **2** | Operating Context | `GET …/initiatives/{id}/operating-context` oder Kontrolle | `maturity_stage`, `recurring_rhythm`; Default-Route Kontrolle | | |
|
||||
| **3** | Gates + Join im Designer | Plan → Zielzustände → **Designer** | 5 Knoten: 4× `Reifegrad-Stufe` + 1× **Join/Schaltknoten**; D1+H1 **active**, Rest **planned** | | |
|
||||
| **4** | Kanten ziehen | Designer, Kantentyp **Voraussetzung** | Join→D1, Join→H1, D2→Join, H2→Join (4 Kanten) | | |
|
||||
| **5** | Activity Sets | Gate-Detail je Work-Gate: Recurring/Übung anlegen oder Kriterium + Übung | D1 und H1 je ≥1 Übung; Join **ohne** Checkliste/Verify-Button | | |
|
||||
| **6** | Today (Start) | `/work/today` (Initiative-Scope) | Übungen von **beiden** aktiven Gates; **keine** flache AP-Wand; Join **nicht** in Today | | |
|
||||
| **7** | Verify Pfad 1 | Gate D1: Evidence + **Verify** | D1 `reached`; Join wartet auf H1; **kein** auto-next auf D2; Today nur noch H1-Übungen | | |
|
||||
| **8** | Join schaltet | Gate H1: Evidence + **Verify** | Join `reached` (automatisch); D2 + H2 **active**; neue Übungen in Today | | |
|
||||
| **9** | Leitfrage + Kontrolle | Kontrolle `/control/status` + Stopuhr | Join-Warteliste zeigte offene Eingänge (Schritt 7); Leitfrage in **≤2 Min** beantwortbar: *Welche Übung heute, welcher Join wartet?* | | |
|
||||
|
||||
**Technische Referenz (Automatisierung):** `backend/tests/test_a1_progression_join.py`
|
||||
|
||||
---
|
||||
|
||||
**Go-Kriterien A1 Progression (alle nötig für Go)**
|
||||
|
||||
| # | Kriterium | ja/nein | Notiz |
|
||||
|---|-----------|---------|-------|
|
||||
| G1 | Multi-Gate: 2 aktive Work-Gates parallel (Schritt 6) | | |
|
||||
| G2 | Join: Warteliste/Kontrolle zeigt pending inputs (Schritt 7) | | |
|
||||
| G3 | Join schaltet Ausgänge gemeinsam (Schritt 8) | | |
|
||||
| G4 | Today = Übungen am Gate, nicht AP-Liste (Schritt 6, 9) | | |
|
||||
| G5 | Leitfrage ≤2 Min (Schritt 9) | | |
|
||||
| G6 | Kein Anti-Pattern: flache Rhythmus-Liste als Hauptbild | | |
|
||||
|
||||
**Zwischenurteil Re-Abnahme:** ☐ Go · ☐ Bedingt · ☐ No-Go
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Initiative-ID / Titel** | |
|
||||
| **Deploy / Commit** | `develop` @ |
|
||||
| **Testdatum Re-Abnahme** | |
|
||||
|
||||
**Leitfrage-Antwort (PO, ≤2 Min):**
|
||||
|
||||
**Nächster Schritt:**
|
||||
**Warum:**
|
||||
**Stopuhr (Min):**
|
||||
|
||||
**Bei Go:** §3 Zwischenurteil auf Go setzen; Findings Register F-A1-02…10 einzeln abhaken; Truth Table MVP-Abnahfe A1 aktualisieren.
|
||||
|
||||
**Bei No-Go:** neue Finding-IDs im Register; keine Stufe-A-Sign-off.
|
||||
|
||||
---
|
||||
|
||||
## 4. Szenario A2 — Neue Küche (Linear)
|
||||
|
|
|
|||
|
|
@ -216,12 +216,12 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4
|
|||
|
||||
Kernentscheide:
|
||||
|
||||
- **Multi-Strang** (Projects) + **Gate-Graph** + **Join-Gates**
|
||||
- **Parallele Gates** + **Gate-Graph** + **Join-Gates** (Lanes graph-abgeleitet — ADP A1 Lanes v0.1)
|
||||
- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit)
|
||||
- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld
|
||||
- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
|
||||
- **Übung** = `RecurringElement` am Gate (`roadmap_item_id`); Rhythmus = Feld
|
||||
- **A1 Go MVP:** ≥2 parallele Gates + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
|
||||
|
||||
**Validation A1:** pausiert bis `AP-A1-PM-4` (Plan Struktur + Gate-Detail). A2/B2b-Smoke **parallel** möglich.
|
||||
**Validation A1:** Re-Abnahme gemäß [`Sprint1_AP2_1_MVP_Validation_Report_v0.3.md`](Sprint1_AP2_1_MVP_Validation_Report_v0.3.md) **§3.5** (Progressionsmodell, ohne Starter-Kit). A2/B2b-Smoke **parallel** möglich.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function JoinWaitlistPanel({ progressionState = null }) {
|
|||
<div>
|
||||
<h2>Join-Fortschritt</h2>
|
||||
<p className="section-lead muted">
|
||||
Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten?
|
||||
Konsolidierte Meilensteine — welche Eingangs-Gates fehlen noch zum Schalten?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,12 +25,6 @@ function exercisesForGate(recurringItems = [], gateId) {
|
|||
)
|
||||
}
|
||||
|
||||
function strandLabel(gate, projectsById) {
|
||||
const projectId = gate.strand_project_id
|
||||
if (!projectId) return 'Training'
|
||||
return projectsById.get(projectId)?.title || 'Strang'
|
||||
}
|
||||
|
||||
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
||||
const [measurement, setMeasurement] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
|
@ -99,31 +93,24 @@ function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
|||
}
|
||||
|
||||
/**
|
||||
* A1 — Heutige Übungen aller aktiven Work-Gates, gruppiert nach Strang.
|
||||
* A1 — Heutige Übungen aller aktiven Work-Gates, gruppiert nach Gate.
|
||||
*/
|
||||
export function WorkTodayPracticePanel({
|
||||
initiativeId,
|
||||
roadmapItems = [],
|
||||
recurringItems = [],
|
||||
projects = [],
|
||||
canManage = false,
|
||||
onCompleted,
|
||||
busy = false,
|
||||
}) {
|
||||
const projectsById = useMemo(
|
||||
() => new Map((projects || []).map((project) => [project.id, project])),
|
||||
[projects],
|
||||
)
|
||||
|
||||
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return gates.map((gate) => ({
|
||||
gate,
|
||||
strand: strandLabel(gate, projectsById),
|
||||
exercises: exercisesForGate(recurringItems, gate.id),
|
||||
}))
|
||||
}, [gates, recurringItems, projectsById])
|
||||
}, [gates, recurringItems])
|
||||
|
||||
const todayExercises = grouped.flatMap((group) => group.exercises)
|
||||
const pending = todayExercises.filter((item) => !item.today_completed)
|
||||
|
|
@ -137,7 +124,7 @@ export function WorkTodayPracticePanel({
|
|||
<p className="section-lead muted">
|
||||
{gates.length > 0
|
||||
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
|
||||
: 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'}
|
||||
: 'Tages-Tracking für aktive Work-Gates.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,12 +138,10 @@ export function WorkTodayPracticePanel({
|
|||
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
||||
</p>
|
||||
)}
|
||||
{grouped.map(({ gate, strand, exercises }) =>
|
||||
{grouped.map(({ gate, exercises }) =>
|
||||
exercises.length === 0 ? null : (
|
||||
<div key={gate.id} className="work-today-practice__strand">
|
||||
<h3 className="work-today-practice__strand-title">
|
||||
{strand} — {gate.title}
|
||||
</h3>
|
||||
<h3 className="work-today-practice__strand-title">{gate.title}</h3>
|
||||
<ul className="item-list work-today-practice__list">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseRow
|
||||
|
|
|
|||
|
|
@ -212,7 +212,6 @@ export function buildProviderProps(provider, input) {
|
|||
initiativeId: ops.initiativeId,
|
||||
roadmapItems: ops.roadmapItems || [],
|
||||
recurringItems: ops.recurringItems || [],
|
||||
projects: ops.projects || [],
|
||||
canManage: capabilities.has('kairo.recurring.manage'),
|
||||
busy: ops.formBusy,
|
||||
onCompleted: async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user