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
|
```text
|
||||||
GET /api/operational/me
|
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
|
GET /api/operational/next-action?initiative_id={id}&limit=5
|
||||||
… Arbeit …
|
… Arbeit …
|
||||||
PATCH /api/operational/actions/{id}/status {"status":"in_progress"}
|
PATCH /api/operational/actions/{id}/status {"status":"in_progress"}
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,12 @@ def get_initiative_steering_snapshot(
|
||||||
method_profile_label = profile.get("label")
|
method_profile_label = profile.get("label")
|
||||||
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
|
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 {
|
return {
|
||||||
"initiative_id": initiative_id,
|
"initiative_id": initiative_id,
|
||||||
"initiative_title": initiative["title"],
|
"initiative_title": initiative["title"],
|
||||||
|
|
@ -334,6 +340,7 @@ def get_initiative_steering_snapshot(
|
||||||
"upcoming_milestones": upcoming_milestones,
|
"upcoming_milestones": upcoming_milestones,
|
||||||
"upcoming_roadmap_items": upcoming_milestones,
|
"upcoming_roadmap_items": upcoming_milestones,
|
||||||
"next_actions": next_actions,
|
"next_actions": next_actions,
|
||||||
|
"active_work_cycle": active_work_cycle,
|
||||||
"counts": {
|
"counts": {
|
||||||
"actions_open": open_actions,
|
"actions_open": open_actions,
|
||||||
"actions_blocked": blocked_actions,
|
"actions_blocked": blocked_actions,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ from routers import ( # noqa: E402
|
||||||
tasks,
|
tasks,
|
||||||
workspace,
|
workspace,
|
||||||
operational,
|
operational,
|
||||||
|
work_cycles,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
|
@ -111,6 +112,7 @@ app.include_router(steering.router)
|
||||||
app.include_router(actors.router)
|
app.include_router(actors.router)
|
||||||
app.include_router(workspace.router)
|
app.include_router(workspace.router)
|
||||||
app.include_router(operational.router)
|
app.include_router(operational.router)
|
||||||
|
app.include_router(work_cycles.initiative_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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
|
clear_roadmap_item: bool = False
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
action_kind: Optional[Literal["delivery", "planning", "review"]] = None
|
action_kind: Optional[Literal["delivery", "planning", "review"]] = None
|
||||||
|
work_cycle_id: Optional[str] = None
|
||||||
|
clear_work_cycle: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TaskCreateRequest(BaseModel):
|
class TaskCreateRequest(BaseModel):
|
||||||
|
|
@ -137,6 +139,8 @@ def update_action(
|
||||||
clear_project=body.clear_project,
|
clear_project=body.clear_project,
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
clear_roadmap_item=body.clear_roadmap_item,
|
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,
|
sort_order=body.sort_order,
|
||||||
action_kind=body.action_kind,
|
action_kind=body.action_kind,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ class ActionCreateRequest(BaseModel):
|
||||||
due_at: Optional[str] = None
|
due_at: Optional[str] = None
|
||||||
project_id: Optional[str] = None
|
project_id: Optional[str] = None
|
||||||
roadmap_item_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)
|
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
action_kind: Literal["delivery", "planning", "review"] = "delivery"
|
action_kind: Literal["delivery", "planning", "review"] = "delivery"
|
||||||
|
|
@ -332,6 +333,7 @@ def create_initiative_action(
|
||||||
due_at=due_at,
|
due_at=due_at,
|
||||||
project_id=body.project_id,
|
project_id=body.project_id,
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
|
work_cycle_id=body.work_cycle_id,
|
||||||
assigned_actor_ids=body.assigned_actor_ids,
|
assigned_actor_ids=body.assigned_actor_ids,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
action_kind=body.action_kind,
|
action_kind=body.action_kind,
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ def operational_context(
|
||||||
"lifecycle": snapshot.get("lifecycle"),
|
"lifecycle": snapshot.get("lifecycle"),
|
||||||
"signals": snapshot.get("signals"),
|
"signals": snapshot.get("signals"),
|
||||||
"attention": snapshot.get("attention"),
|
"attention": snapshot.get("attention"),
|
||||||
|
"active_work_cycle": snapshot.get("active_work_cycle"),
|
||||||
}
|
}
|
||||||
return _ok(ctx, subset)
|
return _ok(ctx, subset)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ router = APIRouter(tags=["roadmap"])
|
||||||
|
|
||||||
class RoadmapItemCreateRequest(BaseModel):
|
class RoadmapItemCreateRequest(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
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 = ""
|
goal_description: str = ""
|
||||||
definition_of_done: list[Any] = Field(default_factory=list)
|
definition_of_done: list[Any] = Field(default_factory=list)
|
||||||
status: Literal[
|
status: Literal[
|
||||||
|
|
@ -32,7 +32,7 @@ class RoadmapItemCreateRequest(BaseModel):
|
||||||
|
|
||||||
class RoadmapItemUpdateRequest(BaseModel):
|
class RoadmapItemUpdateRequest(BaseModel):
|
||||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
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
|
goal_description: Optional[str] = None
|
||||||
definition_of_done: Optional[list[Any]] = None
|
definition_of_done: Optional[list[Any]] = None
|
||||||
status: Optional[
|
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.initiatives import PRIORITIES, get_initiative
|
||||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||||
from services.projects import project_is_leaf
|
from services.projects import project_is_leaf
|
||||||
|
from services.work_cycle import validate_work_cycle_in_initiative
|
||||||
|
|
||||||
ActionStatus = Literal[
|
ActionStatus = Literal[
|
||||||
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
|
||||||
|
|
@ -24,7 +25,7 @@ OPEN_ACTION_STATUSES = frozenset(
|
||||||
)
|
)
|
||||||
|
|
||||||
_ACTION_COLUMNS = """
|
_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
|
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]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result = dict(row)
|
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):
|
if result.get(key):
|
||||||
result[key] = str(result[key])
|
result[key] = str(result[key])
|
||||||
if result.get("created_at"):
|
if result.get("created_at"):
|
||||||
|
|
@ -144,6 +145,7 @@ def create_action(
|
||||||
due_at: Optional[Any] = None,
|
due_at: Optional[Any] = None,
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
|
work_cycle_id: Optional[str] = None,
|
||||||
assigned_actor_ids: Optional[list[str]] = None,
|
assigned_actor_ids: Optional[list[str]] = None,
|
||||||
sort_order: int = 0,
|
sort_order: int = 0,
|
||||||
action_kind: str = "delivery",
|
action_kind: str = "delivery",
|
||||||
|
|
@ -175,13 +177,19 @@ def create_action(
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
roadmap_item_id=roadmap_item_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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO actions (
|
INSERT INTO actions (
|
||||||
tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
|
tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id,
|
||||||
status, priority, due_at, sort_order, action_kind
|
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}
|
RETURNING {_ACTION_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -189,6 +197,7 @@ def create_action(
|
||||||
initiative_id,
|
initiative_id,
|
||||||
project_id,
|
project_id,
|
||||||
roadmap_item_id,
|
roadmap_item_id,
|
||||||
|
work_cycle_id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
|
|
@ -286,6 +295,8 @@ def update_action(
|
||||||
clear_project: bool = False,
|
clear_project: bool = False,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
clear_roadmap_item: bool = False,
|
clear_roadmap_item: bool = False,
|
||||||
|
work_cycle_id: Optional[str] = None,
|
||||||
|
clear_work_cycle: bool = False,
|
||||||
sort_order: Optional[int] = None,
|
sort_order: Optional[int] = None,
|
||||||
action_kind: Optional[str] = None,
|
action_kind: Optional[str] = None,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
|
|
@ -345,6 +356,16 @@ def update_action(
|
||||||
conn.close()
|
conn.close()
|
||||||
updates.append("roadmap_item_id = %s")
|
updates.append("roadmap_item_id = %s")
|
||||||
params.append(roadmap_item_id)
|
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:
|
if sort_order is not None:
|
||||||
updates.append("sort_order = %s")
|
updates.append("sort_order = %s")
|
||||||
params.append(sort_order)
|
params.append(sort_order)
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ from services.audit import log_audit
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
|
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[
|
RoadmapItemStatus = Literal[
|
||||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||||
]
|
]
|
||||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
SequencingMode = Literal["sequential", "parallel", "optional"]
|
||||||
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
|
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(
|
ROADMAP_ITEM_STATUSES = frozenset(
|
||||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
||||||
)
|
)
|
||||||
|
|
@ -277,6 +277,7 @@ def create_roadmap_item(
|
||||||
)
|
)
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
row["initiative_id"] = initiative_id
|
row["initiative_id"] = initiative_id
|
||||||
|
if item_type != "work_cycle":
|
||||||
create_default_criterion(
|
create_default_criterion(
|
||||||
cur,
|
cur,
|
||||||
tenant_id=tenant_id,
|
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",
|
key="agile_iteration",
|
||||||
label="Iterations-Zeitbox",
|
label="Iterations-Zeitbox",
|
||||||
description="Sprint/work_cycle Profil auf Product/Programm",
|
description="Sprint/work_cycle Profil auf Product/Programm",
|
||||||
next_action_strategy_key="default",
|
next_action_strategy_key="agile_iteration",
|
||||||
)
|
)
|
||||||
register_stub_method(
|
register_stub_method(
|
||||||
key="dispute_procedure",
|
key="dispute_procedure",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.strategies.next_action.agile_iteration import (
|
||||||
|
register_agile_iteration_strategy,
|
||||||
|
)
|
||||||
from steering.strategies.next_action.continuous_product import (
|
from steering.strategies.next_action.continuous_product import (
|
||||||
continuous_product_strategy,
|
continuous_product_strategy,
|
||||||
register_continuous_product_strategy,
|
register_continuous_product_strategy,
|
||||||
|
|
@ -40,3 +43,4 @@ def register_builtin_strategies() -> None:
|
||||||
register_maturity_progression_strategy()
|
register_maturity_progression_strategy()
|
||||||
register_recurring_control_strategy()
|
register_recurring_control_strategy()
|
||||||
register_queue_pull_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"
|
APP_VERSION = "0.19.0-ap2.0f"
|
||||||
DB_SCHEMA_VERSION = "024"
|
DB_SCHEMA_VERSION = "025"
|
||||||
APP_NAME = "jinkendo-kairo"
|
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 |
|
| 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 |
|
| 5 | Dogfooding R2 — Ist + Gitea-Evidence | **→ nächstes** | Seed sync + manuelle Abnahfe |
|
||||||
| 6 | AP1.7 Operational Actor API | **◐ Code** | `/api/operational/` + Service Tokens |
|
| 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 |
|
| 8 | AP1.7 Operational API | offen | MCP-Voraussetzung |
|
||||||
| 9 | Gitea-Webhook + MCP | deferred | Schicht 4 |
|
| 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 |
|
| Method Profiles (Code-Seeds) | ◐ | `product.kairo_dev`, Kumite, Buch — AP2.0b |
|
||||||
| Execution Graph Engine | ◐ | AP1.16b; API `/execution/graph-state` |
|
| Execution Graph Engine | ◐ | AP1.16b; API `/execution/graph-state` |
|
||||||
| Planning Debt (Attention) | ◐ | AP1.16d: Attention + Cockpit-Kacheln AP1.9c |
|
| 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 ◐→✓ |
|
| AP1.9c | Cockpit-Signale ◐→✓ |
|
||||||
| AP2.0d | Strategien ◐ Code |
|
| AP2.0d | Strategien ◐ Code |
|
||||||
| Dogfooding R1 | Referenz-Vorhaben in Alltag ◐ | Seed R2 sync nach Deploy |
|
| 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 |
|
| AP1.7 | Operational API ◐ Code |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user