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

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:
Lars 2026-07-12 12:34:36 +02:00
parent 5dc372c043
commit d1bc725ab5
19 changed files with 564 additions and 24 deletions

View File

@ -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"}

View File

@ -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,

View File

@ -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")

View 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;

View File

@ -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,
)

View File

@ -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,

View File

@ -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)

View File

@ -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[

View 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

View File

@ -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)

View File

@ -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,

View 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()

View File

@ -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",

View File

@ -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()

View 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)

View 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"

View File

@ -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"

View File

@ -186,7 +186,7 @@ Siehe **`Kairo_Status_Review_and_Next_Steps_v0.1.md` §5** für vollständige Ro
| 4 | AP1.16cd 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 |

View File

@ -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 |
---