diff --git a/backend/routers/actors.py b/backend/routers/actors.py index d0a6ee1..ae2e6da 100644 --- a/backend/routers/actors.py +++ b/backend/routers/actors.py @@ -20,6 +20,11 @@ class ServiceTokenCreateRequest(BaseModel): expires_in_days: Optional[int] = Field(default=365, ge=1, le=730) +class ActorCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + actor_type: Literal["human", "agent", "working_group", "external_system"] = "agent" + + @router.get("") def list_actors( actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None, @@ -38,6 +43,26 @@ def list_actors( raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("", status_code=201) +def create_actor( + body: ActorCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.actor.manage")), +): + if body.actor_type == "human": + raise HTTPException( + status_code=400, + detail="Human-Actors werden über User-Provisioning angelegt", + ) + try: + return actor_service.create_actor( + tenant_id=ctx.tenant_id, + actor_type=body.actor_type, + name=body.name.strip(), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/{actor_id}") def get_actor( actor_id: str, diff --git a/backend/services/archetype_starter_kit.py b/backend/services/archetype_starter_kit.py index 316caec..e2964f4 100644 --- a/backend/services/archetype_starter_kit.py +++ b/backend/services/archetype_starter_kit.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timezone from typing import Any, Optional from entity_archetypes import resolve_default_method_key @@ -222,6 +223,7 @@ def _apply_a1( title="Tägliche Übung", description="Regelmäßige Übung für die aktuelle Reifegrad-Stufe", interval_days=1, + next_due_at=datetime.now(timezone.utc), user_id=user_id, ) diff --git a/backend/services/maturity_stage_transition.py b/backend/services/maturity_stage_transition.py new file mode 100644 index 0000000..7f0f0db --- /dev/null +++ b/backend/services/maturity_stage_transition.py @@ -0,0 +1,87 @@ +"""AP2.0e — Recurring transition when a maturity_stage is reached.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from services.recurring import ( + create_recurring_element, + list_recurring_for_initiative, + update_recurring_element, +) +from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative, update_roadmap_item + + +def on_maturity_stage_reached( + *, + tenant_id: str, + initiative_id: str, + reached_item_id: str, + user_id: Optional[str] = None, +) -> Optional[dict[str, Any]]: + """Pause old routines, activate next stage, seed new routine for next stage.""" + item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id) + if not item or item.get("item_type") != "maturity_stage": + return None + + stages = [ + s + for s in list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + if s.get("item_type") == "maturity_stage" + ] + stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", ""))) + + reached_index = next( + (index for index, stage in enumerate(stages) if stage["id"] == reached_item_id), + None, + ) + if reached_index is None: + return None + + next_stage = None + for stage in stages[reached_index + 1 :]: + if stage.get("status") in {"planned", "active", "at_risk"}: + next_stage = stage + break + + paused: list[str] = [] + for recurring in list_recurring_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ): + if recurring.get("status") == "active": + update_recurring_element( + tenant_id=tenant_id, + recurring_id=recurring["id"], + status="paused", + user_id=user_id, + ) + paused.append(recurring["title"]) + + created_recurring = None + if next_stage: + update_roadmap_item( + tenant_id=tenant_id, + item_id=next_stage["id"], + status="active", + user_id=user_id, + ) + created_recurring = create_recurring_element( + tenant_id=tenant_id, + initiative_id=initiative_id, + title=f"Übung — {next_stage['title']}", + description=f"Routine für {next_stage['title']} (automatisch nach Stufenübergang)", + status="active", + interval_days=1, + next_due_at=datetime.now(timezone.utc), + user_id=user_id, + ) + + return { + "reached_stage_id": reached_item_id, + "next_stage_id": next_stage["id"] if next_stage else None, + "paused_recurring": paused, + "new_recurring_id": created_recurring["id"] if created_recurring else None, + } diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index bd6f4a6..ddd8036 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -708,6 +708,17 @@ def verify_reached( tenant_id=tenant_id, details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, ) + if row.get("item_type") == "maturity_stage": + from services.maturity_stage_transition import on_maturity_stage_reached + + transition = on_maturity_stage_reached( + tenant_id=tenant_id, + initiative_id=initiative_id, + reached_item_id=item_id, + user_id=user_id, + ) + if transition: + row["maturity_transition"] = transition return row diff --git a/backend/steering/strategies/next_action/maturity_progression.py b/backend/steering/strategies/next_action/maturity_progression.py index 6cc9485..de7b7bc 100644 --- a/backend/steering/strategies/next_action/maturity_progression.py +++ b/backend/steering/strategies/next_action/maturity_progression.py @@ -14,6 +14,10 @@ from steering.strategies.next_action.execution_ready import ( from steering.strategies.next_action.product_milestone_driven import ( ProductMilestoneDrivenStrategy, ) +from steering.strategies.next_action.recurring_helpers import ( + active_recurring_candidates, + recurring_due_candidates, +) from steering.strategies.next_action.registry import ( get_next_action_strategy, register_next_action_strategy, @@ -39,26 +43,37 @@ class MaturityProgressionStrategy: if not initiative_id: return _default.evaluate(ctx, limit=limit) - gate_scope = first_active_gate_id(ctx, initiative_id) - ready = list_execution_ready_candidates( - ctx, - initiative_id, - limit=limit, - scope_roadmap_item_id=gate_scope, - prefer_critical_path=True, - ) + recurring = recurring_due_candidates(ctx, initiative_id, limit=limit) + if not recurring: + recurring = active_recurring_candidates(ctx, initiative_id, limit=1) - remaining = limit - len(ready) - gate_items: list[dict[str, Any]] = [] + remaining = limit - len(recurring) + gate_scope = first_active_gate_id(ctx, initiative_id) + ready: list[dict[str, Any]] = [] if remaining > 0: + ready = list_execution_ready_candidates( + ctx, + initiative_id, + limit=remaining, + scope_roadmap_item_id=gate_scope, + prefer_critical_path=True, + ) + + merged = merge_candidates(recurring, ready, limit=limit) + if len(merged) >= limit: + return merged[:limit] + + gate_items: list[dict[str, Any]] = [] + rest_limit = limit - len(merged) + if rest_limit > 0: gate_items = _milestone.evaluate( - ctx, initiative_id=initiative_id, limit=remaining + ctx, initiative_id=initiative_id, limit=rest_limit ) gate_items = [ item for item in gate_items if item.get("kind") == "review_milestone" ] - merged = merge_candidates(ready, gate_items, limit=limit) + merged = merge_candidates(merged, gate_items, limit=limit) if len(merged) >= limit: return merged[:limit] diff --git a/backend/steering/strategies/next_action/recurring_control.py b/backend/steering/strategies/next_action/recurring_control.py index ee3822b..1b0eadc 100644 --- a/backend/steering/strategies/next_action/recurring_control.py +++ b/backend/steering/strategies/next_action/recurring_control.py @@ -4,8 +4,7 @@ from __future__ import annotations from typing import Any -from db import get_connection -from psycopg2.extras import RealDictCursor +from steering.strategies.next_action.recurring_helpers import recurring_due_candidates from steering.signals import default_rules from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy from steering.strategies.next_action.execution_ready import ( @@ -21,43 +20,6 @@ from tenant_context import TenantContext _default = DefaultNextActionStrategy() -def _recurring_due_candidates( - ctx: TenantContext, initiative_id: str, *, limit: int -) -> list[dict[str, Any]]: - conn = get_connection() - try: - with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute( - """ - SELECT - 'recurring_due' AS kind, - re.title AS title, - 'Wiederkehrendes Element fällig' AS summary, - re.initiative_id, - NULL::uuid AS action_id, - NULL::uuid AS backlog_item_id, - 'recurring_due' AS reason_code, - 'Rhythmus-Element bearbeiten' AS recommended_action - FROM recurring_elements re - WHERE re.tenant_id = %s AND re.initiative_id = %s - AND re.status = 'active' - AND re.next_due_at IS NOT NULL - AND re.next_due_at <= NOW() - ORDER BY re.next_due_at ASC - LIMIT %s - """, - (ctx.tenant_id, initiative_id, limit), - ) - items: list[dict[str, Any]] = [] - for row in cur.fetchall(): - item = dict(row) - item["initiative_id"] = str(item["initiative_id"]) - items.append(item) - return items - finally: - conn.close() - - class RecurringControlStrategy: key = "recurring_control" @@ -73,7 +35,7 @@ class RecurringControlStrategy: if not initiative_id: return _default.evaluate(ctx, limit=limit) - recurring = _recurring_due_candidates(ctx, initiative_id, limit=limit) + recurring = recurring_due_candidates(ctx, initiative_id, limit=limit) remaining = limit - len(recurring) ready: list[dict[str, Any]] = [] if remaining > 0: diff --git a/backend/steering/strategies/next_action/recurring_helpers.py b/backend/steering/strategies/next_action/recurring_helpers.py new file mode 100644 index 0000000..b265cdc --- /dev/null +++ b/backend/steering/strategies/next_action/recurring_helpers.py @@ -0,0 +1,82 @@ +"""Shared recurring NextAction helpers.""" + +from __future__ import annotations + +from typing import Any + +from db import get_connection +from psycopg2.extras import RealDictCursor +from tenant_context import TenantContext + + +def recurring_due_candidates( + ctx: TenantContext, initiative_id: str, *, limit: int +) -> list[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT + 'recurring_due' AS kind, + re.title AS title, + 'Wiederkehrendes Element fällig' AS summary, + re.initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS backlog_item_id, + 'recurring_due' AS reason_code, + 'Rhythmus-Element bearbeiten' AS recommended_action + FROM recurring_elements re + WHERE re.tenant_id = %s AND re.initiative_id = %s + AND re.status = 'active' + AND re.next_due_at IS NOT NULL + AND re.next_due_at <= NOW() + ORDER BY re.next_due_at ASC + LIMIT %s + """, + (ctx.tenant_id, initiative_id, limit), + ) + items: list[dict[str, Any]] = [] + for row in cur.fetchall(): + item = dict(row) + item["initiative_id"] = str(item["initiative_id"]) + items.append(item) + return items + finally: + conn.close() + + +def active_recurring_candidates( + ctx: TenantContext, initiative_id: str, *, limit: int = 1 +) -> list[dict[str, Any]]: + """Active recurring as 'heutige Übung' when nothing is due yet.""" + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT + 'recurring_due' AS kind, + re.title AS title, + 'Heutige Übung / Routine' AS summary, + re.initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS backlog_item_id, + 'recurring_active' AS reason_code, + 'Routine ausführen' AS recommended_action + FROM recurring_elements re + WHERE re.tenant_id = %s AND re.initiative_id = %s + AND re.status = 'active' + ORDER BY re.next_due_at ASC NULLS LAST, re.title + LIMIT %s + """, + (ctx.tenant_id, initiative_id, limit), + ) + items: list[dict[str, Any]] = [] + for row in cur.fetchall(): + item = dict(row) + item["initiative_id"] = str(item["initiative_id"]) + items.append(item) + return items + finally: + conn.close() diff --git a/backend/tests/test_ap20e_maturity_transition.py b/backend/tests/test_ap20e_maturity_transition.py new file mode 100644 index 0000000..7230747 --- /dev/null +++ b/backend/tests/test_ap20e_maturity_transition.py @@ -0,0 +1,62 @@ +"""AP2.0e — maturity stage transition on verify reached.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _login + + +def test_maturity_stage_reached_rotates_recurring(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = client.post( + "/api/initiatives", + json={ + "title": "Spagat Test", + "archetype_key": "initiative.maturity_journey", + }, + headers=_auth(token), + ) + assert created.status_code == 201 + initiative_id = created.json()["id"] + + items = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + stage1 = next(i for i in items if i["title"] == "Stufe 1 — Basis") + + client.post( + f"/api/initiatives/{initiative_id}/evidence", + json={ + "title": "Stufe 1 geschafft", + "roadmap_item_id": stage1["id"], + "status": "accepted", + }, + headers=_auth(token), + ) + + verify = client.post( + f"/api/roadmap-items/{stage1['id']}/verify-reached", + headers=_auth(token), + ) + assert verify.status_code == 200 + body = verify.json() + assert body["status"] == "reached" + assert body.get("maturity_transition", {}).get("new_recurring_id") + + recurring = client.get( + f"/api/initiatives/{initiative_id}/recurring", + headers=_auth(token), + ).json() + titles = {r["title"]: r["status"] for r in recurring} + assert titles.get("Tägliche Übung") == "paused" + assert any(t.startswith("Übung — Stufe 2") for t in titles) + + items_after = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + stage2 = next(i for i in items_after if i["title"] == "Stufe 2 — Aufbau") + assert stage2["status"] == "active" diff --git a/backend/tests/test_ap22_actor_create.py b/backend/tests/test_ap22_actor_create.py new file mode 100644 index 0000000..41fc00d --- /dev/null +++ b/backend/tests/test_ap22_actor_create.py @@ -0,0 +1,18 @@ +"""Actor create API.""" + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _login + + +def test_create_agent_actor(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = client.post( + "/api/actors", + json={"name": "Cursor Agent", "actor_type": "agent"}, + headers=_auth(token), + ) + assert created.status_code == 201 + assert created.json()["name"] == "Cursor Agent" + assert created.json()["actor_type"] == "agent" diff --git a/backend/version.py b/backend/version.py index 8698266..0c74c91 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.20.0-ap2.2a" +APP_VERSION = "0.20.1-ap2.2bc" DB_SCHEMA_VERSION = "025" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index 583b695..cbed10a 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -71,10 +71,10 @@ Legende: **Anlage** · **Struktur** · **Ist** · **Kontrolle** · **UI-Default* ```text Phase 0 DOC + Kurskorrektur (dieses Dokument) ← jetzt -Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ← NÄCHSTES CODE -Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) parallel möglich -Phase 3 AP2.2b A2 Linear End-to-End - AP2.2c A1 Reifegrad + AP2.0e +Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓ +Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring) +Phase 3 AP2.2b A2 Linear End-to-End ◐ + AP2.2c A1 Reifegrad + AP2.0e ◐ Code AP2.2d B2b Product + B3 Sprint AP2.2e B2a Programm (optional vor AP2.1) Phase 4 AP1.7b Operational API — Pflege-Parität diff --git a/frontend/package.json b/frontend/package.json index 5055ae1..091a44c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.20.0-ap2.2a", + "version": "0.20.1-ap2.2bc", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/actors.js b/frontend/src/api/actors.js index 7b18f1b..78d7e78 100644 --- a/frontend/src/api/actors.js +++ b/frontend/src/api/actors.js @@ -16,6 +16,13 @@ export function getActor(id) { return apiFetch(`/api/actors/${id}`) } +export function createActor(body) { + return apiFetch('/api/actors', { + method: 'POST', + body: JSON.stringify(body), + }) +} + /** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */ export function actorsFromContext(context) { if (!context?.actor?.id) return [] diff --git a/frontend/src/components/ArchetypeSteeringHints.jsx b/frontend/src/components/ArchetypeSteeringHints.jsx new file mode 100644 index 0000000..26c9f61 --- /dev/null +++ b/frontend/src/components/ArchetypeSteeringHints.jsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { scopedPath } from '../utils/routes.js' + +const HINTS = { + 'initiative.maturity_journey': { + text: 'Reifegrad: Heutige Übung (Rhythmus) und aktive Stufe steuern den nächsten Schritt.', + link: { to: '/control/journey', label: 'Rhythmen & Journey' }, + }, + 'initiative.linear_project': { + text: 'Linear: Nächster Schritt am kritischen Pfad — Abhängigkeiten im Gate-Graph.', + link: { to: '/plan/gates', label: 'Gate-Graph öffnen' }, + }, + 'initiative.product': { + text: 'Product: Eingang triagieren, committete Actions — optional Sprint-Zeitbox.', + link: { to: '/plan/inbox', label: 'Zum Eingang' }, + }, + 'initiative.program': { + text: 'Programm: Gate-Horizont und Abschluss im Blick behalten.', + link: { to: '/plan/gates', label: 'Phasen / Gates' }, + }, +} + +export function ArchetypeSteeringHints({ archetypeKey }) { + const hint = HINTS[archetypeKey] + if (!hint) return null + + return ( +
+ {hint.text}{' '} + + {hint.link.label} + +
+ ) +} diff --git a/frontend/src/components/InitiativeForm.jsx b/frontend/src/components/InitiativeForm.jsx index 910b967..7fd0794 100644 --- a/frontend/src/components/InitiativeForm.jsx +++ b/frontend/src/components/InitiativeForm.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js' import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js' -import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js' +import { INITIATIVE_ARCHETYPE_LABELS, STUFE_A_ARCHETYPE_KEYS } from '../constants/initiativeArchetypes.js' import { listEntityArchetypes, getArchetypeStarterPreview } from '../api/entityArchetypes.js' import { getInitiativeFields, @@ -129,6 +129,8 @@ export function InitiativeForm({ const formReady = archetypes && !fieldsLoading const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey) + const stufeA = (archetypes || []).filter((item) => STUFE_A_ARCHETYPE_KEYS.has(item.key)) + const otherArchetypes = (archetypes || []).filter((item) => !STUFE_A_ARCHETYPE_KEYS.has(item.key)) return (