AP1.2 + AP1.4: Signals konsolidieren, RoadmapItem mit Gate-Verify
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

Entfernt operating_phase aus dem Snapshot zugunsten von Lifecycle + signals.
Führt methodenneutrale RoadmapItems (Migration 010), Verify-Pfad und Plan-UI ein.
Milestone-API bleibt als Compat-Wrapper; Version 0.13.0-ap1.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 18:45:36 +02:00
parent a5d9eeb338
commit 09433b5d28
25 changed files with 1582 additions and 343 deletions

View File

@ -5,28 +5,17 @@ Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steer
from __future__ import annotations
from typing import Any, Literal, Optional
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from data_layer.attention import get_next_action_candidates_for_initiative
from steering.context import get_steering_context_dto
from steering.signals.snapshot_signals import derive_initiative_signals
from services.initiatives import get_initiative
from tenant_context import TenantContext
OperatingPhase = Literal[
"capture",
"triage",
"structure",
"commit",
"execute",
"verify",
"review",
"adapt",
"closure",
]
OPEN_BLOCKER = ("open", "in_progress")
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE = ("active", "paused")
@ -40,51 +29,6 @@ def _iso(value: Any) -> Optional[str]:
return value.isoformat() if value else None
def _derive_operating_phase(
*,
initiative_status: str,
open_actions: int,
blocked_actions: int,
review_required_actions: int,
open_blockers: int,
backlog_new: int,
planned_reviews_due: int,
) -> tuple[OperatingPhase, list[str]]:
"""Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
signals: list[str] = []
if initiative_status in ("completed", "archived"):
return "closure", ["initiative_terminal"]
if open_actions == 0 and backlog_new > 0:
signals.append("backlog_awaiting_commit")
return "triage", signals
if open_actions == 0 and backlog_new == 0:
signals.append("no_open_work")
return "structure", signals
if blocked_actions > 0 or open_blockers > 0:
if blocked_actions:
signals.append("actions_blocked")
if open_blockers:
signals.append("open_blockers")
return "execute", signals
if review_required_actions > 0 or planned_reviews_due > 0:
if review_required_actions:
signals.append("actions_need_review")
if planned_reviews_due:
signals.append("reviews_due")
return "review", signals
if open_actions > 0:
signals.append("work_in_progress")
return "execute", signals
return "adapt", signals
def get_initiative_steering_snapshot(
ctx: TenantContext, *, initiative_id: str
) -> Optional[dict[str, Any]]:
@ -155,14 +99,16 @@ def get_initiative_steering_snapshot(
cur.execute(
"""
SELECT id, title, status, target_date, created_at, updated_at
FROM milestones
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
SELECT id, title, status, target_date, item_type, sequencing_mode,
sort_order, created_at, updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
ORDER BY ri.sort_order ASC, ri.updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
milestones_raw = [dict(r) for r in cur.fetchall()]
roadmap_items_raw = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
@ -270,14 +216,17 @@ def get_initiative_steering_snapshot(
}
)
milestones = []
for m in milestones_raw:
roadmap_items: list[dict[str, Any]] = []
for m in roadmap_items_raw:
mid = _sid(m["id"])
milestones.append(
roadmap_items.append(
{
"id": mid,
"title": m["title"],
"status": m["status"],
"item_type": m["item_type"],
"sequencing_mode": m["sequencing_mode"],
"sort_order": m.get("sort_order", 0),
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
"reviews": reviews_by_milestone.get(mid, []),
}
@ -288,7 +237,7 @@ def get_initiative_steering_snapshot(
)
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
phase, phase_signals = _derive_operating_phase(
signals = derive_initiative_signals(
initiative_status=initiative["status"],
open_actions=open_actions,
blocked_actions=blocked_actions,
@ -301,7 +250,7 @@ def get_initiative_steering_snapshot(
upcoming_milestones = sorted(
[
m
for m in milestones
for m in roadmap_items
if m["status"] in ("planned", "active", "at_risk")
],
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
@ -322,10 +271,9 @@ def get_initiative_steering_snapshot(
"method_key": steering["method_key"],
"method_label": steering.get("method_label"),
"method_version": steering["method_version"],
"operating_phase": phase,
"operating_phase_deprecated": True,
"phase_signals": phase_signals,
"signals": signals,
"upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"counts": {
"actions_open": open_actions,
@ -341,7 +289,8 @@ def get_initiative_steering_snapshot(
"unlinked_reviews": len(unlinked_reviews),
},
"actions": actions,
"milestones": milestones,
"roadmap_items": roadmap_items,
"milestones": roadmap_items,
"initiative_level": {
"blockers": unlinked_blockers,
"evidence": unlinked_evidence,

View File

@ -68,6 +68,7 @@ from routers import ( # noqa: E402
initiatives,
me,
milestones,
roadmap,
prompts,
recurring,
reviews,
@ -85,6 +86,8 @@ app.include_router(actions.router)
app.include_router(blockers.router)
app.include_router(backlog.router)
app.include_router(milestones.router)
app.include_router(roadmap.initiative_router)
app.include_router(roadmap.items_router)
app.include_router(evidence.router)
app.include_router(decisions.router)
app.include_router(reviews.router)

View File

@ -0,0 +1,86 @@
-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral)
CREATE TABLE roadmaps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL DEFAULT 'Plan',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id)
);
CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id);
CREATE TABLE roadmap_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
item_type VARCHAR(32) NOT NULL DEFAULT 'milestone'
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')),
title VARCHAR(255) NOT NULL,
goal_description TEXT NOT NULL DEFAULT '',
definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb,
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential'
CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')),
target_date DATE NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id);
CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status);
CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type);
CREATE TABLE roadmap_item_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires'
CHECK (dependency_type IN ('requires', 'blocks', 'related')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type)
);
CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id);
CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id);
ALTER TABLE backlog_items
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
ALTER TABLE actions
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
-- Ein Plan pro Initiative
INSERT INTO roadmaps (tenant_id, initiative_id, title)
SELECT tenant_id, id, 'Plan' FROM initiatives;
-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs)
INSERT INTO roadmap_items (
id, tenant_id, roadmap_id, item_type, title, goal_description, status,
sequencing_mode, target_date, sort_order, created_at, updated_at
)
SELECT
m.id,
m.tenant_id,
r.id,
'milestone',
m.title,
m.goal_description,
m.status,
'sequential',
m.target_date,
0,
m.created_at,
m.updated_at
FROM milestones m
JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id;

