AP2.0f: work_cycle Zeitbox und actions.work_cycle_id.
Some checks failed
Deploy Development / deploy (push) Failing after 48s
Test Suite / pytest-backend (push) Failing after 1s
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 3s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Failing after 48s
Test Suite / pytest-backend (push) Failing after 1s
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 3s
Test Suite / compose-smoke (push) Has been skipped
RoadmapItem type work_cycle, Sprint-API, agile_iteration Next-Action-Strategie; Steering-Snapshot zeigt active_work_cycle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5dc372c043
commit
d1bc725ab5
|
|
@ -54,7 +54,9 @@ Alternativ: `Authorization: Bearer {KAIRO_ACTOR_TOKEN}`
|
|||
|
||||
```text
|
||||
GET /api/operational/me
|
||||
GET /api/operational/initiatives/{id}/context
|
||||
GET /api/operational/initiatives/{id}/context → enthält active_work_cycle
|
||||
GET /api/initiatives/{id}/work-cycles/active → aktive Sprint-Zeitbox
|
||||
POST /api/initiatives/{id}/work-cycles → {"title","status":"active"}
|
||||
GET /api/operational/next-action?initiative_id={id}&limit=5
|
||||
… Arbeit …
|
||||
PATCH /api/operational/actions/{id}/status {"status":"in_progress"}
|
||||
|
|
|
|||
|
|
@ -309,6 +309,12 @@ def get_initiative_steering_snapshot(
|
|||
method_profile_label = profile.get("label")
|
||||
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
|
||||
|
||||
from services.work_cycle import get_active_work_cycle
|
||||
|
||||
active_work_cycle = get_active_work_cycle(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
|
||||
return {
|
||||
"initiative_id": initiative_id,
|
||||
"initiative_title": initiative["title"],
|
||||
|
|
@ -334,6 +340,7 @@ def get_initiative_steering_snapshot(
|
|||
"upcoming_milestones": upcoming_milestones,
|
||||
"upcoming_roadmap_items": upcoming_milestones,
|
||||
"next_actions": next_actions,
|
||||
"active_work_cycle": active_work_cycle,
|
||||
"counts": {
|
||||
"actions_open": open_actions,
|
||||
"actions_blocked": blocked_actions,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ from routers import ( # noqa: E402
|
|||
tasks,
|
||||
workspace,
|
||||
operational,
|
||||
work_cycles,
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
|
|
@ -111,6 +112,7 @@ app.include_router(steering.router)
|
|||
app.include_router(actors.router)
|
||||
app.include_router(workspace.router)
|
||||
app.include_router(operational.router)
|
||||
app.include_router(work_cycles.initiative_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
11
backend/migrations/025_work_cycle.sql
Normal file
11
backend/migrations/025_work_cycle.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- AP2.0f — work_cycle (Zeitbox/Sprint) + actions.work_cycle_id
|
||||
|
||||
ALTER TABLE roadmap_items DROP CONSTRAINT IF EXISTS roadmap_items_item_type_check;
|
||||
ALTER TABLE roadmap_items ADD CONSTRAINT roadmap_items_item_type_check
|
||||
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage', 'work_cycle'));
|
||||
|
||||
ALTER TABLE actions
|
||||
ADD COLUMN work_cycle_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_actions_work_cycle ON actions(tenant_id, work_cycle_id)
|
||||
WHERE work_cycle_id IS NOT NULL;
|
||||
|
|
@ -31,6 +31,8 @@ class ActionUpdateRequest(BaseModel):
|
|||
clear_roadmap_item: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
action_kind: Optional[Literal["delivery", "planning", "review"]] = None
|
||||
work_cycle_id: Optional[str] = None
|
||||
clear_work_cycle: bool = False
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
|
|
@ -137,6 +139,8 @@ def update_action(
|
|||
clear_project=body.clear_project,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
clear_work_cycle=body.clear_work_cycle,
|
||||
sort_order=body.sort_order,
|
||||
action_kind=body.action_kind,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class ActionCreateRequest(BaseModel):
|
|||
due_at: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
work_cycle_id: Optional[str] = None
|
||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||
sort_order: int = 0
|
||||
action_kind: Literal["delivery", "planning", "review"] = "delivery"
|
||||
|
|
@ -332,6 +333,7 @@ def create_initiative_action(
|
|||
due_at=due_at,
|
||||
project_id=body.project_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
work_cycle_id=body.work_cycle_id,
|
||||
assigned_actor_ids=body.assigned_actor_ids,
|
||||
sort_order=body.sort_order,
|
||||
action_kind=body.action_kind,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ def operational_context(
|
|||
"lifecycle": snapshot.get("lifecycle"),
|
||||
"signals": snapshot.get("signals"),
|
||||
"attention": snapshot.get("attention"),
|
||||
"active_work_cycle": snapshot.get("active_work_cycle"),
|
||||
}
|
||||
return _ok(ctx, subset)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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"
|
||||
item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle"] = "milestone"
|
||||
goal_description: str = ""
|
||||
definition_of_done: list[Any] = Field(default_factory=list)
|
||||
status: Literal[
|
||||
|
|
@ -32,7 +32,7 @@ class RoadmapItemCreateRequest(BaseModel):
|
|||
|
||||
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
|
||||
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]] = None
|
||||
goal_description: Optional[str] = None
|
||||
definition_of_done: Optional[list[Any]] = None
|
||||
status: Optional[
|
||||
|
|
|
|||
105
backend/routers/work_cycles.py
Normal file
105
backend/routers/work_cycles.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Work cycle (Sprint/Zeitbox) API — AP2.0f."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal, Optional
|
||||
|
||||
from capabilities import require_capability
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from services import work_cycle as work_cycle_service
|
||||
from tenant_context import TenantContext
|
||||
|
||||
initiative_router = APIRouter(
|
||||
prefix="/api/initiatives/{initiative_id}/work-cycles",
|
||||
tags=["work-cycles"],
|
||||
)
|
||||
|
||||
|
||||
class WorkCycleCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
goal_description: str = ""
|
||||
status: Literal["planned", "active"] = "planned"
|
||||
target_date: Optional[date] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
@initiative_router.get("")
|
||||
def list_work_cycles(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
return work_cycle_service.list_work_cycles_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
|
||||
|
||||
@initiative_router.get("/active")
|
||||
def get_active_work_cycle(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
item = work_cycle_service.get_active_work_cycle(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not item:
|
||||
return None
|
||||
item["open_action_count"] = work_cycle_service.count_actions_in_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=item["id"],
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
@initiative_router.post("", status_code=201)
|
||||
def create_work_cycle(
|
||||
initiative_id: str,
|
||||
body: WorkCycleCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
item = work_cycle_service.create_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=body.title,
|
||||
goal_description=body.goal_description,
|
||||
status=body.status,
|
||||
target_date=body.target_date,
|
||||
sort_order=body.sort_order,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
if body.status == "active":
|
||||
item = work_cycle_service.activate_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=item["id"],
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
return item
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "Initiative nicht gefunden":
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{work_cycle_id}/activate")
|
||||
def activate_work_cycle(
|
||||
initiative_id: str,
|
||||
work_cycle_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
return work_cycle_service.activate_work_cycle(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail in ("Initiative nicht gefunden", "Zeitbox nicht gefunden"):
|
||||
raise HTTPException(status_code=404, detail=detail) from exc
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
|
@ -11,6 +11,7 @@ from services.audit import log_audit
|
|||
from services.initiatives import PRIORITIES, get_initiative
|
||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||
from services.projects import project_is_leaf
|
||||
from services.work_cycle import validate_work_cycle_in_initiative
|
||||
|
||||
ActionStatus = Literal[
|
||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||
|
|
@ -24,7 +25,7 @@ OPEN_ACTION_STATUSES = frozenset(
|
|||
)
|
||||
|
||||
_ACTION_COLUMNS = """
|
||||
id, tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
|
||||
id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description,
|
||||
status, priority, due_at, sort_order, action_kind, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ ACTION_KINDS = frozenset({"delivery", "planning", "review"})
|
|||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "owner_actor_id"):
|
||||
for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "work_cycle_id", "owner_actor_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
|
|
@ -144,6 +145,7 @@ def create_action(
|
|||
due_at: Optional[Any] = None,
|
||||
project_id: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
work_cycle_id: Optional[str] = None,
|
||||
assigned_actor_ids: Optional[list[str]] = None,
|
||||
sort_order: int = 0,
|
||||
action_kind: str = "delivery",
|
||||
|
|
@ -175,13 +177,19 @@ def create_action(
|
|||
initiative_id=initiative_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
validate_work_cycle_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
cur=cur,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO actions (
|
||||
tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
|
||||
status, priority, due_at, sort_order, action_kind
|
||||
tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id,
|
||||
title, description, status, priority, due_at, sort_order, action_kind
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_ACTION_COLUMNS}
|
||||
""",
|
||||
(
|
||||
|
|
@ -189,6 +197,7 @@ def create_action(
|
|||
initiative_id,
|
||||
project_id,
|
||||
roadmap_item_id,
|
||||
work_cycle_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
|
|
@ -286,6 +295,8 @@ def update_action(
|
|||
clear_project: bool = False,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
work_cycle_id: Optional[str] = None,
|
||||
clear_work_cycle: bool = False,
|
||||
sort_order: Optional[int] = None,
|
||||
action_kind: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
|
|
@ -345,6 +356,16 @@ def update_action(
|
|||
conn.close()
|
||||
updates.append("roadmap_item_id = %s")
|
||||
params.append(roadmap_item_id)
|
||||
if clear_work_cycle:
|
||||
updates.append("work_cycle_id = NULL")
|
||||
elif work_cycle_id is not None:
|
||||
validate_work_cycle_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
work_cycle_id=work_cycle_id,
|
||||
)
|
||||
updates.append("work_cycle_id = %s")
|
||||
params.append(work_cycle_id)
|
||||
if sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
params.append(sort_order)
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ from services.audit import log_audit
|
|||
from services.initiatives import get_initiative
|
||||
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
|
||||
|
||||
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"]
|
||||
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]
|
||||
RoadmapItemStatus = Literal[
|
||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||
]
|
||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
||||
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
|
||||
|
||||
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
|
||||
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle"})
|
||||
ROADMAP_ITEM_STATUSES = frozenset(
|
||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
||||
)
|
||||
|
|
@ -277,14 +277,15 @@ def create_roadmap_item(
|
|||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
row["initiative_id"] = initiative_id
|
||||
create_default_criterion(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
roadmap_item_id=row["id"],
|
||||
title=(initial_criterion_title or "Gate allgemein").strip()
|
||||
or "Gate allgemein",
|
||||
description=goal_description or "",
|
||||
)
|
||||
if item_type != "work_cycle":
|
||||
create_default_criterion(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
roadmap_item_id=row["id"],
|
||||
title=(initial_criterion_title or "Gate allgemein").strip()
|
||||
or "Gate allgemein",
|
||||
description=goal_description or "",
|
||||
)
|
||||
_sync_milestone_compat_row(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
|
|
|
|||
195
backend/services/work_cycle.py
Normal file
195
backend/services/work_cycle.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Work cycle (Sprint/Zeitbox) — AP2.0f on RoadmapItem(type=work_cycle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, 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.roadmap import create_roadmap_item, list_roadmap_items_for_initiative
|
||||
|
||||
WORK_CYCLE_TYPE = "work_cycle"
|
||||
OPEN_CYCLE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||
|
||||
|
||||
def _serialize_cycle(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": item["id"],
|
||||
"initiative_id": item.get("initiative_id"),
|
||||
"title": item["title"],
|
||||
"goal_description": item.get("goal_description") or "",
|
||||
"status": item["status"],
|
||||
"target_date": item.get("target_date"),
|
||||
"sort_order": item.get("sort_order", 0),
|
||||
"item_type": item.get("item_type", WORK_CYCLE_TYPE),
|
||||
}
|
||||
|
||||
|
||||
def list_work_cycles_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
items = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return [
|
||||
_serialize_cycle(item)
|
||||
for item in items
|
||||
if item.get("item_type") == WORK_CYCLE_TYPE
|
||||
]
|
||||
|
||||
|
||||
def get_active_work_cycle(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
cycles = list_work_cycles_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
active = [c for c in cycles if c["status"] == "active"]
|
||||
if not active:
|
||||
return None
|
||||
active.sort(key=lambda c: (c.get("sort_order", 0), c["id"]))
|
||||
return active[0]
|
||||
|
||||
|
||||
def validate_work_cycle_in_initiative(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
work_cycle_id: Optional[str],
|
||||
cur=None,
|
||||
) -> None:
|
||||
if not work_cycle_id:
|
||||
return
|
||||
|
||||
sql = """
|
||||
SELECT ri.item_type
|
||||
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
|
||||
"""
|
||||
params = (work_cycle_id, tenant_id, initiative_id)
|
||||
|
||||
if cur is not None:
|
||||
cur.execute(sql, params)
|
||||
row = cur.fetchone()
|
||||
else:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(sql, params)
|
||||
row = cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise ValueError("Zeitbox gehört nicht zu diesem Vorhaben")
|
||||
item_type = row["item_type"] if isinstance(row, dict) else row[0]
|
||||
if item_type != WORK_CYCLE_TYPE:
|
||||
raise ValueError("work_cycle_id muss auf RoadmapItem(type=work_cycle) verweisen")
|
||||
|
||||
|
||||
def create_work_cycle(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
title: str,
|
||||
goal_description: str = "",
|
||||
status: str = "planned",
|
||||
target_date: Optional[date] = None,
|
||||
sort_order: int = 0,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
if status == "reached":
|
||||
raise ValueError("Status 'reached' für work_cycle über complete-Endpoint")
|
||||
item = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title=title,
|
||||
item_type="work_cycle",
|
||||
goal_description=goal_description,
|
||||
status=status, # type: ignore[arg-type]
|
||||
target_date=target_date,
|
||||
sort_order=sort_order,
|
||||
initial_criterion_title=None,
|
||||
user_id=user_id,
|
||||
)
|
||||
return _serialize_cycle(item)
|
||||
|
||||
|
||||
def activate_work_cycle(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
work_cycle_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
validate_work_cycle_in_initiative(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
work_cycle_id=work_cycle_id,
|
||||
)
|
||||
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(
|
||||
"""
|
||||
UPDATE roadmap_items ri
|
||||
SET status = 'planned', updated_at = NOW()
|
||||
FROM roadmaps r
|
||||
WHERE ri.roadmap_id = r.id
|
||||
AND r.initiative_id = %s AND ri.tenant_id = %s
|
||||
AND ri.item_type = %s AND ri.status = 'active'
|
||||
AND ri.id <> %s
|
||||
""",
|
||||
(initiative_id, tenant_id, WORK_CYCLE_TYPE, work_cycle_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE roadmap_items
|
||||
SET status = 'active', updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, title, goal_description, status, target_date, sort_order, item_type
|
||||
""",
|
||||
(work_cycle_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Zeitbox nicht gefunden")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = _serialize_cycle({**dict(row), "initiative_id": initiative_id})
|
||||
log_audit(
|
||||
"work_cycle.activated",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def count_actions_in_work_cycle(
|
||||
*, tenant_id: str, initiative_id: str, work_cycle_id: str
|
||||
) -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM actions
|
||||
WHERE tenant_id = %s AND initiative_id = %s AND work_cycle_id = %s
|
||||
AND status NOT IN ('done', 'discarded')
|
||||
""",
|
||||
(tenant_id, initiative_id, work_cycle_id),
|
||||
)
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -34,7 +34,7 @@ def register() -> None:
|
|||
key="agile_iteration",
|
||||
label="Iterations-Zeitbox",
|
||||
description="Sprint/work_cycle Profil auf Product/Programm",
|
||||
next_action_strategy_key="default",
|
||||
next_action_strategy_key="agile_iteration",
|
||||
)
|
||||
register_stub_method(
|
||||
key="dispute_procedure",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from steering.strategies.next_action.agile_iteration import (
|
||||
register_agile_iteration_strategy,
|
||||
)
|
||||
from steering.strategies.next_action.continuous_product import (
|
||||
continuous_product_strategy,
|
||||
register_continuous_product_strategy,
|
||||
|
|
@ -40,3 +43,4 @@ def register_builtin_strategies() -> None:
|
|||
register_maturity_progression_strategy()
|
||||
register_recurring_control_strategy()
|
||||
register_queue_pull_strategy()
|
||||
register_agile_iteration_strategy()
|
||||
|
|
|
|||
103
backend/steering/strategies/next_action/agile_iteration.py
Normal file
103
backend/steering/strategies/next_action/agile_iteration.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Agile iteration NextAction strategy — AP2.0f (active work_cycle first)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from steering.signals import default_rules
|
||||
from steering.strategies.next_action.continuous_product import (
|
||||
ContinuousProductStrategy,
|
||||
)
|
||||
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
||||
from steering.strategies.next_action.execution_ready import (
|
||||
blocked_execution_action_ids,
|
||||
build_execution_ready_candidates,
|
||||
list_execution_ready_candidates,
|
||||
merge_candidates,
|
||||
)
|
||||
from steering.strategies.next_action.registry import (
|
||||
get_next_action_strategy,
|
||||
register_next_action_strategy,
|
||||
)
|
||||
from services.work_cycle import get_active_work_cycle
|
||||
from tenant_context import TenantContext
|
||||
|
||||
_default = DefaultNextActionStrategy()
|
||||
_continuous = ContinuousProductStrategy()
|
||||
|
||||
|
||||
class AgileIterationStrategy:
|
||||
key = "agile_iteration"
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
ctx: TenantContext,
|
||||
*,
|
||||
initiative_id: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if not initiative_id:
|
||||
return _default.evaluate(ctx, limit=limit)
|
||||
|
||||
active = get_active_work_cycle(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if not active:
|
||||
return _continuous.evaluate(ctx, initiative_id=initiative_id, limit=limit)
|
||||
|
||||
cycle_id = active["id"]
|
||||
from services import actions as action_service
|
||||
from steering.graph.execution_engine import load_initiative_execution_graph_state
|
||||
|
||||
actions = action_service.list_actions_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
cycle_actions = [
|
||||
a for a in actions if str(a.get("work_cycle_id") or "") == cycle_id
|
||||
]
|
||||
if not cycle_actions:
|
||||
ready = list_execution_ready_candidates(
|
||||
ctx, initiative_id, limit=limit, prefer_critical_path=True
|
||||
)
|
||||
if ready:
|
||||
return ready[:limit]
|
||||
return _continuous.evaluate(ctx, initiative_id=initiative_id, limit=limit)
|
||||
|
||||
graph_state = load_initiative_execution_graph_state(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
)
|
||||
blocked_ids = blocked_execution_action_ids(ctx, initiative_id)
|
||||
ready = build_execution_ready_candidates(
|
||||
actions=cycle_actions,
|
||||
graph_state=graph_state,
|
||||
limit=limit,
|
||||
prefer_critical_path=True,
|
||||
)
|
||||
for item in ready:
|
||||
item["reason_code"] = "work_cycle_ready"
|
||||
item["summary"] = f"Aktive Zeitbox „{active['title']}“ — ausführungsbereit"
|
||||
item["recommended_action"] = "In dieser Iteration ausführen"
|
||||
|
||||
if len(ready) >= limit:
|
||||
return ready[:limit]
|
||||
|
||||
rest = _continuous.evaluate(
|
||||
ctx, initiative_id=initiative_id, limit=limit - len(ready)
|
||||
)
|
||||
filtered = [
|
||||
item
|
||||
for item in rest
|
||||
if not item.get("action_id") or item["action_id"] not in blocked_ids
|
||||
]
|
||||
return merge_candidates(ready, filtered, limit=limit, exclude_action_ids=blocked_ids)
|
||||
|
||||
|
||||
agile_iteration_strategy = AgileIterationStrategy()
|
||||
|
||||
|
||||
def register_agile_iteration_strategy() -> None:
|
||||
if not get_next_action_strategy(agile_iteration_strategy.key):
|
||||
register_next_action_strategy(agile_iteration_strategy)
|
||||
82
backend/tests/test_ap20f_work_cycle.py
Normal file
82
backend/tests/test_ap20f_work_cycle.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""AP2.0f — work_cycle / Sprint-Zeitbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from steering.strategies.next_action import register_builtin_strategies
|
||||
from steering.strategies.next_action.registry import get_next_action_strategy
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||
|
||||
|
||||
def test_agile_iteration_strategy_registered():
|
||||
register_builtin_strategies()
|
||||
assert get_next_action_strategy("agile_iteration") is not None
|
||||
|
||||
|
||||
def test_work_cycle_crud_and_action_link(client):
|
||||
user = provision_user_in_tenant(tenant_role="admin")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="Sprint Product",
|
||||
archetype_key="initiative.product",
|
||||
)
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
cycle = client.post(
|
||||
f"/api/initiatives/{initiative_id}/work-cycles",
|
||||
json={"title": "Iteration R1", "goal_description": "AP2.0f", "status": "active"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert cycle.status_code == 201
|
||||
cycle_id = cycle.json()["id"]
|
||||
assert cycle.json()["status"] == "active"
|
||||
|
||||
active = client.get(
|
||||
f"/api/initiatives/{initiative_id}/work-cycles/active",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert active.status_code == 200
|
||||
assert active.json()["id"] == cycle_id
|
||||
|
||||
action = client.post(
|
||||
f"/api/initiatives/{initiative_id}/actions",
|
||||
json={
|
||||
"title": "Sprint AP A",
|
||||
"work_cycle_id": cycle_id,
|
||||
"status": "ready",
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert action.status_code == 201
|
||||
assert action.json()["work_cycle_id"] == cycle_id
|
||||
|
||||
listed = client.get(
|
||||
f"/api/initiatives/{initiative_id}/work-cycles",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert any(item["id"] == cycle_id for item in listed.json())
|
||||
|
||||
|
||||
def test_steering_snapshot_includes_active_work_cycle(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
created = _create_initiative(client, token, title="Snapshot Cycle")
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
client.post(
|
||||
f"/api/initiatives/{initiative_id}/work-cycles",
|
||||
json={"title": "Sprint 1", "status": "active"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
snap = client.get(
|
||||
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert snap.status_code == 200
|
||||
assert snap.json().get("active_work_cycle")
|
||||
assert snap.json()["active_work_cycle"]["title"] == "Sprint 1"
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.19.0-ap1.7"
|
||||
DB_SCHEMA_VERSION = "024"
|
||||
APP_VERSION = "0.19.0-ap2.0f"
|
||||
DB_SCHEMA_VERSION = "025"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ Siehe **`Kairo_Status_Review_and_Next_Steps_v0.1.md` §5** für vollständige Ro
|
|||
| 4 | AP1.16c–d Plan-Outline-Kanten + Planning Debt | **◐ Code** | Remote-Verifikation nach Deploy |
|
||||
| 5 | Dogfooding R2 — Ist + Gitea-Evidence | **→ nächstes** | Seed sync + manuelle Abnahfe |
|
||||
| 6 | AP1.7 Operational Actor API | **◐ Code** | `/api/operational/` + Service Tokens |
|
||||
| 7 | AP2.0f work_cycle (B3 minimal) | offen | Sprint-Zeitbox |
|
||||
| 7 | AP2.0f work_cycle (B3 minimal) | **◐ Code** | Sprint-Zeitbox; Remote-Verifikation nach Deploy |
|
||||
| 8 | AP1.7 Operational API | offen | MCP-Voraussetzung |
|
||||
| 9 | Gitea-Webhook + MCP | deferred | Schicht 4 |
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Method Profiles (Code-Seeds) | ◐ | `product.kairo_dev`, Kumite, Buch — AP2.0b |
|
||||
| Execution Graph Engine | ◐ | AP1.16b; API `/execution/graph-state` |
|
||||
| Planning Debt (Attention) | ◐ | AP1.16d: Attention + Cockpit-Kacheln AP1.9c |
|
||||
| `work_cycle` / Sprint | ✗ | 📄 MVP v0.3 B3; Migration AP2.0f geplant |
|
||||
| `work_cycle` / Sprint | ◐ | AP2.0f Code: RoadmapItem + actions.work_cycle_id; Remote-Verifikation |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -208,7 +208,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
|
|||
| AP1.9c | Cockpit-Signale ◐→✓ |
|
||||
| AP2.0d | Strategien ◐ Code |
|
||||
| Dogfooding R1 | Referenz-Vorhaben in Alltag ◐ | Seed R2 sync nach Deploy |
|
||||
| AP2.0f | work_cycle ○→◐ |
|
||||
| AP2.0f | work_cycle ◐ Code |
|
||||
| AP1.7 | Operational API ◐ Code |
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user