198
backend/routers/roadmap.py Normal file
View File

@ -0,0 +1,198 @@
"""Roadmap API — AP1.4."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import roadmap as roadmap_service
from tenant_context import TenantContext
router = APIRouter(tags=["roadmap"])
class RoadmapItemCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
item_type: Literal["milestone", "review_gate", "maturity_stage"] = "milestone"
goal_description: str = ""
definition_of_done: list[Any] = Field(default_factory=list)
status: Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
] = "planned"
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
target_date: Optional[date] = None
sort_order: int = 0
class RoadmapItemUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage"]] = None
goal_description: Optional[str] = None
definition_of_done: Optional[list[Any]] = None
status: Optional[
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
] = None
sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None
target_date: Optional[date] = None
clear_target_date: bool = False
sort_order: Optional[int] = None
class DependencyCreateRequest(BaseModel):
to_item_id: str
dependency_type: Literal["requires", "blocks", "related"] = "requires"
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
@initiative_router.get("/{initiative_id}/roadmap")
def get_initiative_roadmap(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
roadmap = roadmap_service.get_roadmap_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not roadmap:
return {"initiative_id": initiative_id, "items": []}
items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
return {**roadmap, "items": items}
@initiative_router.get("/{initiative_id}/roadmap/items")
def list_initiative_roadmap_items(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_roadmap_items_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
def create_initiative_roadmap_item(
initiative_id: str,
body: RoadmapItemCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.create_roadmap_item(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
item_type=body.item_type,
goal_description=body.goal_description,
definition_of_done=body.definition_of_done,
status=body.status,
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}")
def get_roadmap_item(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id)
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
return item
@items_router.patch("/{item_id}")
def update_roadmap_item(
item_id: str,
body: RoadmapItemUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = roadmap_service.update_roadmap_item(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
title=body.title,
item_type=body.item_type,
goal_description=body.goal_description,
definition_of_done=body.definition_of_done,
status=body.status,
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
return item
@items_router.delete("/{item_id}", status_code=204)
def delete_roadmap_item(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
if not roadmap_service.delete_roadmap_item(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
@items_router.post("/{item_id}/verify-reached")
def verify_roadmap_item_reached(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.verify_reached(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}/dependencies")
def list_roadmap_item_dependencies(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/dependencies", status_code=201)
def add_roadmap_item_dependency(
item_id: str,
body: DependencyCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.add_dependency(
tenant_id=ctx.tenant_id,
from_item_id=item_id,
to_item_id=body.to_item_id,
dependency_type=body.dependency_type,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -85,6 +85,18 @@ def _milestone_in_initiative(
""",
(milestone_id, tenant_id, initiative_id),
)
if cur.fetchone():
return True
cur.execute(
"""
SELECT 1
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
AND ri.item_type = 'milestone'
""",
(milestone_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()

View File

@ -1,46 +1,31 @@
"""Milestone service — tenant-scoped CRUD (AP0.8d)."""
"""Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4)."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
from services import roadmap as roadmap_service
MilestoneStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
MILESTONE_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
result["target_date"] = (
result["target_date"].isoformat()
if hasattr(result["target_date"], "isoformat")
else str(result["target_date"])
)
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in MILESTONE_STATUSES:
raise ValueError(f"Ungültiger Meilenstein-Status: {status}")
def _as_milestone(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": item["id"],
"tenant_id": item["tenant_id"],
"initiative_id": item["initiative_id"],
"title": item["title"],
"goal_description": item.get("goal_description", ""),
"status": item["status"],
"target_date": item.get("target_date"),
"created_at": item["created_at"],
"updated_at": item["updated_at"],
}
def create_milestone(
@ -53,80 +38,31 @@ def create_milestone(
target_date: Optional[date] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO milestones (
tenant_id, initiative_id, title, goal_description, status, target_date
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
""",
(tenant_id, initiative_id, title, goal_description, status, target_date),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"milestone.created",
user_id=user_id,
item = roadmap_service.create_roadmap_item(
tenant_id=tenant_id,
details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title},
initiative_id=initiative_id,
title=title,
item_type="milestone",
goal_description=goal_description,
status=status,
target_date=target_date,
user_id=user_id,
)
return row
return _as_milestone(item)
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
FROM milestones
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY target_date NULLS LAST, updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return [_as_milestone(i) for i in items if i["item_type"] == "milestone"]
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
FROM milestones
WHERE id = %s AND tenant_id = %s
""",
(milestone_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
if not item or item["item_type"] != "milestone":
return None
return _as_milestone(item)
def update_milestone(
@ -140,78 +76,17 @@ def update_milestone(
target_date: Optional[date] = None,
clear_target_date: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal_description is not None:
updates.append("goal_description = %s")
params.append(goal_description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([milestone_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE milestones
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"milestone.updated",
user_id=user_id,
item = roadmap_service.update_roadmap_item(
tenant_id=tenant_id,
details={"milestone_id": milestone_id},
item_id=milestone_id,
user_id=user_id,
title=title,
goal_description=goal_description,
status=status,
target_date=target_date,
clear_target_date=clear_target_date,
)
if status is not None and status != old_status:
log_audit(
"milestone.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"milestone_id": milestone_id,
"from_status": old_status,
"to_status": status,
},
)
return result
return _as_milestone(item) if item else None
def delete_milestone(
@ -220,23 +95,9 @@ def delete_milestone(
milestone_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id",
(milestone_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"milestone.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"milestone_id": milestone_id},
)
return deleted
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
if not item or item["item_type"] != "milestone":
return False
return roadmap_service.delete_roadmap_item(
tenant_id=tenant_id, item_id=milestone_id, user_id=user_id
)

621
backend/services/roadmap.py Normal file
View File

@ -0,0 +1,621 @@
"""Roadmap service — tenant-scoped Plan/Gates (AP1.4)."""
from __future__ import annotations
import json
from datetime import date
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"]
RoadmapItemStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
SequencingMode = Literal["sequential", "parallel", "optional"]
DependencyType = Literal["requires", "blocks", "related"]
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
ROADMAP_ITEM_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"})
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
result["target_date"] = (
result["target_date"].isoformat()
if hasattr(result["target_date"], "isoformat")
else str(result["target_date"])
)
for ts in ("created_at", "updated_at"):
if result.get(ts):
result[ts] = result[ts].isoformat()
dod = result.get("definition_of_done")
if isinstance(dod, str):
result["definition_of_done"] = json.loads(dod)
return result
def _validate_item_type(item_type: str) -> None:
if item_type not in ROADMAP_ITEM_TYPES:
raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}")
def _validate_status(status: str) -> None:
if status not in ROADMAP_ITEM_STATUSES:
raise ValueError(f"Ungültiger RoadmapItem-Status: {status}")
def _validate_sequencing_mode(mode: str) -> None:
if mode not in SEQUENCING_MODES:
raise ValueError(f"Ungültiger sequencing_mode: {mode}")
def _validate_dependency_type(dep_type: str) -> None:
if dep_type not in DEPENDENCY_TYPES:
raise ValueError(f"Ungültiger dependency_type: {dep_type}")
def _sync_milestone_compat_row(
cur,
*,
tenant_id: str,
initiative_id: str,
item: dict[str, Any],
) -> None:
"""Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop)."""
if item["item_type"] != "milestone":
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
(item["id"], tenant_id),
)
return
target_date = item.get("target_date")
if target_date and isinstance(target_date, str):
target_date = date.fromisoformat(target_date)
cur.execute(
"""
INSERT INTO milestones (
id, tenant_id, initiative_id, title, goal_description, status, target_date
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
goal_description = EXCLUDED.goal_description,
status = EXCLUDED.status,
target_date = EXCLUDED.target_date,
updated_at = NOW()
""",
(
item["id"],
tenant_id,
initiative_id,
item["title"],
item.get("goal_description", ""),
item["status"],
target_date,
),
)
def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> dict[str, Any]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, created_at, updated_at
FROM roadmaps
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
if row:
return _serialize_row(dict(row))
cur.execute(
"""
INSERT INTO roadmaps (tenant_id, initiative_id, title)
VALUES (%s, %s, 'Plan')
RETURNING id, tenant_id, initiative_id, title, created_at, updated_at
""",
(tenant_id, initiative_id),
)
result = _serialize_row(dict(cur.fetchone()))
conn.commit()
return result
finally:
conn.close()
def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, created_at, updated_at
FROM roadmaps
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def list_roadmap_items_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not roadmap:
return []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s
""",
(item_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def create_roadmap_item(
*,
tenant_id: str,
initiative_id: str,
title: str,
item_type: RoadmapItemType = "milestone",
goal_description: str = "",
definition_of_done: Optional[list[Any]] = None,
status: RoadmapItemStatus = "planned",
sequencing_mode: SequencingMode = "sequential",
target_date: Optional[date] = None,
sort_order: int = 0,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_item_type(item_type)
_validate_status(status)
if status == "reached":
raise ValueError("Status 'reached' nur über verify-reached")
_validate_sequencing_mode(sequencing_mode)
roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id)
dod = definition_of_done if definition_of_done is not None else []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO roadmap_items (
tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(
tenant_id,
roadmap["id"],
item_type,
title,
goal_description,
json.dumps(dod),
status,
sequencing_mode,
target_date,
sort_order,
),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.created",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": row["id"],
"initiative_id": initiative_id,
"item_type": item_type,
"title": title,
},
)
return row
def update_roadmap_item(
*,
tenant_id: str,
item_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal_description: Optional[str] = None,
definition_of_done: Optional[list[Any]] = None,
item_type: Optional[RoadmapItemType] = None,
status: Optional[RoadmapItemStatus] = None,
sequencing_mode: Optional[SequencingMode] = None,
target_date: Optional[date] = None,
clear_target_date: bool = False,
sort_order: Optional[int] = None,
) -> Optional[dict[str, Any]]:
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal_description is not None:
updates.append("goal_description = %s")
params.append(goal_description)
if definition_of_done is not None:
updates.append("definition_of_done = %s::jsonb")
params.append(json.dumps(definition_of_done))
if item_type is not None:
_validate_item_type(item_type)
updates.append("item_type = %s")
params.append(item_type)
if status is not None:
_validate_status(status)
if status in TERMINAL_STATUSES and status == "reached":
raise ValueError(
"Status 'reached' nur über verify-reached — nicht direkt setzen"
)
if status in ("moved", "discarded"):
raise ValueError(
"Status moved/discarded erfordert Decision — noch nicht implementiert"
)
updates.append("status = %s")
params.append(status)
if sequencing_mode is not None:
_validate_sequencing_mode(sequencing_mode)
updates.append("sequencing_mode = %s")
params.append(sequencing_mode)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([item_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE roadmap_items
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
result["initiative_id"] = existing["initiative_id"]
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
item=result,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id},
)
if status is not None and status != old_status:
log_audit(
"roadmap_item.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"roadmap_item_id": item_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_roadmap_item(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
(item_id, tenant_id),
)
cur.execute(
"DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id",
(item_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"roadmap_item.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id},
)
return deleted
def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
FROM roadmap_item_dependencies
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
ORDER BY created_at ASC
""",
(tenant_id, item_id, item_id),
)
items = []
for row in cur.fetchall():
dep = dict(row)
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
dep[key] = str(dep[key])
if dep.get("created_at"):
dep["created_at"] = dep["created_at"].isoformat()
items.append(dep)
return items
finally:
conn.close()
def add_dependency(
*,
tenant_id: str,
from_item_id: str,
to_item_id: str,
dependency_type: DependencyType = "requires",
user_id: Optional[str] = None,
) -> dict[str, Any]:
_validate_dependency_type(dependency_type)
if from_item_id == to_item_id:
raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id)
to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id)
if not from_item or not to_item:
raise ValueError("RoadmapItem nicht gefunden")
if from_item["initiative_id"] != to_item["initiative_id"]:
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO roadmap_item_dependencies (
tenant_id, from_item_id, to_item_id, dependency_type
)
VALUES (%s, %s, %s, %s)
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
""",
(tenant_id, from_item_id, to_item_id, dependency_type),
)
row = dict(cur.fetchone())
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
row[key] = str(row[key])
row["created_at"] = row["created_at"].isoformat()
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.dependency_added",
user_id=user_id,
tenant_id=tenant_id,
details={
"from_item_id": from_item_id,
"to_item_id": to_item_id,
"dependency_type": dependency_type,
},
)
return row
def verify_reached(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> dict[str, Any]:
"""Gate-Verify: reached nur mit accepted Evidence, completed Review oder gate_override Decision."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise ValueError("RoadmapItem nicht gefunden")
if item["status"] in TERMINAL_STATUSES:
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
initiative_id = item["initiative_id"]
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT 1 FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'accepted'
AND milestone_id = %s
LIMIT 1
""",
(tenant_id, initiative_id, item_id),
)
if cur.fetchone():
verify_reason = "evidence_accepted"
else:
cur.execute(
"""
SELECT 1 FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'completed'
AND (milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id),
)
if cur.fetchone():
verify_reason = "review_completed"
else:
cur.execute(
"""
SELECT 1 FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'decided'
AND title ILIKE %s
LIMIT 1
""",
(tenant_id, initiative_id, f"gate_override:{item_id}%"),
)
if cur.fetchone():
verify_reason = "gate_override_decision"
else:
raise ValueError(
"Verify fehlgeschlagen — accepted Evidence, completed Review "
"oder gate_override Decision erforderlich"
)
cur.execute(
"""
UPDATE roadmap_items
SET status = 'reached', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at
""",
(item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
row["verify_reason"] = verify_reason
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item=row,
)
conn.commit()
finally:
conn.close()
log_audit(
"roadmap_item.reached",
user_id=user_id,
tenant_id=tenant_id,
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
)
return row

View File

@ -256,19 +256,20 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
SELECT
'milestone_at_risk' AS kind,
'warning' AS severity,
m.title AS title,
'Meilenstein als gefährdet markiert' AS summary,
ri.title AS title,
'Plan-Element als gefährdet markiert' AS summary,
'milestone' AS scope_type,
m.id AS scope_id,
m.initiative_id,
ri.id AS scope_id,
r.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
m.id AS milestone_id,
ri.id AS milestone_id,
'milestone_at_risk' AS reason_code,
'milestones' AS data_source
FROM milestones m
WHERE m.tenant_id = %s AND m.status = 'at_risk'
ORDER BY m.updated_at DESC
'roadmap_items' AS data_source
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND ri.status = 'at_risk'
ORDER BY ri.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),

View File

@ -0,0 +1,47 @@
"""Initiative snapshot signals — erklärbare Heuristiken ohne operating_phase (AP1.2)."""
from __future__ import annotations
def derive_initiative_signals(
*,
initiative_status: str,
open_actions: int,
blocked_actions: int,
review_required_actions: int,
open_blockers: int,
backlog_new: int,
planned_reviews_due: int,
) -> list[str]:
"""Regelbasierte Signale für Steuerungs-Snapshot — kein Lifecycle-Ersatz."""
if initiative_status in ("completed", "archived"):
return ["initiative_terminal"]
signals: list[str] = []
if open_actions == 0 and backlog_new > 0:
signals.append("backlog_awaiting_commit")
return signals
if open_actions == 0 and backlog_new == 0:
signals.append("no_open_work")
return signals
if blocked_actions > 0:
signals.append("actions_blocked")
if open_blockers > 0:
signals.append("open_blockers")
if blocked_actions > 0 or open_blockers > 0:
return signals
if review_required_actions > 0:
signals.append("actions_need_review")
if planned_reviews_due > 0:
signals.append("reviews_due")
if review_required_actions > 0 or planned_reviews_due > 0:
return signals
if open_actions > 0:
signals.append("work_in_progress")
return signals

View File

@ -36,20 +36,22 @@ class ProductMilestoneDrivenStrategy:
"""
SELECT
'review_milestone' AS kind,
m.title AS title,
'Meilenstein prüfen oder Status aktualisieren' AS summary,
m.initiative_id,
ri.title AS title,
'Plan-Element prüfen oder Status aktualisieren' AS summary,
r.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'milestone_at_risk_or_active' AS reason_code,
'Meilenstein prüfen' AS recommended_action
FROM milestones m
WHERE m.tenant_id = %s AND m.initiative_id = %s
AND m.status IN ('at_risk', 'active')
'Plan-Element prüfen' AS recommended_action
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
AND ri.item_type = 'milestone'
AND ri.status IN ('at_risk', 'active')
ORDER BY
CASE m.status WHEN 'at_risk' THEN 0 ELSE 1 END,
m.target_date ASC NULLS LAST,
m.updated_at DESC
CASE ri.status WHEN 'at_risk' THEN 0 ELSE 1 END,
ri.target_date ASC NULLS LAST,
ri.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),

View File

@ -30,17 +30,8 @@ def test_steering_snapshot_graph(client):
)
assert snap.status_code == 200
data = snap.json()
assert data["operating_phase"] in (
"execute",
"triage",
"structure",
"review",
"adapt",
"commit",
"capture",
"verify",
"closure",
)
assert isinstance(data.get("signals"), list)
assert "operating_phase" not in data
linked = next(a for a in data["actions"] if a["id"] == action["id"])
assert len(linked["blockers"]) == 1
assert linked["open_blocker_count"] == 1

View File

@ -0,0 +1,44 @@
"""AP1.2 — Snapshot signals without operating_phase."""
from __future__ import annotations
from steering.signals.snapshot_signals import derive_initiative_signals
def test_derive_signals_backlog_awaiting_commit():
signals = derive_initiative_signals(
initiative_status="active",
open_actions=0,
blocked_actions=0,
review_required_actions=0,
open_blockers=0,
backlog_new=3,
planned_reviews_due=0,
)
assert signals == ["backlog_awaiting_commit"]
def test_derive_signals_work_in_progress():
signals = derive_initiative_signals(
initiative_status="active",
open_actions=2,
blocked_actions=0,
review_required_actions=0,
open_blockers=0,
backlog_new=0,
planned_reviews_due=0,
)
assert signals == ["work_in_progress"]
def test_derive_signals_terminal():
signals = derive_initiative_signals(
initiative_status="completed",
open_actions=0,
blocked_actions=0,
review_required_actions=0,
open_blockers=0,
backlog_new=0,
planned_reviews_due=0,
)
assert signals == ["initiative_terminal"]

View File

@ -0,0 +1,122 @@
"""AP1.4 — RoadmapItem & Quality Gate tests."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_initiative,
_login,
)
def _create_roadmap_item(client, token, initiative_id, **kwargs):
body = {"title": "Gate A", "item_type": "milestone", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/roadmap/items",
json=body,
headers=_auth(token),
)
def test_roadmap_item_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_roadmap_item(
client,
token,
initiative_id,
sequencing_mode="parallel",
item_type="maturity_stage",
)
assert created.status_code == 201
item = created.json()
assert item["item_type"] == "maturity_stage"
assert item["sequencing_mode"] == "parallel"
listed = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
headers=_auth(token),
)
assert listed.status_code == 200
assert len(listed.json()) >= 1
patched = client.patch(
f"/api/roadmap-items/{item['id']}",
json={"status": "at_risk"},
headers=_auth(token),
)
assert patched.status_code == 200
assert patched.json()["status"] == "at_risk"
deleted = client.delete(f"/api/roadmap-items/{item['id']}", headers=_auth(token))
assert deleted.status_code == 204
def test_verify_reached_requires_evidence(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_roadmap_item(client, token, initiative_id, status="active").json()
fail = client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
assert fail.status_code == 400
evidence = client.post(
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Nachweis",
"milestone_id": item["id"],
"status": "accepted",
},
headers=_auth(token),
)
assert evidence.status_code == 201
ok = client.post(
f"/api/roadmap-items/{item['id']}/verify-reached",
headers=_auth(token),
)
assert ok.status_code == 200
assert ok.json()["status"] == "reached"
assert ok.json()["verify_reason"] == "evidence_accepted"
def test_milestone_compat_api_uses_roadmap(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = client.post(
f"/api/initiatives/{initiative_id}/milestones",
json={"title": "Legacy MS", "status": "planned"},
headers=_auth(token),
)
assert created.status_code == 201
ms_id = created.json()["id"]
roadmap = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
headers=_auth(token),
).json()
assert any(i["id"] == ms_id and i["item_type"] == "milestone" for i in roadmap)
def test_steering_snapshot_reads_roadmap_items(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
_create_roadmap_item(client, token, initiative_id, status="active", target_date="2026-12-31")
snap = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
).json()
assert snap.get("roadmap_items")
assert snap.get("upcoming_milestones")
assert len(snap["upcoming_roadmap_items"]) >= 1

View File

@ -41,7 +41,8 @@ def test_steering_snapshot_has_lifecycle(client):
assert snap.status_code == 200
data = snap.json()
assert "lifecycle_state" in data
assert data.get("operating_phase_deprecated") is True
assert "signals" in data
assert "operating_phase" not in data
assert data["lifecycle_state"] in (
"intake",
"planning",

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.12.0-ap1.2c"
DB_SCHEMA_VERSION = "009"
APP_VERSION = "0.13.0-ap1.4"
DB_SCHEMA_VERSION = "010"
APP_NAME = "jinkendo-kairo"

View File

@ -50,10 +50,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Review | ✓ | |
| RecurringElement | ✓ | |
| Task (unter Action) | ✗ | |
| Roadmap | ✗ | 📄 Target State |
| RoadmapItem | ✗ | 📄; Milestone soll hierhin |
| RoadmapItem Dependencies | ✗ | |
| Plan-Ist-Verknüpfung | ✗ | Backlog/Action → Gate |
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
| RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat |
| RoadmapItem Dependencies | ◐ | requires/blocks/related |
| Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 |
---
@ -70,7 +70,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
| AttentionItem | ◐ | |
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
| operating_phase | ◐ | deprecated flag; noch im Snapshot |
| operating_phase | ✗ | entfernt AP1.2; Snapshot nutzt `signals` + Lifecycle |
| signals (Snapshot) | ✓ | `backend/steering/signals/snapshot_signals.py` |
| Hook Orchestrator | ✗ | AP1.3 geplant |
| Structure Builder | ✗ | 📄 |
| DoD Engine | ✗ | 📄 |
@ -133,7 +134,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| AP | Erwartete Truth-Table-Änderung |
|----|--------------------------------|
| AP1.2c | IA-Skeleton ✓ |
| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ |
| AP1.2 | operating_phase ✗, signals ✓ |
| AP1.4 | RoadmapItem ◐, Gate Verify ◐ |
| AP1.5 | Task ○, Project GUI ◐ |
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.12.0-ap1.2c",
"version": "0.13.0-ap1.4",
"private": true,
"type": "module",
"scripts": {

View File

@ -0,0 +1,40 @@
import { apiFetch } from './client.js'
export function listInitiativeRoadmapItems(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
}
export function createInitiativeRoadmapItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateRoadmapItem(itemId, body) {
return apiFetch(`/api/roadmap-items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteRoadmapItem(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}`, { method: 'DELETE' })
}
export function verifyRoadmapItemReached(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/verify-reached`, {
method: 'POST',
})
}
export function listRoadmapItemDependencies(itemId) {
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`)
}
export function addRoadmapItemDependency(itemId, body) {
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`, {
method: 'POST',
body: JSON.stringify(body),
})
}

View File

@ -4,7 +4,7 @@ const AREAS = [
{
to: 'plan',
title: 'Plan',
description: 'Meilensteine und Roadmap — überprüfbare Zielpunkte pflegen.',
description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
},
{
to: 'execution',
@ -34,8 +34,8 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
? `${counts.backlog} Backlog`
: area.to === 'execution' && counts.actionsOpen
? `${counts.actionsOpen} offen`
: area.to === 'plan' && counts.milestones
? `${counts.milestones} Meilensteine`
: area.to === 'plan' && counts.roadmapItems
? `${counts.roadmapItems} Plan-Elemente`
: null
return (
<Link

View File

@ -0,0 +1,220 @@
import { useState } from 'react'
import {
MILESTONE_STATUSES,
MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
SEQUENCING_MODE_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
(s) => !['reached', 'moved', 'discarded'].includes(s)
)
function formatDate(value) {
if (!value) return null
try {
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
'de-DE'
)
} catch {
return value
}
}
export function RoadmapPlanSection({
items,
canManage,
onCreate,
onUpdateStatus,
onUpdateItem,
onVerifyReached,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [goalDescription, setGoalDescription] = useState('')
const [targetDate, setTargetDate] = useState('')
const [itemType, setItemType] = useState('milestone')
const [sequencingMode, setSequencingMode] = useState('sequential')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({
title: title.trim(),
goal_description: goalDescription.trim(),
target_date: targetDate || undefined,
item_type: itemType,
sequencing_mode: sequencingMode,
})
setTitle('')
setGoalDescription('')
setTargetDate('')
setItemType('milestone')
setSequencingMode('sequential')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Plan</h2>
<p className="section-lead muted">
Roadmap-Elemente methodenneutral Gates, Reifegrade oder Review-Punkte.
Erreicht nur über Verify (Evidence, Review oder Decision).
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Plan-Element'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
<label>
Typ
<select value={itemType} onChange={(e) => setItemType(e.target.value)}>
{ITEM_TYPES.map((t) => (
<option key={t} value={t}>
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
</option>
))}
</select>
</label>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="z. B. MVP nutzbar im Alltag"
/>
</label>
<label>
Ziel / Definition of Done
<textarea
value={goalDescription}
onChange={(e) => setGoalDescription(e.target.value)}
rows={3}
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
/>
</label>
<label>
Abfolge
<select
value={sequencingMode}
onChange={(e) => setSequencingMode(e.target.value)}
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m] || m}
</option>
))}
</select>
</label>
<label>
Zieltermin
<input
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && (
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)}
<ul className="item-list milestone-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item milestone-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
<p className="muted list-item-sub">
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
{' · '}
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
</p>
{item.goal_description && (
<p className="list-item-desc">{item.goal_description}</p>
)}
{item.target_date && (
<p className="muted list-item-sub">
Zieltermin: {formatDate(item.target_date)}
</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="milestone" status={item.status} />
{canManage && item.status !== 'reached' && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Plan-Status"
>
{EDITABLE_STATUSES.map((s) => (
<option key={s} value={s}>
{MILESTONE_STATUS_LABELS[s]}
</option>
))}
</select>
<select
className="inline-select"
value={item.sequencing_mode}
onChange={(e) =>
onUpdateItem(item.id, { sequencing_mode: e.target.value })
}
aria-label="Abfolge"
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m]}
</option>
))}
</select>
{['planned', 'active', 'at_risk'].includes(item.status) && (
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onVerifyReached(item.id)}
>
Verify erreicht
</button>
)}
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -1,7 +1,5 @@
import {
LIFECYCLE_LABELS,
PHASE_LABELS,
PHASE_DESCRIPTIONS,
SIGNAL_LABELS,
NEXT_ACTION_KIND_LABELS,
} from '../constants/operating.js'
@ -51,9 +49,7 @@ export function SteeringSnapshotPanel({
lifecycle_label,
method_key,
method_label,
operating_phase,
operating_phase_deprecated,
phase_signals,
signals = [],
counts,
upcoming_milestones = [],
next_actions = [],
@ -61,12 +57,7 @@ export function SteeringSnapshotPanel({
const displayLabel =
lifecycle_label ||
LIFECYCLE_LABELS[lifecycle_state] ||
PHASE_LABELS[operating_phase] ||
lifecycle_state ||
operating_phase
const phaseDesc = lifecycle_state
? null
: PHASE_DESCRIPTIONS[operating_phase] || ''
lifecycle_state
const statPills = [
{ label: 'Offen', value: counts.actions_open, warn: false },
@ -126,11 +117,9 @@ export function SteeringSnapshotPanel({
</ul>
</div>
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
{phase_signals?.length > 0 && (
{signals?.length > 0 && (
<p className="muted snapshot-signals">
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
{signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
</p>
)}

View File

@ -60,6 +60,18 @@ export const MILESTONE_STATUS_LABELS = {
discarded: 'Verworfen',
}
export const ROADMAP_ITEM_TYPE_LABELS = {
milestone: 'Meilenstein / Gate',
review_gate: 'Review-Gate',
maturity_stage: 'Reifegrad-Stufe',
}
export const SEQUENCING_MODE_LABELS = {
sequential: 'Sequenziell',
parallel: 'Parallel',
optional: 'Optional',
}
export const EVIDENCE_STATUS_LABELS = {
submitted: 'Eingereicht',
accepted: 'Akzeptiert',

View File

@ -28,11 +28,12 @@ import {
convertBacklogToAction,
} from '../api/backlog.js'
import {
listInitiativeMilestones,
createInitiativeMilestone,
updateMilestone,
deleteMilestone,
} from '../api/milestones.js'
listInitiativeRoadmapItems,
createInitiativeRoadmapItem,
updateRoadmapItem,
deleteRoadmapItem,
verifyRoadmapItemReached,
} from '../api/roadmap.js'
import {
listInitiativeEvidence,
createInitiativeEvidence,
@ -74,7 +75,7 @@ export function InitiativeOperationsProvider({ children }) {
const [actions, setActions] = useState([])
const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([])
const [milestones, setMilestones] = useState([])
const [roadmapItems, setRoadmapItems] = useState([])
const [evidenceItems, setEvidenceItems] = useState([])
const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([])
@ -110,7 +111,7 @@ export function InitiativeOperationsProvider({ children }) {
if (canReadOm) {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([])))
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
@ -316,10 +317,10 @@ export function InitiativeOperationsProvider({ children }) {
}
}
async function handleCreateMilestone(body) {
async function handleCreateRoadmapItem(body) {
setFormBusy(true)
try {
await createInitiativeMilestone(id, body)
await createInitiativeRoadmapItem(id, body)
await load()
} catch (err) {
setError(err.message)
@ -328,24 +329,50 @@ export function InitiativeOperationsProvider({ children }) {
}
}
async function handleMilestoneStatus(milestoneId, status) {
async function handleRoadmapItemStatus(itemId, status) {
try {
await updateMilestone(milestoneId, { status })
await updateRoadmapItem(itemId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteMilestone(milestoneId) {
async function handleUpdateRoadmapItem(itemId, body) {
try {
await deleteMilestone(milestoneId)
await updateRoadmapItem(itemId, body)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleVerifyRoadmapItem(itemId) {
setFormBusy(true)
try {
await verifyRoadmapItemReached(itemId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteRoadmapItem(itemId) {
try {
await deleteRoadmapItem(itemId)
await load()
} catch (err) {
setError(err.message)
}
}
const milestones = useMemo(
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
[roadmapItems]
)
async function handleCreateEvidence(body) {
setFormBusy(true)
try {
@ -474,6 +501,7 @@ export function InitiativeOperationsProvider({ children }) {
blockers,
unlinkedBlockers,
backlogItems,
roadmapItems,
milestones,
evidenceItems,
decisions,
@ -513,9 +541,14 @@ export function InitiativeOperationsProvider({ children }) {
handleBacklogStatus,
handleConvertBacklog,
handleDeleteBacklog,
handleCreateMilestone,
handleMilestoneStatus,
handleDeleteMilestone,
handleCreateRoadmapItem,
handleRoadmapItemStatus,
handleUpdateRoadmapItem,
handleVerifyRoadmapItem,
handleDeleteRoadmapItem,
handleCreateMilestone: handleCreateRoadmapItem,
handleMilestoneStatus: handleRoadmapItemStatus,
handleDeleteMilestone: handleDeleteRoadmapItem,
handleCreateEvidence,
handleEvidenceStatus,
handleDeleteEvidence,

View File

@ -16,6 +16,7 @@ export function InitiativeOverviewPage() {
capabilities,
handleMethodChange,
backlogItems,
roadmapItems,
milestones,
} = useInitiativeOperations()
@ -69,7 +70,7 @@ export function InitiativeOverviewPage() {
initiativeId={initiativeId}
counts={{
backlog: backlogItems.length,
milestones: milestones.length,
roadmapItems: roadmapItems.length,
actionsOpen: counts.actions_open,
}}
/>

View File

@ -1,14 +1,16 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { MilestonesSection } from '../../components/MilestonesSection.jsx'
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
export function InitiativePlanPage() {
const {
milestones,
roadmapItems,
capabilities,
formBusy,
handleCreateMilestone,
handleMilestoneStatus,
handleDeleteMilestone,
handleCreateRoadmapItem,
handleRoadmapItemStatus,
handleUpdateRoadmapItem,
handleVerifyRoadmapItem,
handleDeleteRoadmapItem,
error,
} = useInitiativeOperations()
@ -19,12 +21,14 @@ export function InitiativePlanPage() {
return (
<>
{error && <p className="error">{error}</p>}
<MilestonesSection
milestones={milestones}
<RoadmapPlanSection
items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateMilestone}
onUpdateStatus={handleMilestoneStatus}
onDelete={handleDeleteMilestone}
onCreate={handleCreateRoadmapItem}
onUpdateStatus={handleRoadmapItemStatus}
onUpdateItem={handleUpdateRoadmapItem}
onVerifyReached={handleVerifyRoadmapItem}
onDelete={handleDeleteRoadmapItem}
busy={formBusy}
/>
</>