diff --git a/.cursor/rules/kairo-deployment-testing.mdc b/.cursor/rules/kairo-deployment-testing.mdc new file mode 100644 index 0000000..bdb92e3 --- /dev/null +++ b/.cursor/rules/kairo-deployment-testing.mdc @@ -0,0 +1,27 @@ +--- +description: Deployment und Tests — kein lokaler Lauf; Verifikation über develop auf Raspberry +alwaysApply: true +--- + +# Kairo — Deployment & Tests + +## Kein lokaler Test-/DB-Lauf + +Backend-Tests (pytest), Migrationen und Integrationstests **können auf der Entwicklungsmaschine in der Regel nicht ausgeführt werden** — PostgreSQL/Docker ist lokal oft nicht verfügbar. + +Versuche nicht wiederholt, lokale pytest-/Migration-Läufe als Abnahme zu erzwingen, wenn die DB nicht erreichbar ist. + +## Verifikation über Remote-Deploy + +Praktische Verifikation erfolgt durch **Deployment auf ein entferntes System (Raspberry Pi)**: + +1. Änderungen **committen** und auf Branch **`develop`** pushen +2. Deployment wird dadurch ausgelöst (CI/Deploy-Pipeline auf dem Raspberry) +3. Dort laufen Migrationen, Backend-Tests und Smoke-Checks gegen echte Infrastruktur + +## Konsequenz für Agenten + +- Code und Tests **schreiben** und dokumentieren — aber fehlende lokale DB nicht als Implementierungsblocker behandeln +- Frontend: `npm test` / `vite build` lokal weiterhin sinnvoll, wenn Node verfügbar +- Backend: nach AP-Umsetzung Abschlussbericht mit „Remote-Verifikation nach Push auf develop“ kennzeichnen +- **Nicht** proaktiv committen/pushen, es sei denn, der Nutzer bittet explizit darum diff --git a/README.md b/README.md index bbed8e1..23b389d 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,32 @@ Actor Directory und Workspace Data Layer (AP0.7): | `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) | | `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) | +Operating Model Extension I (AP0.8, Schema `007`): + +| Endpoint | Methode | Capability | Beschreibung | +|----------|---------|------------|--------------| +| `/api/workspace/attention` | GET | `kairo.attention.read` | Regelbasierte Attention-Items | +| `/api/workspace/next-actions` | GET | `kairo.attention.read` | NextActionCandidates (`?limit=`) | +| `/api/initiatives/{id}/blockers` | GET/POST | `kairo.blocker.read/manage` | Blocker eines Vorhabens | +| `/api/blockers/{id}` | GET/PATCH/DELETE | `kairo.blocker.read/manage` | Einzelner Blocker | +| `/api/initiatives/{id}/backlog` | GET/POST | `kairo.backlog.read/manage` | Backlog-Items | +| `/api/backlog/{id}` | GET/PATCH/DELETE | `kairo.backlog.read/manage` | Einzelnes Backlog-Item | +| `/api/backlog/{id}/convert-to-action` | POST | `kairo.backlog.manage` | Backlog → Maßnahme | +| `/api/initiatives/{id}/milestones` | GET/POST | `kairo.milestone.read/manage` | Meilensteine | +| `/api/milestones/{id}` | GET/PATCH/DELETE | `kairo.milestone.read/manage` | Einzelner Meilenstein | + +**Status Blocker:** `open`, `in_progress`, `resolved`, `accepted_risk`, `dismissed` + +**Status Backlog:** `new`, `triaged`, `accepted`, `rejected`, `converted` + +**Status Meilenstein:** `planned`, `active`, `at_risk`, `reached`, `moved`, `discarded` + +Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage). + +Zielarchitektur-Einordnung: AP0.8 ist Schritt 5 der Evolutionslinie (Signal/NextAction) und Operating-Model-Phase A — siehe `docs/architecture/Kairo_System_Target_State_v0.1.md` und `Sprint0_AP0_8_Assignment_v0.2.md`. + Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` + `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` **Status Vorhaben:** `active`, `paused`, `completed`, `archived` @@ -218,9 +243,9 @@ Nach Login leitet Kairo auf den **Workspace** weiter. | Route | Beschreibung | |-------|--------------| -| `/workspace` | Karten: Kontext, Überblick, offene/blockierte Maßnahmen, aktive Vorhaben | +| `/workspace` | Karten: Kontext, Überblick, **Aufmerksamkeit**, offene/blockierte Maßnahmen, aktive Vorhaben | | `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler | -| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung | +| `/initiatives/:id` | Vorhaben-Detail: Maßnahmen, **Meilensteine**, **Backlog**, **Blocker** | | `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors | **App Shell (AP0.6b):** Desktop-Sidebar (≥1024px), Mobile-Header + Bottom-Navigation (<1024px), Tenant-/Actor-Kontext, Jinkendo-Family-Design-Tokens (`--jk-*`). @@ -246,8 +271,9 @@ cd frontend && npm install && npm run test && npm run build 5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px 6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar 7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar +8. AP0.8: Attention-Widget auf Workspace; Vorhaben-Detail mit Blocker/Backlog/Meilenstein; Backlog → Maßnahme konvertieren -Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md` +Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md` ### Registries (AP0.4) diff --git a/backend/data_layer/attention.py b/backend/data_layer/attention.py new file mode 100644 index 0000000..c87d8fb --- /dev/null +++ b/backend/data_layer/attention.py @@ -0,0 +1,418 @@ +"""Attention and NextAction read-models — regelbasiert, tenant-scoped (AP0.8a).""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from tenant_context import TenantContext + +AttentionKind = Literal[ + "blocked_action", + "open_blocker", + "high_priority_action", + "unassigned_action", + "initiative_without_next_action", + "stale_initiative", + "milestone_at_risk", +] + +NextActionKind = Literal[ + "assign_action", + "resolve_blocker", + "create_action", + "convert_backlog", + "review_milestone", +] + +Severity = Literal["info", "warning", "critical"] + +_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2} + +OPEN_BLOCKER_STATUSES = ("open", "in_progress") +OPEN_ACTION_STATUSES = ("open", "in_progress", "blocked") +ACTIVE_INITIATIVE_STATUSES = ("active", "paused") + + +def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]: + item = dict(row) + for key in ("scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id"): + if item.get(key): + item[key] = str(item[key]) + return item + + +def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'blocked_action' AS kind, + 'critical' AS severity, + a.title AS title, + 'Maßnahme ist blockiert' AS summary, + 'action' AS scope_type, + a.id AS scope_id, + a.initiative_id, + a.id AS action_id, + NULL::uuid AS blocker_id, + NULL::uuid AS milestone_id, + 'action_blocked' AS reason_code, + 'actions' AS data_source + FROM actions a + WHERE a.tenant_id = %s AND a.status = 'blocked' + ORDER BY a.updated_at DESC + LIMIT 50 + """, + (ctx.tenant_id,), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + +def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'open_blocker' AS kind, + 'warning' AS severity, + b.title AS title, + 'Offener Blocker im Vorhaben' AS summary, + 'blocker' AS scope_type, + b.id AS scope_id, + b.initiative_id, + b.action_id, + b.id AS blocker_id, + NULL::uuid AS milestone_id, + 'blocker_open' AS reason_code, + 'blockers' AS data_source + FROM blockers b + WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress') + ORDER BY b.updated_at DESC + LIMIT 50 + """, + (ctx.tenant_id,), + ) + items = [] + for row in cur.fetchall(): + item = _serialize_attention(dict(row)) + if item.get("action_id"): + item["action_id"] = str(item["action_id"]) + items.append(item) + return items + + +def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: + actor_filter = "" + params: list[Any] = [ctx.tenant_id] + if ctx.actor_id: + actor_filter = """ + AND EXISTS ( + SELECT 1 FROM action_assignments aa + WHERE aa.action_id = a.id + AND aa.tenant_id = a.tenant_id + AND aa.actor_id = %s + ) + """ + params.append(ctx.actor_id) + else: + return [] + + cur.execute( + f""" + SELECT + 'high_priority_action' AS kind, + 'warning' AS severity, + a.title AS title, + 'High-Priority Maßnahme offen' AS summary, + 'action' AS scope_type, + a.id AS scope_id, + a.initiative_id, + a.id AS action_id, + NULL::uuid AS blocker_id, + NULL::uuid AS milestone_id, + 'action_high_priority_open' AS reason_code, + 'actions' AS data_source + FROM actions a + WHERE a.tenant_id = %s + AND a.priority = 'high' + AND a.status IN ('open', 'in_progress') + {actor_filter} + ORDER BY a.updated_at DESC + LIMIT 30 + """, + tuple(params), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + +def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'unassigned_action' AS kind, + 'warning' AS severity, + a.title AS title, + 'Maßnahme ohne Zuweisung' AS summary, + 'action' AS scope_type, + a.id AS scope_id, + a.initiative_id, + a.id AS action_id, + NULL::uuid AS blocker_id, + NULL::uuid AS milestone_id, + 'action_unassigned' AS reason_code, + 'actions' AS data_source + FROM actions a + WHERE a.tenant_id = %s + AND a.status IN ('open', 'in_progress') + AND NOT EXISTS ( + SELECT 1 FROM action_assignments aa + WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id + ) + ORDER BY a.updated_at DESC + LIMIT 30 + """, + (ctx.tenant_id,), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + +def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'initiative_without_next_action' AS kind, + 'info' AS severity, + i.title AS title, + 'Keine offene nächste Maßnahme' AS summary, + 'initiative' AS scope_type, + i.id AS scope_id, + i.id AS initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS blocker_id, + NULL::uuid AS milestone_id, + 'initiative_no_open_action' AS reason_code, + 'initiatives' AS data_source + FROM initiatives i + WHERE i.tenant_id = %s + AND i.status IN ('active', 'paused') + AND NOT EXISTS ( + SELECT 1 FROM actions a + WHERE a.initiative_id = i.id + AND a.tenant_id = i.tenant_id + AND a.status IN ('open', 'in_progress', 'blocked') + ) + ORDER BY i.updated_at DESC + LIMIT 30 + """, + (ctx.tenant_id,), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + +def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'stale_initiative' AS kind, + 'info' AS severity, + i.title AS title, + 'Vorhaben seit über 14 Tagen unverändert' AS summary, + 'initiative' AS scope_type, + i.id AS scope_id, + i.id AS initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS blocker_id, + NULL::uuid AS milestone_id, + 'initiative_stale' AS reason_code, + 'initiatives' AS data_source + FROM initiatives i + WHERE i.tenant_id = %s + AND i.status = 'active' + AND i.updated_at < NOW() - INTERVAL '14 days' + ORDER BY i.updated_at ASC + LIMIT 20 + """, + (ctx.tenant_id,), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + +def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'milestone_at_risk' AS kind, + 'warning' AS severity, + m.title AS title, + 'Meilenstein als gefährdet markiert' AS summary, + 'milestone' AS scope_type, + m.id AS scope_id, + m.initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS blocker_id, + m.id AS milestone_id, + 'milestone_at_risk' AS reason_code, + 'milestones' AS data_source + FROM milestones m + WHERE m.tenant_id = %s AND m.status = 'at_risk' + ORDER BY m.updated_at DESC + LIMIT 20 + """, + (ctx.tenant_id,), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + +def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]: + """Regelbasierte Attention Items — tenant-scoped, erklärbar.""" + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + items: list[dict[str, Any]] = [] + items.extend(_blocked_actions(cur, ctx)) + items.extend(_open_blockers(cur, ctx)) + items.extend(_high_priority_actions(cur, ctx)) + items.extend(_unassigned_actions(cur, ctx)) + items.extend(_initiatives_without_next_action(cur, ctx)) + items.extend(_stale_initiatives(cur, ctx)) + items.extend(_milestones_at_risk(cur, ctx)) + + items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99)) + return items + finally: + conn.close() + + +def get_next_action_candidates( + ctx: TenantContext, *, limit: int = 10 +) -> list[dict[str, Any]]: + """Regelbasierte NextActionCandidates — limitiert, tenant-scoped.""" + if limit < 1: + limit = 1 + if limit > 50: + limit = 50 + + conn = get_connection() + candidates: list[dict[str, Any]] = [] + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT + 'resolve_blocker' AS kind, + b.title AS title, + 'Blocker klären oder Status aktualisieren' AS summary, + b.initiative_id, + b.action_id, + NULL::uuid AS backlog_item_id, + 'blocker_open' AS reason_code, + 'Blocker bearbeiten' AS recommended_action + FROM blockers b + WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress') + ORDER BY b.updated_at DESC + LIMIT %s + """, + (ctx.tenant_id, limit), + ) + for row in cur.fetchall(): + item = dict(row) + item["initiative_id"] = str(item["initiative_id"]) + if item.get("action_id"): + item["action_id"] = str(item["action_id"]) + candidates.append(item) + + remaining = limit - len(candidates) + if remaining > 0: + cur.execute( + """ + SELECT + 'assign_action' AS kind, + a.title AS title, + 'Actor zuweisen' AS summary, + a.initiative_id, + a.id AS action_id, + NULL::uuid AS backlog_item_id, + 'action_unassigned' AS reason_code, + 'Maßnahme zuweisen' AS recommended_action + FROM actions a + WHERE a.tenant_id = %s + AND a.status IN ('open', 'in_progress') + AND NOT EXISTS ( + SELECT 1 FROM action_assignments aa + WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id + ) + ORDER BY a.updated_at DESC + LIMIT %s + """, + (ctx.tenant_id, remaining), + ) + for row in cur.fetchall(): + item = dict(row) + item["initiative_id"] = str(item["initiative_id"]) + item["action_id"] = str(item["action_id"]) + candidates.append(item) + + remaining = limit - len(candidates) + if remaining > 0: + cur.execute( + """ + SELECT + 'convert_backlog' AS kind, + bi.title AS title, + 'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary, + bi.initiative_id, + NULL::uuid AS action_id, + bi.id AS backlog_item_id, + 'backlog_accepted_not_converted' AS reason_code, + 'In Maßnahme umwandeln' AS recommended_action + FROM backlog_items bi + WHERE bi.tenant_id = %s + AND bi.status = 'accepted' + AND bi.converted_action_id IS NULL + ORDER BY bi.updated_at DESC + LIMIT %s + """, + (ctx.tenant_id, remaining), + ) + for row in cur.fetchall(): + item = dict(row) + item["initiative_id"] = str(item["initiative_id"]) + item["backlog_item_id"] = str(item["backlog_item_id"]) + candidates.append(item) + + remaining = limit - len(candidates) + if remaining > 0: + cur.execute( + """ + SELECT + 'create_action' AS kind, + i.title AS title, + 'Nächste Maßnahme für Vorhaben anlegen' AS summary, + i.id AS initiative_id, + NULL::uuid AS action_id, + NULL::uuid AS backlog_item_id, + 'initiative_no_open_action' AS reason_code, + 'Maßnahme anlegen' AS recommended_action + FROM initiatives i + WHERE i.tenant_id = %s + AND i.status IN ('active', 'paused') + AND NOT EXISTS ( + SELECT 1 FROM actions a + WHERE a.initiative_id = i.id + AND a.tenant_id = i.tenant_id + AND a.status IN ('open', 'in_progress', 'blocked') + ) + ORDER BY i.updated_at DESC + LIMIT %s + """, + (ctx.tenant_id, remaining), + ) + for row in cur.fetchall(): + item = dict(row) + item["initiative_id"] = str(item["initiative_id"]) + candidates.append(item) + + return candidates[:limit] + finally: + conn.close() diff --git a/backend/main.py b/backend/main.py index 9d2f036..8bc4db1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,7 +53,20 @@ app.add_middleware( allow_headers=["*"], ) -from routers import actions, actors, auth, config, features, initiatives, me, prompts, workspace # noqa: E402 +from routers import ( # noqa: E402 + actions, + actors, + auth, + backlog, + blockers, + config, + features, + initiatives, + me, + milestones, + prompts, + workspace, +) app.include_router(auth.router) app.include_router(me.router) @@ -62,6 +75,9 @@ app.include_router(prompts.router) app.include_router(config.router) app.include_router(initiatives.router) app.include_router(actions.router) +app.include_router(blockers.router) +app.include_router(backlog.router) +app.include_router(milestones.router) app.include_router(actors.router) app.include_router(workspace.router) diff --git a/backend/migrations/007_operating_model_extension_i.sql b/backend/migrations/007_operating_model_extension_i.sql new file mode 100644 index 0000000..a1f9ad7 --- /dev/null +++ b/backend/migrations/007_operating_model_extension_i.sql @@ -0,0 +1,52 @@ +-- AP0.8: Operating Model Extension I — blockers, backlog_items, milestones + +CREATE TABLE blockers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed')), + reported_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_blockers_tenant_initiative ON blockers(tenant_id, initiative_id); +CREATE INDEX idx_blockers_tenant_status ON blockers(tenant_id, status); + +CREATE TABLE backlog_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'triaged', 'accepted', 'rejected', 'converted')), + priority VARCHAR(16) NOT NULL DEFAULT 'normal' + CHECK (priority IN ('low', 'normal', 'high')), + converted_action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_backlog_items_tenant_initiative ON backlog_items(tenant_id, initiative_id); +CREATE INDEX idx_backlog_items_tenant_status ON backlog_items(tenant_id, status); + +CREATE TABLE milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + goal_description TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'planned' + CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')), + target_date DATE NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_milestones_tenant_initiative ON milestones(tenant_id, initiative_id); +CREATE INDEX idx_milestones_tenant_status ON milestones(tenant_id, status); diff --git a/backend/rights_registrations/__init__.py b/backend/rights_registrations/__init__.py index a8aca60..540dfa9 100644 --- a/backend/rights_registrations/__init__.py +++ b/backend/rights_registrations/__init__.py @@ -1,5 +1,19 @@ """Import all module registrations — side effect registers capabilities.""" -from . import initiative_ops, platform, registry_ops, tenant_ops, workspace_ops # noqa: F401 +from . import ( # noqa: F401 + initiative_ops, + operating_model_ops, + platform, + registry_ops, + tenant_ops, + workspace_ops, +) -__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops", "workspace_ops"] +__all__ = [ + "initiative_ops", + "operating_model_ops", + "platform", + "registry_ops", + "tenant_ops", + "workspace_ops", +] diff --git a/backend/rights_registrations/operating_model_ops.py b/backend/rights_registrations/operating_model_ops.py new file mode 100644 index 0000000..cffe738 --- /dev/null +++ b/backend/rights_registrations/operating_model_ops.py @@ -0,0 +1,83 @@ +"""Operating model capabilities (AP0.8).""" + +from __future__ import annotations + +from rights_registry import CapabilityRegistration, register_capability + +_MEMBER_READ = ( + ("portal", "admin"), + ("portal", "user"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), +) + +_MEMBER_MANAGE = ( + ("portal", "admin"), + ("tenant", "owner"), + ("tenant", "admin"), + ("tenant", "member"), +) + +register_capability( + CapabilityRegistration( + key="kairo.attention.read", + module="attention", + description="Attention- und NextAction-Sichten lesen", + default_grants=_MEMBER_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.blocker.read", + module="blocker", + description="Blocker im aktiven Tenant lesen", + default_grants=_MEMBER_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.blocker.manage", + module="blocker", + description="Blocker anlegen und bearbeiten", + default_grants=_MEMBER_MANAGE, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.backlog.read", + module="backlog", + description="Backlog-Items im aktiven Tenant lesen", + default_grants=_MEMBER_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.backlog.manage", + module="backlog", + description="Backlog-Items anlegen, bearbeiten und konvertieren", + default_grants=_MEMBER_MANAGE, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.milestone.read", + module="milestone", + description="Meilensteine im aktiven Tenant lesen", + default_grants=_MEMBER_READ, + ) +) + +register_capability( + CapabilityRegistration( + key="kairo.milestone.manage", + module="milestone", + description="Meilensteine anlegen und bearbeiten", + default_grants=_MEMBER_MANAGE, + ) +) diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py new file mode 100644 index 0000000..da9da27 --- /dev/null +++ b/backend/routers/backlog.py @@ -0,0 +1,103 @@ +"""BacklogItem API — AP0.8c.""" + +from __future__ import annotations + +from typing import Literal, Optional + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from services import backlog as backlog_service +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/backlog", tags=["backlog"]) + + +class BacklogCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["new", "triaged", "accepted", "rejected"] = "new" + priority: Literal["low", "normal", "high"] = "normal" + + +class BacklogUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + description: Optional[str] = None + status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None + priority: Optional[Literal["low", "normal", "high"]] = None + + +class BacklogConvertRequest(BaseModel): + assigned_actor_ids: list[str] = Field(default_factory=list) + + +@router.get("/{backlog_item_id}") +def get_backlog_item( + backlog_item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.backlog.read")), +): + item = backlog_service.get_backlog_item( + tenant_id=ctx.tenant_id, backlog_item_id=backlog_item_id + ) + if not item: + raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden") + return item + + +@router.patch("/{backlog_item_id}") +def update_backlog_item( + backlog_item_id: str, + body: BacklogUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")), +): + try: + item = backlog_service.update_backlog_item( + tenant_id=ctx.tenant_id, + backlog_item_id=backlog_item_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + priority=body.priority, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden") + return item + + +@router.delete("/{backlog_item_id}", status_code=204) +def delete_backlog_item( + backlog_item_id: str, + ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")), +): + if not backlog_service.delete_backlog_item( + tenant_id=ctx.tenant_id, + backlog_item_id=backlog_item_id, + user_id=ctx.user_id, + ): + raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden") + + +@router.post("/{backlog_item_id}/convert-to-action", status_code=201) +def convert_backlog_to_action( + backlog_item_id: str, + body: BacklogConvertRequest, + ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")), +): + assigned = body.assigned_actor_ids + if not assigned and ctx.actor_id: + assigned = [ctx.actor_id] + try: + return backlog_service.convert_backlog_to_action( + tenant_id=ctx.tenant_id, + backlog_item_id=backlog_item_id, + user_id=ctx.user_id, + assigned_actor_ids=assigned, + ) + except ValueError as exc: + detail = str(exc) + if detail == "Backlog-Item nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc diff --git a/backend/routers/blockers.py b/backend/routers/blockers.py new file mode 100644 index 0000000..fae1409 --- /dev/null +++ b/backend/routers/blockers.py @@ -0,0 +1,77 @@ +"""Blocker API — AP0.8b.""" + +from __future__ import annotations + +from typing import Literal, Optional + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from services import blockers as blocker_service +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/blockers", tags=["blockers"]) + + +class BlockerCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open" + action_id: Optional[str] = None + set_action_blocked: bool = False + + +class BlockerUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + description: Optional[str] = None + status: Optional[ + Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] + ] = None + action_id: Optional[str] = None + clear_action_id: bool = False + + +@router.get("/{blocker_id}") +def get_blocker( + blocker_id: str, + ctx: TenantContext = Depends(require_capability("kairo.blocker.read")), +): + item = blocker_service.get_blocker(tenant_id=ctx.tenant_id, blocker_id=blocker_id) + if not item: + raise HTTPException(status_code=404, detail="Blocker nicht gefunden") + return item + + +@router.patch("/{blocker_id}") +def update_blocker( + blocker_id: str, + body: BlockerUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")), +): + try: + item = blocker_service.update_blocker( + tenant_id=ctx.tenant_id, + blocker_id=blocker_id, + user_id=ctx.user_id, + title=body.title, + description=body.description, + status=body.status, + action_id=body.action_id, + clear_action_id=body.clear_action_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Blocker nicht gefunden") + return item + + +@router.delete("/{blocker_id}", status_code=204) +def delete_blocker( + blocker_id: str, + ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")), +): + if not blocker_service.delete_blocker( + tenant_id=ctx.tenant_id, blocker_id=blocker_id, user_id=ctx.user_id + ): + raise HTTPException(status_code=404, detail="Blocker nicht gefunden") diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index d2e4c99..7c444b6 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -8,7 +8,10 @@ from capabilities import require_capability from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from services import actions as action_service +from services import backlog as backlog_service +from services import blockers as blocker_service from services import initiatives as initiative_service +from services import milestones as milestone_service from tenant_context import TenantContext router = APIRouter(prefix="/api/initiatives", tags=["initiatives"]) @@ -38,6 +41,28 @@ class ActionCreateRequest(BaseModel): assigned_actor_ids: list[str] = Field(default_factory=list) +class BlockerCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open" + action_id: Optional[str] = None + set_action_blocked: bool = False + + +class BacklogCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + description: str = "" + status: Literal["new", "triaged", "accepted", "rejected"] = "new" + priority: Literal["low", "normal", "high"] = "normal" + + +class MilestoneCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + goal_description: str = "" + status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned" + target_date: Optional[str] = None + + @router.get("") def list_initiatives( ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), @@ -153,3 +178,127 @@ def create_initiative_action( if detail == "Initiative nicht gefunden": raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc + + +@router.get("/{initiative_id}/blockers") +def list_initiative_blockers( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.blocker.read")), +): + try: + return blocker_service.list_blockers_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + if str(exc) == "Initiative nicht gefunden": + raise HTTPException(status_code=404, detail=str(exc)) from exc + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{initiative_id}/blockers", status_code=201) +def create_initiative_blocker( + initiative_id: str, + body: BlockerCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")), +): + try: + return blocker_service.create_blocker( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + title=body.title, + description=body.description, + status=body.status, + action_id=body.action_id, + reported_by_actor_id=ctx.actor_id, + user_id=ctx.user_id, + set_action_blocked=body.set_action_blocked, + ) + 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 + + +@router.get("/{initiative_id}/backlog") +def list_initiative_backlog( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.backlog.read")), +): + try: + return backlog_service.list_backlog_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + if str(exc) == "Initiative nicht gefunden": + raise HTTPException(status_code=404, detail=str(exc)) from exc + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{initiative_id}/backlog", status_code=201) +def create_initiative_backlog_item( + initiative_id: str, + body: BacklogCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")), +): + try: + return backlog_service.create_backlog_item( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + title=body.title, + description=body.description, + status=body.status, + priority=body.priority, + user_id=ctx.user_id, + ) + 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 + + +@router.get("/{initiative_id}/milestones") +def list_initiative_milestones( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + return milestone_service.list_milestones_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + if str(exc) == "Initiative nicht gefunden": + raise HTTPException(status_code=404, detail=str(exc)) from exc + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{initiative_id}/milestones", status_code=201) +def create_initiative_milestone( + initiative_id: str, + body: MilestoneCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + from datetime import date as date_type + + target_date = None + if body.target_date: + try: + target_date = date_type.fromisoformat(body.target_date) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Ungültiges target_date") from exc + try: + return milestone_service.create_milestone( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + title=body.title, + goal_description=body.goal_description, + status=body.status, + target_date=target_date, + user_id=ctx.user_id, + ) + 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 diff --git a/backend/routers/milestones.py b/backend/routers/milestones.py new file mode 100644 index 0000000..bdb9a93 --- /dev/null +++ b/backend/routers/milestones.py @@ -0,0 +1,77 @@ +"""Milestone API — AP0.8d.""" + +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 milestones as milestone_service +from tenant_context import TenantContext + +router = APIRouter(prefix="/api/milestones", tags=["milestones"]) + + +class MilestoneCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + goal_description: str = "" + status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned" + target_date: Optional[date] = None + + +class MilestoneUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255) + goal_description: Optional[str] = None + status: Optional[ + Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] + ] = None + target_date: Optional[date] = None + clear_target_date: bool = False + + +@router.get("/{milestone_id}") +def get_milestone( + milestone_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + item = milestone_service.get_milestone(tenant_id=ctx.tenant_id, milestone_id=milestone_id) + if not item: + raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden") + return item + + +@router.patch("/{milestone_id}") +def update_milestone( + milestone_id: str, + body: MilestoneUpdateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + item = milestone_service.update_milestone( + tenant_id=ctx.tenant_id, + milestone_id=milestone_id, + user_id=ctx.user_id, + title=body.title, + goal_description=body.goal_description, + status=body.status, + target_date=body.target_date, + clear_target_date=body.clear_target_date, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden") + return item + + +@router.delete("/{milestone_id}", status_code=204) +def delete_milestone( + milestone_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + if not milestone_service.delete_milestone( + tenant_id=ctx.tenant_id, milestone_id=milestone_id, user_id=ctx.user_id + ): + raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden") diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py index 1b18754..6ab1a24 100644 --- a/backend/routers/workspace.py +++ b/backend/routers/workspace.py @@ -7,6 +7,7 @@ from typing import Optional from capabilities import require_capability from data_layer import actions as dl_actions from data_layer import actors as dl_actors +from data_layer import attention as dl_attention from data_layer import initiatives as dl_initiatives from data_layer import workspace as dl_workspace from fastapi import APIRouter, Depends, HTTPException, Query @@ -61,3 +62,18 @@ def workspace_actor_workload( ctx: TenantContext = Depends(require_capability("kairo.workspace.read")), ): return dl_actors.get_actor_workload(ctx) + + +@router.get("/attention") +def workspace_attention( + ctx: TenantContext = Depends(require_capability("kairo.attention.read")), +): + return dl_attention.get_attention_items(ctx) + + +@router.get("/next-actions") +def workspace_next_actions( + limit: Optional[int] = Query(default=10, ge=1, le=50), + ctx: TenantContext = Depends(require_capability("kairo.attention.read")), +): + return dl_attention.get_next_action_candidates(ctx, limit=limit or 10) diff --git a/backend/services/backlog.py b/backend/services/backlog.py new file mode 100644 index 0000000..c4e944e --- /dev/null +++ b/backend/services/backlog.py @@ -0,0 +1,296 @@ +"""BacklogItem service — tenant-scoped CRUD + convert (AP0.8c).""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.actions import create_action +from services.audit import log_audit +from services.initiatives import PRIORITIES, get_initiative + +BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] + +BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "initiative_id", "converted_action_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + if result.get("updated_at"): + result["updated_at"] = result["updated_at"].isoformat() + return result + + +def _validate_status(status: str) -> None: + if status not in BACKLOG_STATUSES: + raise ValueError(f"Ungültiger Backlog-Status: {status}") + + +def _validate_priority(priority: str) -> None: + if priority not in PRIORITIES: + raise ValueError(f"Ungültige Priorität: {priority}") + + +def create_backlog_item( + *, + tenant_id: str, + initiative_id: str, + title: str, + description: str = "", + status: BacklogStatus = "new", + priority: str = "normal", + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_status(status) + _validate_priority(priority) + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO backlog_items ( + tenant_id, initiative_id, title, description, status, priority + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, title, description, status, + priority, converted_action_id, created_at, updated_at + """, + (tenant_id, initiative_id, title, description, status, priority), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "backlog.created", + user_id=user_id, + tenant_id=tenant_id, + details={"backlog_item_id": row["id"], "initiative_id": initiative_id, "title": title}, + ) + return row + + +def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, title, description, status, + priority, converted_action_id, created_at, updated_at + FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC, title + """, + (tenant_id, initiative_id), + ) + return [_serialize_row(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, title, description, status, + priority, converted_action_id, created_at, updated_at + FROM backlog_items + WHERE id = %s AND tenant_id = %s + """, + (backlog_item_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def update_backlog_item( + *, + tenant_id: str, + backlog_item_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[BacklogStatus] = None, + priority: Optional[str] = None, +) -> Optional[dict[str, Any]]: + existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) + if not existing: + return None + if existing["status"] == "converted": + raise ValueError("Konvertiertes Backlog-Item kann nicht bearbeitet werden") + + old_status = existing["status"] + updates: list[str] = [] + params: list[Any] = [] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if description is not None: + updates.append("description = %s") + params.append(description) + if status is not None: + _validate_status(status) + updates.append("status = %s") + params.append(status) + if priority is not None: + _validate_priority(priority) + updates.append("priority = %s") + params.append(priority) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([backlog_item_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE backlog_items + SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, initiative_id, title, description, status, + priority, converted_action_id, created_at, updated_at + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "backlog.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"backlog_item_id": backlog_item_id}, + ) + if status is not None and status != old_status: + log_audit( + "backlog.status_changed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "backlog_item_id": backlog_item_id, + "from_status": old_status, + "to_status": status, + }, + ) + return result + + +def delete_backlog_item( + *, + tenant_id: str, + backlog_item_id: str, + user_id: Optional[str] = None, +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id", + (backlog_item_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "backlog.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"backlog_item_id": backlog_item_id}, + ) + return deleted + + +def convert_backlog_to_action( + *, + tenant_id: str, + backlog_item_id: str, + user_id: Optional[str] = None, + assigned_actor_ids: Optional[list[str]] = None, +) -> dict[str, Any]: + existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) + if not existing: + raise ValueError("Backlog-Item nicht gefunden") + if existing["status"] == "converted": + raise ValueError("Backlog-Item wurde bereits konvertiert") + if existing["status"] not in ("accepted", "triaged", "new"): + raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden") + + action = create_action( + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + title=existing["title"], + description=existing["description"] or "", + priority=existing["priority"], + assigned_actor_ids=assigned_actor_ids or [], + user_id=user_id, + ) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + UPDATE backlog_items + SET status = 'converted', + converted_action_id = %s, + updated_at = NOW() + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, initiative_id, title, description, status, + priority, converted_action_id, created_at, updated_at + """, + (action["id"], backlog_item_id, tenant_id), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "backlog.converted_to_action", + user_id=user_id, + tenant_id=tenant_id, + details={ + "backlog_item_id": backlog_item_id, + "action_id": action["id"], + "initiative_id": existing["initiative_id"], + }, + ) + return {"backlog_item": row, "action": action} diff --git a/backend/services/blockers.py b/backend/services/blockers.py new file mode 100644 index 0000000..20c67fb --- /dev/null +++ b/backend/services/blockers.py @@ -0,0 +1,295 @@ +"""Blocker service — tenant-scoped CRUD (AP0.8b).""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative + +BlockerStatus = Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] + +BLOCKER_STATUSES = frozenset( + {"open", "in_progress", "resolved", "accepted_risk", "dismissed"} +) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "initiative_id", "action_id", "reported_by_actor_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + if result.get("updated_at"): + result["updated_at"] = result["updated_at"].isoformat() + return result + + +def _validate_status(status: str) -> None: + if status not in BLOCKER_STATUSES: + raise ValueError(f"Ungültiger Blocker-Status: {status}") + + +def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE", + (actor_id, tenant_id), + ) + return cur.fetchone() is not None + finally: + conn.close() + + +def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT 1 FROM actions + WHERE id = %s AND tenant_id = %s AND initiative_id = %s + """, + (action_id, tenant_id, initiative_id), + ) + return cur.fetchone() is not None + finally: + conn.close() + + +def create_blocker( + *, + tenant_id: str, + initiative_id: str, + title: str, + description: str = "", + status: BlockerStatus = "open", + action_id: Optional[str] = None, + reported_by_actor_id: Optional[str] = None, + user_id: Optional[str] = None, + set_action_blocked: bool = False, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_status(status) + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + if action_id and not _action_in_initiative( + tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id + ): + raise ValueError("Maßnahme gehört nicht zum Vorhaben") + if reported_by_actor_id and not _actor_in_tenant( + tenant_id=tenant_id, actor_id=reported_by_actor_id + ): + raise ValueError("Reporter-Actor gehört nicht zum Tenant") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO blockers ( + tenant_id, initiative_id, action_id, title, description, + status, reported_by_actor_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, action_id, title, description, + status, reported_by_actor_id, created_at, updated_at + """, + ( + tenant_id, + initiative_id, + action_id, + title, + description, + status, + reported_by_actor_id, + ), + ) + row = _serialize_row(dict(cur.fetchone())) + if set_action_blocked and action_id: + cur.execute( + """ + UPDATE actions SET status = 'blocked', updated_at = NOW() + WHERE id = %s AND tenant_id = %s + """, + (action_id, tenant_id), + ) + conn.commit() + finally: + conn.close() + + log_audit( + "blocker.created", + user_id=user_id, + tenant_id=tenant_id, + details={"blocker_id": row["id"], "initiative_id": initiative_id, "title": title}, + ) + return row + + +def list_blockers_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, action_id, title, description, + status, reported_by_actor_id, created_at, updated_at + FROM blockers + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC, title + """, + (tenant_id, initiative_id), + ) + return [_serialize_row(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_blocker(*, tenant_id: str, blocker_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, action_id, title, description, + status, reported_by_actor_id, created_at, updated_at + FROM blockers + WHERE id = %s AND tenant_id = %s + """, + (blocker_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def update_blocker( + *, + tenant_id: str, + blocker_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + status: Optional[BlockerStatus] = None, + action_id: Optional[str] = None, + clear_action_id: bool = False, +) -> Optional[dict[str, Any]]: + existing = get_blocker(tenant_id=tenant_id, blocker_id=blocker_id) + if not existing: + return None + + old_status = existing["status"] + updates: list[str] = [] + params: list[Any] = [] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if description is not None: + updates.append("description = %s") + params.append(description) + if status is not None: + _validate_status(status) + updates.append("status = %s") + params.append(status) + if clear_action_id: + updates.append("action_id = NULL") + elif action_id is not None: + if not _action_in_initiative( + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + action_id=action_id, + ): + raise ValueError("Maßnahme gehört nicht zum Vorhaben") + updates.append("action_id = %s") + params.append(action_id) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([blocker_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE blockers + SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, initiative_id, action_id, title, description, + status, reported_by_actor_id, created_at, updated_at + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "blocker.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"blocker_id": blocker_id}, + ) + if status is not None and status != old_status: + log_audit( + "blocker.status_changed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "blocker_id": blocker_id, + "from_status": old_status, + "to_status": status, + }, + ) + return result + + +def delete_blocker( + *, + tenant_id: str, + blocker_id: str, + user_id: Optional[str] = None, +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM blockers WHERE id = %s AND tenant_id = %s RETURNING id", + (blocker_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "blocker.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"blocker_id": blocker_id}, + ) + return deleted diff --git a/backend/services/milestones.py b/backend/services/milestones.py new file mode 100644 index 0000000..dc4d553 --- /dev/null +++ b/backend/services/milestones.py @@ -0,0 +1,242 @@ +"""Milestone service — tenant-scoped CRUD (AP0.8d).""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative + +MilestoneStatus = Literal[ + "planned", "active", "at_risk", "reached", "moved", "discarded" +] + +MILESTONE_STATUSES = frozenset( + {"planned", "active", "at_risk", "reached", "moved", "discarded"} +) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + result = dict(row) + for key in ("id", "tenant_id", "initiative_id"): + if result.get(key): + result[key] = str(result[key]) + if result.get("target_date"): + result["target_date"] = ( + result["target_date"].isoformat() + if hasattr(result["target_date"], "isoformat") + else str(result["target_date"]) + ) + if result.get("created_at"): + result["created_at"] = result["created_at"].isoformat() + if result.get("updated_at"): + result["updated_at"] = result["updated_at"].isoformat() + return result + + +def _validate_status(status: str) -> None: + if status not in MILESTONE_STATUSES: + raise ValueError(f"Ungültiger Meilenstein-Status: {status}") + + +def create_milestone( + *, + tenant_id: str, + initiative_id: str, + title: str, + goal_description: str = "", + status: MilestoneStatus = "planned", + target_date: Optional[date] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + _validate_status(status) + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO milestones ( + tenant_id, initiative_id, title, goal_description, status, target_date + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, title, goal_description, status, + target_date, created_at, updated_at + """, + (tenant_id, initiative_id, title, goal_description, status, target_date), + ) + row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + log_audit( + "milestone.created", + user_id=user_id, + tenant_id=tenant_id, + details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title}, + ) + return row + + +def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, title, goal_description, status, + target_date, created_at, updated_at + FROM milestones + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY target_date NULLS LAST, updated_at DESC, title + """, + (tenant_id, initiative_id), + ) + return [_serialize_row(dict(r)) for r in cur.fetchall()] + finally: + conn.close() + + +def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, tenant_id, initiative_id, title, goal_description, status, + target_date, created_at, updated_at + FROM milestones + WHERE id = %s AND tenant_id = %s + """, + (milestone_id, tenant_id), + ) + row = cur.fetchone() + return _serialize_row(dict(row)) if row else None + finally: + conn.close() + + +def update_milestone( + *, + tenant_id: str, + milestone_id: str, + user_id: Optional[str] = None, + title: Optional[str] = None, + goal_description: Optional[str] = None, + status: Optional[MilestoneStatus] = None, + target_date: Optional[date] = None, + clear_target_date: bool = False, +) -> Optional[dict[str, Any]]: + existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id) + if not existing: + return None + + old_status = existing["status"] + updates: list[str] = [] + params: list[Any] = [] + + if title is not None: + title = title.strip() + if not title: + raise ValueError("Titel ist erforderlich") + updates.append("title = %s") + params.append(title) + if goal_description is not None: + updates.append("goal_description = %s") + params.append(goal_description) + if status is not None: + _validate_status(status) + updates.append("status = %s") + params.append(status) + if clear_target_date: + updates.append("target_date = NULL") + elif target_date is not None: + updates.append("target_date = %s") + params.append(target_date) + + if not updates: + return existing + + updates.append("updated_at = NOW()") + params.extend([milestone_id, tenant_id]) + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + f""" + UPDATE milestones + SET {", ".join(updates)} + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, initiative_id, title, goal_description, status, + target_date, created_at, updated_at + """, + params, + ) + row = cur.fetchone() + if not row: + return None + result = _serialize_row(dict(row)) + conn.commit() + finally: + conn.close() + + log_audit( + "milestone.updated", + user_id=user_id, + tenant_id=tenant_id, + details={"milestone_id": milestone_id}, + ) + if status is not None and status != old_status: + log_audit( + "milestone.status_changed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "milestone_id": milestone_id, + "from_status": old_status, + "to_status": status, + }, + ) + return result + + +def delete_milestone( + *, + tenant_id: str, + milestone_id: str, + user_id: Optional[str] = None, +) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id", + (milestone_id, tenant_id), + ) + deleted = cur.fetchone() is not None + conn.commit() + finally: + conn.close() + + if deleted: + log_audit( + "milestone.deleted", + user_id=user_id, + tenant_id=tenant_id, + details={"milestone_id": milestone_id}, + ) + return deleted diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 197d367..a6be23c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,8 @@ -"""Shared pytest fixtures for AP0.2 auth tests.""" +"""Shared pytest fixtures — Integrationstests benötigen PostgreSQL. + +Lokal typischerweise nicht verfügbar: Tests laufen nach Push auf develop/main +im Backend-Container auf dem Pi (Gitea workflow test.yml). +""" from __future__ import annotations @@ -23,6 +27,7 @@ def _db_available() -> bool: dbname=os.getenv("DB_NAME", "kairo_dev"), user=os.getenv("DB_USER", "kairo_dev"), password=os.getenv("DB_PASSWORD", "dev_password"), + connect_timeout=3, ) conn.close() return True @@ -30,7 +35,18 @@ def _db_available() -> bool: return False -pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar") +_REMOTE_HINT = ( + "PostgreSQL lokal nicht erreichbar. " + "Backend-Integrationstests laufen nach Push auf develop/main " + "im deployten Backend-Container (Gitea: test.yml auf dem Pi)." +) + + +@pytest.fixture(scope="session", autouse=True) +def _require_postgresql(): + """Session-weiter Guard — verhindert Migrations-Setup ohne DB (keine ERROR-Spirale).""" + if not _db_available(): + pytest.skip(_REMOTE_HINT) @pytest.fixture(scope="session", autouse=True) diff --git a/backend/tests/test_ap08_operating_model.py b/backend/tests/test_ap08_operating_model.py new file mode 100644 index 0000000..160eb86 --- /dev/null +++ b/backend/tests/test_ap08_operating_model.py @@ -0,0 +1,279 @@ +"""AP0.8 — Operating Model Extension I tests. + +Ausführung: im Backend-Container auf dem Pi nach Deploy (develop/main), +siehe .gitea/workflows/test.yml und docs/DEPLOYMENT.md. +""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import ( + _auth, + _create_action, + _create_initiative, + _login, +) + + +def _create_blocker(client, token, initiative_id, **kwargs): + body = {"title": "Blocker Test", **kwargs} + return client.post( + f"/api/initiatives/{initiative_id}/blockers", + json=body, + headers=_auth(token), + ) + + +def _create_backlog(client, token, initiative_id, **kwargs): + body = {"title": "Backlog Test", **kwargs} + return client.post( + f"/api/initiatives/{initiative_id}/backlog", + json=body, + headers=_auth(token), + ) + + +def _create_milestone(client, token, initiative_id, **kwargs): + body = {"title": "Milestone Test", **kwargs} + return client.post( + f"/api/initiatives/{initiative_id}/milestones", + json=body, + headers=_auth(token), + ) + + +def test_attention_blocked_action(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action( + client, token, initiative_id, assigned_actor_ids=[user["actor_id"]] + ).json() + client.patch( + f"/api/actions/{action['id']}", + json={"status": "blocked"}, + headers=_auth(token), + ) + + res = client.get("/api/workspace/attention", headers=_auth(token)) + assert res.status_code == 200 + items = res.json() + assert any(i["kind"] == "blocked_action" for i in items) + blocked = next(i for i in items if i["kind"] == "blocked_action") + assert blocked["reason_code"] == "action_blocked" + assert blocked["data_source"] == "actions" + assert blocked["scope_type"] == "action" + + +def test_attention_unassigned_action(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + _create_action(client, token, initiative_id, title="Unassigned") + + res = client.get("/api/workspace/attention", headers=_auth(token)) + assert res.status_code == 200 + assert any(i["kind"] == "unassigned_action" for i in res.json()) + + +def test_attention_initiative_without_next_action(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + _create_initiative(client, token, title="Empty Initiative") + + res = client.get("/api/workspace/attention", headers=_auth(token)) + assert res.status_code == 200 + assert any(i["kind"] == "initiative_without_next_action" for i in res.json()) + + +def test_attention_open_blocker(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + _create_blocker(client, token, initiative_id, title="Dependency missing") + + res = client.get("/api/workspace/attention", headers=_auth(token)) + assert res.status_code == 200 + assert any(i["kind"] == "open_blocker" for i in res.json()) + + +def test_attention_milestone_at_risk(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + ms = _create_milestone(client, token, initiative_id).json() + client.patch( + f"/api/milestones/{ms['id']}", + json={"status": "at_risk"}, + headers=_auth(token), + ) + + res = client.get("/api/workspace/attention", headers=_auth(token)) + assert any(i["kind"] == "milestone_at_risk" for i in res.json()) + + +def test_attention_severity_sorting(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action(client, token, initiative_id).json() + client.patch( + f"/api/actions/{action['id']}", + json={"status": "blocked"}, + headers=_auth(token), + ) + _create_blocker(client, token, initiative_id) + + items = client.get("/api/workspace/attention", headers=_auth(token)).json() + severities = [i["severity"] for i in items] + order = {"critical": 0, "warning": 1, "info": 2} + assert severities == sorted(severities, key=lambda s: order.get(s, 99)) + + +def test_next_action_convert_backlog(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + item = _create_backlog( + client, token, initiative_id, status="accepted", title="Convert me" + ).json() + + res = client.get("/api/workspace/next-actions?limit=10", headers=_auth(token)) + assert res.status_code == 200 + assert any( + c["kind"] == "convert_backlog" and c["backlog_item_id"] == item["id"] + for c in res.json() + ) + + +def test_next_action_limit(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + for i in range(5): + _create_backlog( + client, token, initiative_id, status="accepted", title=f"Item {i}" + ) + + res = client.get("/api/workspace/next-actions?limit=2", headers=_auth(token)) + assert len(res.json()) <= 2 + + +def test_blocker_crud(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action(client, token, initiative_id).json() + + created = _create_blocker( + client, + token, + initiative_id, + title="API Block", + action_id=action["id"], + ) + assert created.status_code == 201 + blocker = created.json() + assert blocker["initiative_id"] == initiative_id + + listed = client.get( + f"/api/initiatives/{initiative_id}/blockers", headers=_auth(token) + ) + assert listed.status_code == 200 + assert any(b["id"] == blocker["id"] for b in listed.json()) + + updated = client.patch( + f"/api/blockers/{blocker['id']}", + json={"status": "resolved"}, + headers=_auth(token), + ) + assert updated.status_code == 200 + assert updated.json()["status"] == "resolved" + + deleted = client.delete(f"/api/blockers/{blocker['id']}", headers=_auth(token)) + assert deleted.status_code == 204 + + +def test_blocker_cross_tenant(client): + user_a = provision_user_in_tenant() + user_b = provision_user_in_tenant() + token_a = _login(client, user_a) + token_b = _login(client, user_b) + initiative_id = _create_initiative(client, token_a).json()["id"] + blocker = _create_blocker(client, token_a, initiative_id).json() + + assert ( + client.get(f"/api/blockers/{blocker['id']}", headers=_auth(token_b)).status_code + == 404 + ) + + +def test_backlog_convert_to_action(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + item = _create_backlog( + client, token, initiative_id, status="accepted", title="Feature X" + ).json() + + converted = client.post( + f"/api/backlog/{item['id']}/convert-to-action", + json={}, + headers=_auth(token), + ) + assert converted.status_code == 201 + body = converted.json() + assert body["backlog_item"]["status"] == "converted" + assert body["action"]["title"] == "Feature X" + assert body["backlog_item"]["converted_action_id"] == body["action"]["id"] + + +def test_backlog_crud(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + created = _create_backlog(client, token, initiative_id, status="triaged") + assert created.status_code == 201 + item = created.json() + + patched = client.patch( + f"/api/backlog/{item['id']}", + json={"status": "accepted"}, + headers=_auth(token), + ) + assert patched.status_code == 200 + + deleted = client.delete(f"/api/backlog/{item['id']}", headers=_auth(token)) + assert deleted.status_code == 204 + + +def test_milestone_crud(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + created = _create_milestone( + client, token, initiative_id, status="active", target_date="2026-12-31" + ) + assert created.status_code == 201 + ms = created.json() + assert ms["target_date"] == "2026-12-31" + + deleted = client.delete(f"/api/milestones/{ms['id']}", headers=_auth(token)) + assert deleted.status_code == 204 + + +def test_member_has_ap08_capabilities(client): + member = provision_user_in_tenant(tenant_role="member") + token = _login(client, member) + ctx = client.get("/api/me/context", headers=_auth(token)).json() + caps = ctx["capabilities"] + for key in ( + "kairo.attention.read", + "kairo.blocker.read", + "kairo.blocker.manage", + "kairo.backlog.read", + "kairo.backlog.manage", + "kairo.milestone.read", + "kairo.milestone.manage", + ): + assert key in caps diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index c5c352c..484fa3e 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -37,6 +37,7 @@ def test_migration_runner_finds_migrations(): assert "004_capabilities_registry" in names assert "005_prompt_feature_config_registry" in names assert "006_initiatives_actions" in names + assert "007_operating_model_extension_i" in names def test_migration_runner_is_idempotent(): @@ -52,6 +53,7 @@ def test_migration_runner_is_idempotent(): assert "004_capabilities_registry" in executed assert "005_prompt_feature_config_registry" in executed assert "006_initiatives_actions" in executed + assert "007_operating_model_extension_i" in executed def test_core_table_exists(): diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py index 038119c..3e55771 100644 --- a/backend/tests/test_rights_registry.py +++ b/backend/tests/test_rights_registry.py @@ -28,6 +28,13 @@ def test_registry_contains_initial_capabilities(): "kairo.action.read", "kairo.action.manage", "kairo.workspace.read", + "kairo.attention.read", + "kairo.blocker.read", + "kairo.blocker.manage", + "kairo.backlog.read", + "kairo.backlog.manage", + "kairo.milestone.read", + "kairo.milestone.manage", } @@ -39,7 +46,7 @@ def test_sync_is_idempotent(): try: with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM capabilities") - assert cur.fetchone()[0] == 18 + assert cur.fetchone()[0] == 25 cur.execute("SELECT COUNT(*) FROM role_capability_grants") assert cur.fetchone()[0] >= 5 finally: diff --git a/backend/version.py b/backend/version.py index ea240b6..77a465a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.7.0-ap0.7" -DB_SCHEMA_VERSION = "006" +APP_VERSION = "0.8.0-ap0.8" +DB_SCHEMA_VERSION = "007" APP_NAME = "jinkendo-kairo" diff --git a/docs/architecture/Kairo_System_Target_State_v0.1.md b/docs/architecture/Kairo_System_Target_State_v0.1.md new file mode 100644 index 0000000..38d71f1 --- /dev/null +++ b/docs/architecture/Kairo_System_Target_State_v0.1.md @@ -0,0 +1,808 @@ +# Kairo – System Target State +## v0.1 – Zielzustand des angedachten Gesamtsystems + +**Status:** konsolidiertes Zielbild / Nordstern-Dokument +**Stand:** 2026-07-05 +**Zweck:** Beschreibung des angedachten späteren Zielzustands von Kairo als System, unabhängig von konkreten Implementierungsschnitten. + +--- + +## 1. Executive Summary + +Kairo ist langfristig keine einfache Aufgabenliste, keine reine Projektmanagement-App, kein Roadmap-Tool und keine freie Workflow-Automatisierungsplattform. + +Kairo ist eine: + +```text +adaptive, methodengeführte Entwicklungs- und Steuerungsplattform +``` + +Der Kern ist ein: + +```text +Method-driven Adaptive Steering Core +``` + +Kairo soll unterschiedliche Entwicklungs- und Steuerungskontexte führen können: + +```text +- Software- und Produktentwicklung +- Buch- und Content-Entwicklung +- Programmsteuerung +- Projekt- und Initiativensteuerung +- persönliche Entwicklung +- Reifegradentwicklung +- Lernpfade +- Routinen / Betrieb +- Review- und Auditkontexte +- Agentenläufe +- KI-/Tool-gestützte Steuerung +``` + +Die zentrale Leitfrage lautet: + +```text +Welcher nächste Schritt bringt diesen steuerbaren Kontext jetzt am wirksamsten voran? +``` + +--- + +## 2. Grundverständnis + +Kairo steuert nicht nur Aufgaben. + +Kairo steuert Entwicklungen. + +Eine Entwicklung kann sein: + +```text +- ein Produkt +- ein Programm +- ein Projekt +- eine Initiative +- ein Buch +- eine Fähigkeit +- ein Reifegradziel +- eine Routine +- ein Review +- ein Audit +- ein Agentenauftrag +``` + +Kairo hält dabei fest: + +```text +- Was soll entwickelt werden? +- Warum ist es relevant? +- Welche Methode gilt? +- Welche Struktur braucht diese Methode? +- Wo steht der Kontext aktuell? +- Was fehlt? +- Was blockiert? +- Was braucht Aufmerksamkeit? +- Was ist der nächste wirksame Schritt? +- Wer oder welcher Agent soll handeln? +- Worauf wartet Kairo? +- Welche Nachweise, Reviews oder Entscheidungen sind nötig? +- Wann wird angepasst, eskaliert oder abgeschlossen? +``` + +--- + +## 3. Produktidentität + +Kairo ist: + +```text +System of Record +Steering Authority +Actor-/Tenant-sicherer Kontextgeber +methodengeführter Steuerungskern +Roadmap- und Entwicklungsstrukturmodell +Signal- und NextAction-System +später Workflow-/Agenten-/Tool-orchestrierend +``` + +Kairo ist nicht: + +```text +freie Workflow-Plattform +beliebiger Automatisierungsbaukasten +reines Projektmanagementsystem +reine To-do-Liste +reines Roadmap-Tool +reine KI-Agentenplattform +reines Prompt-System +``` + +--- + +## 4. Kernmodell + +Das Zielmodell: + +```text +Steerable Object + → Steering Context + → Steering Method + → Standard Lifecycle / Method Lifecycle + → Hook Slugs + → Structure Builder + → Signal Rules + → NextAction Strategy + → Assignment Strategy + → Waiting / Reminder / Escalation Strategy + → Result Intake + → Evidence / Review / Decision + → Adaptation / Closure + → optionale Workflow-Fragmente + → optionale Human / Agent / LLM / Tool Steps +``` + +--- + +## 5. Method-driven Adaptive Steering Core + +Der Adaptive Steering Core ist der fachliche und technische Kern von Kairo. + +Er führt steuerbare Kontexte durch methodengeführte Steuerungsabläufe. + +Typischer Ablauf: + +```text +1. Ziel / Entwicklungsabsicht erfassen +2. Name, Scope und Kontext definieren +3. passende Basismethode wählen oder vorschlagen +4. gemäß Methode eine Steuerungsstruktur erzeugen + - Roadmap + - Backlog + - WBS + - Reifegradpfad + - Kapitelstruktur + - Feature Landscape + - WorkCycles + - Review Gates +5. Next Best Action ableiten +6. Actor zuweisen +7. auf Ergebnis, Rückmeldung, Termin oder Ereignis warten +8. erinnern, eskalieren oder nachsteuern +9. Ergebnis zurückführen +10. prüfen, reviewen, adaptieren oder abschließen +``` + +Dieser Ablauf ist der methodengeführte Steuerungsworkflow. + +Er ist nicht gleichbedeutend mit einer freien technischen Workflow Engine. + +--- + +## 6. Standard Lifecycle + +Kairo besitzt einen minimalen Standard Lifecycle als gemeinsame Basis. + +```text +intake +method_selection +structure_setup +planning +action_selection +assignment +waiting +result_intake +validation +review +adaptation +closure +``` + +Methoden können diesen Lifecycle: + +```text +- verwenden +- teilweise überspringen +- spezialisieren +- erweitern +- eigene Hooks ergänzen +``` + +Der Standard Lifecycle ist kein starrer Projektprozess, sondern ein gemeinsames Steuerungsraster. + +--- + +## 7. Steering Methods + +Eine Methode ist kein Label, sondern ein registriertes Steuerungspaket. + +Eine Methode definiert: + +```text +- supported_domains +- supported_scope_types +- default_lifecycle +- required_structures +- allowed_structures +- hook_slugs +- structure_builders +- attention_rule_providers +- next_action_strategy +- assignment_strategy +- waiting_strategy +- reminder_strategy +- escalation_strategy +- result_intake_strategy +- review_strategy +- evidence_policy +- closure_policy +- allowed_workflow_fragments +- allowed_node_types +- default_configuration +``` + +Methoden werden zunächst built-in und code-first angelegt, später versioniert und begrenzt konfigurierbar. + +Langfristig können Method Profiles entstehen. + +--- + +## 8. Method Registry + +Kairo benötigt eine Method Registry. + +Sie funktioniert ähnlich einem Widget-System: + +```text +Eine Methode meldet sich mit Slug, Version, unterstützten Domains, +Lifecycle, Hooks, Structure Builders, Strategies und Policies an. +``` + +Ziel: + +```text +Neue Methoden sollen ergänzt werden können, +ohne den Core jedes Mal umzubauen. +``` + +Beispiele für Methoden: + +```text +simple_action_driven +product_milestone_driven +backlog_driven +wbs_driven +cycle_based +content_chapter_based +maturity_progression +routine_control +review_driven +audit_driven +agent_controlled +``` + +--- + +## 9. Steering Domains + +Kairo unterstützt unterschiedliche Steuerungsdomänen. + +Beispiele: + +```text +program +project +product_development +content_development +personal_development +maturity_development +routine_operations +audit_review +agent_execution +custom +``` + +Jede Domain kann eigene typische Lifecycles, Roadmap-Strukturen, Methoden, Hooks und Signals haben. + +--- + +## 10. Steerable Objects + +Steuerbar sind nicht nur Initiativen. + +Mögliche steuerbare Objekte: + +```text +program +initiative +project +roadmap +roadmap_item +backlog_item +action +blocker +review +evidence +decision +recurring_element +work_cycle +agent_run +custom +``` + +Diese Objekte werden über Steering Contexts an Methoden und Domains gebunden. + +--- + +## 11. Steering Context + +Ein Steering Context beschreibt: + +```text +Für welches steuerbare Objekt gilt welche Methode, Domain und Lifecycle-Logik? +``` + +Konzeptuell: + +```text +SteeringContext + - tenant_id + - scope_type + - scope_id + - steering_domain + - lifecycle_model + - steering_method + - method_profile_id optional + - method_version + - configuration_json + - inherits_from_parent + - is_active +``` + +Untergeordnete Objekte können den Steering Context ihres Parents erben oder überschreiben. + +--- + +## 12. Roadmap / Development Model + +Roadmap ist ein Kernmodell, nicht nur eine UI-Ansicht. + +Kairo braucht Roadmaps, um Entwicklungsrichtung und Entwicklungsstruktur abzubilden. + +Roadmap-Struktur: + +```text +Roadmap + → Roadmap Lane + → Roadmap Item +``` + +RoadmapItems können sein: + +```text +milestone +maturity_stage +feature +capability +phase +release +chapter +research_topic +work_cycle +learning_step +review_gate +decision_gate +architecture_step +dependency +risk_reduction +custom +``` + +Entscheidung: + +```text +Milestone = RoadmapItem(type = milestone) +``` + +--- + +## 13. Roadmap vs. Backlog vs. Action + +Zentrale Unterscheidung: + +```text +RoadmapItem = Entwicklungsziel / Orientierung / geplanter Entwicklungsschritt +BacklogItem = konkreter möglicher Handlungsbedarf +Action = freigegebene operative Maßnahme +``` + +Beispiel Softwareentwicklung: + +```text +RoadmapItem: +Attention Engine regelbasiert + +BacklogItems: +- Attention DTO definieren +- Blockierte Maßnahmen auswerten +- Widget bauen + +Actions: +- Endpoint implementieren +- Tests schreiben +``` + +Beispiel Buchentwicklung: + +```text +RoadmapItem: +Kapitel 3 – Argumentation ausarbeiten + +BacklogItems: +- Recherche zur These prüfen +- Beispiel suchen +- Rohfassung schreiben + +Actions: +- Heute Abschnitt 3.1 entwerfen +``` + +Beispiel Reifegradentwicklung: + +```text +RoadmapItem: +Reifegrad: stabile Seitkick-Beweglichkeit + +BacklogItems: +- Hüftmobilität prüfen +- Dehnroutine auswählen +- Technikvideo aufnehmen + +Actions: +- 10 Minuten Mobility durchführen +``` + +--- + +## 14. Structure Builder + +Structure Builder erzeugen methodenspezifische Steuerungsstrukturen. + +Beispiele: + +```text +roadmap_builder +milestone_plan_builder +feature_landscape_builder +wbs_builder +backlog_builder +maturity_path_builder +chapter_structure_builder +cycle_structure_builder +review_gate_builder +routine_structure_builder +``` + +Sie sind zentrale Erweiterungspunkte für Methoden. + +Ein Structure Builder legt z. B. an: + +```text +- Roadmap +- Roadmap Lanes +- Roadmap Items +- initiale BacklogItems +- Review Gates +- WorkCycles +``` + +--- + +## 15. Hooks + +Hooks sind stabile Einhängepunkte in Steuerungsabläufen. + +Beispiele: + +```text +on_goal_captured +on_scope_named +on_method_selection_required +on_method_selected +on_structure_required +on_roadmap_required +on_backlog_required +on_wbs_required +on_maturity_path_required +on_content_structure_required +on_plan_required +on_dependency_analysis_required +on_dod_definition_required +on_next_action_requested +on_assignment_required +on_wait_started +on_due_date_reached +on_result_overdue +on_reminder_required +on_escalation_required +on_result_received +on_evidence_required +on_review_due +on_reassessment_required +on_replan_required +on_closure_requested +``` + +An Hooks können später kleine Workflow-Fragmente, Rules, Prompts, Agent Steps oder Tool Steps gebunden werden. + +Hooks sind nicht beliebige technische Events, sondern fachlich stabile Steuerungspunkte. + +--- + +## 16. Strategies + +Methoden bringen oder referenzieren Strategies. + +Zentrale Strategy-Typen: + +```text +NextActionStrategy +AttentionRuleProvider +AssignmentStrategy +WaitingStrategy +ReminderStrategy +EscalationStrategy +ResultIntakeStrategy +EvidencePolicy +ReviewStrategy +ClosurePolicy +``` + +Diese verhindern, dass methodenspezifische Logik monolithisch im Core wächst. + +--- + +## 17. Signals + +Signals sind sichtbare Ausgaben des Steuerungskerns. + +Signal Types: + +```text +attention +next_action +review_due +evidence_required +escalation +blocker_signal +progress_signal +risk_signal +decision_required +dependency_signal +dod_signal +workflow_trigger +``` + +### Attention + +Beantwortet: + +```text +Was braucht Aufmerksamkeit? +``` + +### NextActionCandidate + +Beantwortet: + +```text +Was ist der nächste wirksame Schritt? +``` + +Signals müssen erklärbar sein: + +```text +- warum erzeugt? +- durch welche Methode? +- durch welchen Hook? +- auf welchen Scope bezogen? +- welche Datenquelle? +- welche empfohlene Handlung? +``` + +--- + +## 18. Assignment / Waiting / Reminder / Escalation + +Der Steuerungskern endet nicht mit dem Erzeugen einer Action. + +Kairo muss steuern: + +```text +- Actor auswählen +- Actor zuweisen +- auf Ergebnis warten +- Fälligkeit überwachen +- erinnern +- eskalieren +- externe Ereignisse aufnehmen +- Agentenergebnisse zurückführen +``` + +Actors können sein: + +```text +human +agent +working_group +external_system +``` + +--- + +## 19. Result Intake / Evidence / Review / Decision + +Kairo führt Ergebnisse zurück und entscheidet, was daraus folgt. + +Mögliche Rückführungen: + +```text +- Action erledigt +- Ergebnis unvollständig +- Evidence erforderlich +- Review erforderlich +- Decision erforderlich +- Blocker entstanden +- RoadmapItem erreicht +- Reifegrad plausibel erhöht +- neue NextAction nötig +``` + +Evidence, Reviews und Decisions sind zentrale Qualitätssicherungs- und Steuerungsobjekte. + +--- + +## 20. Workflow-Fragmente und Runtime + +Kairo soll langfristig Workflow-Fähigkeit haben. + +Aber: + +```text +Workflows sind methodengeführte Steuerungsabläufe, +keine beliebige freie Automatisierungsplattform als Produktkern. +``` + +Zielmodell: + +```text +Hook + → Workflow Fragment + → Node / Step + → Result + → Rückführung in Kairo +``` + +Workflow-Fragmente können später enthalten: + +```text +human_task +approval +decision_gate +review +reflection +llm_prompt +agent_task +tool_call +state_transition +evidence_check +dod_check +dependency_analysis +notification +reminder +escalation +``` + +--- + +## 21. Agenten / LLMs / Tools + +Kairo bleibt Steering Authority. + +Agenten, LLMs und Tools sind Provider. + +Sie können später an Hooks und Workflow-Fragmente angebunden werden. + +Grundsatz: + +```text +Kairo entscheidet Kontext, Methode, Auftrag und Rückführung. +Agenten / LLMs / Tools führen einzelne Steps aus. +``` + +Agenten sind Actors. + +Das ist wichtig für: + +```text +- Verantwortlichkeit +- Audit +- Assignments +- Sichtbarkeit +- Tenant-Kontext +``` + +--- + +## 22. Security / Governance / Audit + +Kairo muss tenant- und actor-sicher bleiben. + +Grundsätze: + +```text +- tenant_id immer aus TenantContext +- keine tenant_id aus Client Input vertrauen +- Actor-Bezüge tenant-intern validieren +- Capabilities prüfen +- Router dünn +- Services schreiben +- Data Layer liest +- Audit-by-design +- Signals und NextActions erklärbar +- Agentenaktionen nachvollziehbar +``` + +--- + +## 23. UI-Zielbild + +Die UI dient als Steuerungs- und Interaktionsschicht. + +Langfristige Views: + +```text +Workspace +Steering View +Roadmap View +Method View +Attention / NextAction View +Backlog View +Action View +Waiting / Reminder View +Evidence / Review / Decision View +Workflow Run View später +Method/Profile Admin später +Agent Run View später +``` + +Die UI darf nicht die Methode hart kodieren. + +Sie muss methodenbewusst und strukturgetrieben sein. + +--- + +## 24. Architektur-Evolutionslinie + +Grobe Evolutionslinie: + +```text +1. Existing Foundation +2. Adaptive Steering Core Foundation +3. Method Registry / Hook Registry / Standard Lifecycle +4. Roadmap / Structure Builder Foundation +5. Signal / NextAction Engine +6. Assignment / Waiting / Reminder / Escalation +7. Evidence / Review / Decision +8. Workflow Fragments and Bindings +9. Workflow Runtime +10. Agent / LLM / Tool Providers +11. Method Profiles +12. Method Designer / Advanced Configuration +13. Advanced AI Steering / Next Best Action Ranking +``` + +Dies ist keine Sprintplanung, sondern ein Zielbild der technischen Entwicklung. + +--- + +## 25. Zielzustand in einem Satz + +Kairo soll ein methodengeführter adaptiver Steuerungskern werden, der Entwicklungen unterschiedlichster Art über registrierbare Methoden, gemeinsame Kernstrukturen, Roadmaps, Hooks, Signals, NextActions, Assignments, Waiting-/Reminder-/Escalation-Mechanismen und später Agent-/Workflow-Fragmente wirksam und nachvollziehbar steuert. diff --git a/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md b/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md index ca6a713..02d9fc6 100644 --- a/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md +++ b/docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md @@ -1,7 +1,7 @@ # AP0.8 – Operating Model Extension I ## Implementierungsauftrag zur Prüfung v0.1 -**Status:** Entwurf zur Freigabe +**Status:** ersetzt durch `Sprint0_AP0_8_Assignment_v0.2.md` **Stand:** 2026-07-05 **Vorgänger:** AP0.7 ✓ · AP0.R1 ✓ **Zielversion:** `0.8.0-ap0.8` · Schema `007` diff --git a/docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md b/docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md new file mode 100644 index 0000000..d38976d --- /dev/null +++ b/docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md @@ -0,0 +1,402 @@ +# AP0.8 – Operating Model Extension I +## Implementierungsauftrag v0.2 (freigegeben) + +**Status:** freigegeben zur Umsetzung +**Stand:** 2026-07-05 +**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓ · KAIRO-ARCH-01 ✓ +**Zielversion:** `0.8.0-ap0.8` · Schema `007` +**Ersetzt:** `Sprint0_AP0_8_Assignment_v0.1.md` (Entwurf) + +--- + +## Einordnung + +AP0.8 ist der **erste fachliche Ausbau nach dem Product Reset** und der **erste Schritt der Operating-Model-Evolutionslinie** gemäß System Target State §24: + +```text +1. Existing Foundation ✓ AP0.1–AP0.7 +2. Adaptive Steering Core — nicht AP0.8 (→ AP1.x) +3. Method Registry / Hooks — nicht AP0.8 +4. Roadmap / Structure Builder — nicht AP0.8 (Milestone als MVP-Brücke) +5. Signal / NextAction Engine ← AP0.8a startet hier (regelbasiert, Data Layer) +6. Assignment / Waiting / … — teilweise vorhanden; Waiting → AP0.9+ +7. Evidence / Review / Decision — AP0.9 +``` + +Ziel: Kairo sichtbar über `Initiative → Action` hinausheben und die Leitfrage operationalisieren: + +> Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran? + +AP0.8 baut auf AP0.7 auf (Tenant-Invarianten, Actor Directory, Data Layer) und bleibt **regelbasiert ohne KI, ohne Method Registry, ohne SteeringContext**. + +--- + +## Verbindliche Referenzen + +Vor Umsetzung lesen: + +1. `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md` +2. `docs/product/Kairo_Canonical_Operating_Model_v0.1.md` — §5 Objektmodell, §7 Attention Logic, §8 Data Layer +3. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md` — AP0.8 +4. `docs/architecture/Kairo_System_Target_State_v0.1.md` — §13 Roadmap/Backlog/Action, §17 Signals +5. `docs/architecture/Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md` — Phasen A/B, AD-TA-08 +6. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` +7. `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md` + +--- + +## Abgleich System Target State + +| Zielbild (System Target State) | AP0.8-Umsetzung | +|-------------------------------|-----------------| +| RoadmapItem = Entwicklungsziel; BacklogItem = möglicher Handlungsbedarf; Action = committete Maßnahme | Backlog und Action **getrennt**; Convert-Flow | +| Milestone = RoadmapItem(type=milestone) langfristig | **MVP-Brücke:** eigene `milestones`-Tabelle; Konsolidierung → RoadmapItem in Phase C (AD-TA-08) | +| Attention + NextActionCandidate als Signal-Typen | Read Models in `data_layer/attention.py` | +| Signals erklärbar (reason, scope, data_source) | DTO-Felder `reason_code`, `scope_type`, `scope_id`, `data_source` | +| Blocker als Steuerungsobjekt | eigene Tabelle, nicht nur Action-Status | +| Kein SteeringContext / Method Registry yet | explizit Nicht-Scope | +| Kein Workflow / KI | explizit Nicht-Scope | + +--- + +## Leitentscheidungen (freigegeben) + +| Entscheidung | Entscheidung | Begründung | +|--------------|--------------|------------| +| Umsetzungsreihenfolge | 4 Slices (8a→8d) in **einem** AP0.8 | Attention zuerst für Produktwirkung | +| Blocker | **Eigenes Objekt** | Canonical Model + System Target State §10 | +| Backlog vs. Action | **Getrennte Tabellen** | §13 Unterscheidung Program Director vs. To-do | +| Milestone | **Eigene Tabelle (MVP-Brücke)** | Schnell prüfbar; Roadmap-Konsolidierung später | +| Project | **Nicht in AP0.8** | Milestone direkt an Initiative | +| Action-Status `ready`/`review_required` | **Nicht in AP0.8** | AP0.9 | +| Due Dates auf Actions | **Nicht in AP0.8** | Regel 7 stub; Milestone `target_date` optional | +| Attention: open Blockers | **Ja, Regel 1b** | Ergänzung zu blocked Actions | +| Capabilities | 7 neue (1 read + 3×2) | Registry-first | +| `kairo.attention.read` | **Eigene Capability** | Explizit, Grants wie workspace.read | +| Member manage Blocker/Backlog/Milestone | **Ja** | wie Actions | +| Convert Backlog→Action | **Pflicht, minimal** | Commit-Flow | +| Blocker ↔ action.status=blocked | **Optional bei Create** | nicht zwingend synchron | +| UI | AP0.6b-Optik | kein Redesign | + +--- + +## Empfohlene Umsetzungsreihenfolge + +```text +AP0.8a Attention / NextActionCandidate (Read Model + Widget) +AP0.8b Blocker (Domäne + API + UI) +AP0.8c BacklogItem (Domäne + API + UI + convert) +AP0.8d Milestone minimal (Domäne + API + UI + Attention Regel 6) +``` + +Reihenfolge ist verbindlich für die Implementierung. + +--- + +# Teil A — AP0.8a: Attention / NextActionCandidate + +## Ziel + +Regelbasierte **Signal-Schicht** im Data Layer — erste Instanz der Signal/NextAction-Engine (System Target State §17). Später Refactor in `backend/steering/signals/` mit Method Rule Providers. + +## Data Layer + +Neue Datei: `backend/data_layer/attention.py` + +```text +get_attention_items(ctx) -> list[AttentionItemDTO] +get_next_action_candidates(ctx, *, limit=10) -> list[NextActionCandidateDTO] +``` + +**AttentionItemDTO** (mit Erklärbarkeit): + +```json +{ + "kind": "blocked_action | open_blocker | high_priority_action | unassigned_action | initiative_without_next_action | stale_initiative | milestone_at_risk", + "severity": "info | warning | critical", + "title": "...", + "summary": "...", + "scope_type": "action | blocker | initiative | milestone", + "scope_id": "...", + "initiative_id": "...", + "action_id": "...", + "blocker_id": "...", + "milestone_id": "...", + "reason_code": "...", + "data_source": "actions | blockers | initiatives | milestones | backlog_items" +} +``` + +**NextActionCandidateDTO:** + +```json +{ + "kind": "assign_action | resolve_blocker | create_action | convert_backlog | review_milestone", + "title": "...", + "summary": "...", + "initiative_id": "...", + "action_id": "...", + "backlog_item_id": "...", + "reason_code": "...", + "recommended_action": "..." +} +``` + +### Regeln + +| # | Regel | Datenquelle | Severity | +|---|-------|-------------|----------| +| 1 | Blockierte Maßnahmen | `actions.status = blocked` | critical | +| 1b | Offene Blocker | `blockers.status IN (open, in_progress)` | warning | +| 2 | High-Priority offen | `priority = high`, open/in_progress; **personal** wenn Actor zugewiesen | warning | +| 3 | Maßnahmen ohne Assignment | keine `action_assignments` | warning | +| 4 | Vorhaben ohne offene Maßnahme | active/paused, keine open/in_progress/blocked Action | info | +| 5 | Lange unveränderte Vorhaben | `updated_at` > 14 Tage, status active | info | +| 6 | Meilenstein at_risk | ab AP0.8d: `milestones.status = at_risk` | warning | +| 7–9 | Due Dates, Reviews, Recurring | AP0.9+ — stub/leer | — | + +**NextAction-Kandidaten (regelbasiert, limitiert):** + +- unassigned Action → `assign_action` +- blocked Action / open Blocker → `resolve_blocker` +- Initiative ohne offene Maßnahme → `create_action` +- accepted BacklogItem ohne Action → `convert_backlog` + +Sortierung Attention: critical → warning → info. Keine Client-Priorisierung. + +## API + +```text +GET /api/workspace/attention +GET /api/workspace/next-actions?limit=10 +``` + +Capability: **`kairo.attention.read`** + +## Frontend + +- **`AttentionWidget`** (Registry-Key `kairo.attention`, order 8, unter Überblick) +- Links zu Vorhaben/Maßnahmen/Blockern +- Loading / Error / Empty States + +## Tests + +- Regeln 1, 1b, 2–5; Regel 6 nach 8d +- NextAction: convert_backlog, create_action +- Cross-Tenant isolation +- Router delegiert an Data Layer + +--- + +# Teil B — AP0.8b: Blocker + +## Migration `007_operating_model_extension_i.sql` + +```sql +blockers ( + id UUID PK, + tenant_id UUID NOT NULL REFERENCES tenants(id), + initiative_id UUID NOT NULL REFERENCES initiatives(id), + action_id UUID NULL REFERENCES actions(id), + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + status VARCHAR(32) NOT NULL, -- open, in_progress, resolved, accepted_risk, dismissed + reported_by_actor_id UUID NULL REFERENCES actors(id), + created_at, updated_at +) +``` + +Index: `(tenant_id, initiative_id)`, `(tenant_id, status)` + +## Service + API + +`backend/services/blockers.py` — CRUD, Audit: `blocker.created/updated/status_changed/deleted` + +```text +GET/POST /api/initiatives/{id}/blockers +GET/PATCH/DELETE /api/blockers/{id} +``` + +Capabilities: **`kairo.blocker.read`**, **`kairo.blocker.manage`** + +## Frontend + +Vorhaben-Detail: Sektion **Blocker** + +--- + +# Teil C — AP0.8c: BacklogItem + +## Migration (gleiche `007`) + +```sql +backlog_items ( + id UUID PK, + tenant_id UUID NOT NULL, + initiative_id UUID NOT NULL REFERENCES initiatives(id), + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + status VARCHAR(32) NOT NULL, -- new, triaged, accepted, rejected, converted + priority VARCHAR(16) DEFAULT 'normal', + converted_action_id UUID NULL REFERENCES actions(id), + created_at, updated_at +) +``` + +## Service + API + +```text +GET/POST /api/initiatives/{id}/backlog +GET/PATCH/DELETE /api/backlog/{id} +POST /api/backlog/{id}/convert-to-action +``` + +Capabilities: **`kairo.backlog.read`**, **`kairo.backlog.manage`** + +Convert: erzeugt Action, setzt `converted_action_id`, status=converted, Audit. + +## Frontend + +Vorhaben-Detail: Sektion **Backlog** + Button „In Maßnahme umwandeln“ + +--- + +# Teil D — AP0.8d: Milestone minimal + +## Migration (gleiche `007`) + +```sql +milestones ( + id UUID PK, + tenant_id UUID NOT NULL, + initiative_id UUID NOT NULL REFERENCES initiatives(id), + title VARCHAR(255) NOT NULL, + goal_description TEXT DEFAULT '', + status VARCHAR(32) NOT NULL, -- planned, active, at_risk, reached, moved, discarded + target_date DATE NULL, + created_at, updated_at +) +``` + +**Hinweis Zielarchitektur:** Langfristig `RoadmapItem(item_type=milestone)`. Diese Tabelle ist bewusste MVP-Brücke (AD-TA-08). + +## Service + API + +```text +GET/POST /api/initiatives/{id}/milestones +GET/PATCH/DELETE /api/milestones/{id} +``` + +Capabilities: **`kairo.milestone.read`**, **`kairo.milestone.manage`** + +Attention-Regel 6 nach Implementierung aktivieren. + +## Frontend + +Vorhaben-Detail: Sektion **Meilensteine** (kompakt, kein Gantt) + +--- + +# Capabilities AP0.8 + +| Capability | Modul | Grants | +|------------|-------|--------| +| `kairo.attention.read` | attention | Member+ (wie workspace.read) | +| `kairo.blocker.read` | blocker | Member+ | +| `kairo.blocker.manage` | blocker | Member+ | +| `kairo.backlog.read` | backlog | Member+ | +| `kairo.backlog.manage` | backlog | Member+ | +| `kairo.milestone.read` | milestone | Member+ | +| `kairo.milestone.manage` | milestone | Member+ | + +Registrierung: `backend/rights_registrations/operating_model_ops.py` +Gesamt nach AP0.8: **25 Capabilities** (18 + 7) + +--- + +# Architekturregeln (verbindlich) + +1. **Tenant-first** — `tenant_id` aus TenantContext +2. **Actor-first** — `reported_by_actor_id` optional; Assignments unverändert +3. **Data Layer read / Services write** — Attention nur Data Layer +4. **Router dünn** — keine Aggregations-SQL in Routern +5. **Signals erklärbar** — reason_code, scope_type, data_source in DTOs +6. **Cross-Tenant → 404** +7. **Migration `007`** — kein ad-hoc DDL +8. **Kein Prompt/KI/MCP/Workflow/SteeringContext/Method Registry** +9. **Product Reset** — drei Ebenen RoadmapItem/BacklogItem/Action respektieren + +--- + +# Nicht-Scope AP0.8 + +- Project / Program / SteeringContext / Method Registry / Hook Runtime +- Evidence, Decision, Review, RecurringElement (→ AP0.9) +- Action-Status `ready`, `review_required` +- Due Dates auf Actions / Überfälligkeits-Engine +- KI, MCP, Workflow Engine +- Roadmap/RoadmapLane/RoadmapItem-Modell (→ Phase C) +- Workspace-weite Backlog-Navigation +- AP0.10 Validation Testvorhaben + +--- + +# Tests (Mindestumfang) + +## Backend — `backend/tests/test_ap08_operating_model.py` + +| Bereich | Tests | +|---------|-------| +| Attention | Regeln 1, 1b, 2–6, Sortierung, Cross-Tenant | +| NextAction | assign, convert_backlog, create_action, limit | +| Blocker | CRUD, Tenant-Isolation, optional action_id | +| Backlog | CRUD, convert-to-action | +| Milestone | CRUD, at_risk in Attention | +| Capabilities | Member grants, Gate 403, 25 sync | +| Migration | `007` in test_migrations | + +## Frontend + +- Vitest: Registry enthält `kairo.attention` +- Build grün + +--- + +# Abnahmekriterien AP0.8 + +1. Migration `007`; Schema `007` +2. Attention/NextAction regelbasiert (Regeln 1–6) +3. Blocker, Backlog, Milestone CRUD tenant-sicher +4. `data_layer/attention.py`; Router delegieren +5. AttentionWidget im Workspace +6. Vorhaben-Detail: Blocker, Backlog, Meilensteine +7. Convert Backlog → Action +8. Cross-Tenant-Tests grün +9. 25 Capabilities registriert +10. AP0.6b UX erhalten +11. Kein Prompt/KI/MCP/Steering-Scope +12. Abschlussbericht + README + +--- + +# Abschlussbericht (nach Umsetzung) + +Siehe Vorlage in v0.1 — Abschnitte 1–15. + +--- + +# Freigegebene Punkte (ehemals offen) + +| # | Entscheidung | +|---|--------------| +| 1 | Ein AP0.8, 4 interne Slices | +| 2 | Eigene Capability `kairo.attention.read` | +| 3 | Blocker/Action-Status optional synchron | +| 4 | Convert Backlog→Action Pflicht | +| 5 | Member manage wie Actions | +| 6 | Initiative draft → AP0.8.1 | +| 7 | Backlog nur Initiative-Detail | + +--- + +*Freigegeben — Umsetzung gemäß Slice-Reihenfolge 8a→8d.* diff --git a/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md new file mode 100644 index 0000000..0258741 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md @@ -0,0 +1,149 @@ +# AP0.8 – Abschlussbericht Operating Model Extension I + +**Status:** abgeschlossen (Implementierung) · QA mit DB ausstehend +**Stand:** 2026-07-05 +**Version:** Backend/Frontend `0.8.0-ap0.8` · Schema `007` +**Auftrag:** `Sprint0_AP0_8_Assignment_v0.2.md` (abgeglichen mit System Target State v0.1) + +--- + +## 1. Scope und Einordnung + +AP0.8 ist der erste fachliche Ausbau nach dem Product Reset. Er operationalisiert die Leitfrage über regelbasierte Attention/NextAction und erweitert das Domänenmodell um Blocker, BacklogItem und Milestone. + +Einordnung in der Evolutionslinie (System Target State §24): + +- Foundation ✓ (AP0.7) +- **Signal / NextAction Engine — erste Instanz** ✓ (AP0.8a, Data Layer) +- Operating Model Phase A ✓ (Blocker, Backlog, Milestone) +- SteeringContext / Method Registry — bewusst **nicht** in AP0.8 + +--- + +## 2. Definition of Done — Prüfmatrix + +| Kriterium | Ergebnis | Nachweis | +|-----------|----------|----------| +| Migration 007 | ✓ | `007_operating_model_extension_i.sql` | +| Attention Regeln 1–6 | ✓ | `data_layer/attention.py` | +| NextActionCandidates | ✓ | `get_next_action_candidates` | +| Blocker CRUD | ✓ | `services/blockers.py`, Router | +| Backlog CRUD + Convert | ✓ | `services/backlog.py` | +| Milestone CRUD | ✓ | `services/milestones.py` | +| AttentionWidget | ✓ | `widgets/AttentionWidget.jsx` | +| Initiative-Detail Sektionen | ✓ | Blockers/Backlog/Milestones | +| 25 Capabilities | ✓ | `operating_model_ops.py` | +| Frontend Vitest + Build | ✓ | 9/9, build grün | +| Backend pytest | ⏳ Remote | Gitea `test.yml` nach Push auf `develop` (Pi, Container) | +| Kein KI/MCP/Steering | ✓ | Scope eingehalten | + +--- + +## 3. Migration 007 + +Tabellen: `blockers`, `backlog_items`, `milestones` — alle mit `tenant_id`, FK RESTRICT/CASCADE wie AP0.5. + +--- + +## 4. Attention / NextActionCandidate + +- Datei: `backend/data_layer/attention.py` +- API: `GET /api/workspace/attention`, `GET /api/workspace/next-actions` +- Regeln: blocked_action, open_blocker, high_priority (personal), unassigned, initiative_without_next_action, stale_initiative, milestone_at_risk +- DTOs mit `reason_code`, `scope_type`, `data_source` (Erklärbarkeit gemäß System Target State §17) + +--- + +## 5. Blocker + +- Service + nested/flat API +- Audit: `blocker.created/updated/status_changed/deleted` +- Optional: `set_action_blocked` bei Create + +--- + +## 6. BacklogItem + +- Getrennt von Actions (RoadmapItem/Backlog/Action-Unterscheidung) +- Convert-to-action mit Audit `backlog.converted_to_action` + +--- + +## 7. Milestone minimal + +- MVP-Brücke (langfristig RoadmapItem — AD-TA-08) +- Attention-Regel 6 für `at_risk` + +--- + +## 8. Data Layer Erweiterung + +Neu: `attention.py` — Read-only, tenant-scoped, keine Router-SQL. + +--- + +## 9. API-Endpunkte + +Siehe README AP0.8-Tabelle. + +--- + +## 10. Capabilities + +7 neue: `kairo.attention.read`, `kairo.blocker.*`, `kairo.backlog.*`, `kairo.milestone.*` +Gesamt: 25 + +--- + +## 11. Frontend-Integration + +- `AttentionWidget` (order 8, capability `kairo.attention.read`) +- `BlockersSection`, `BacklogSection`, `MilestonesSection` im Vorhaben-Detail +- Status-Badges erweitert für blocker/backlog/milestone + +--- + +## 12. Tests und Verifikation + +| Test | Status | +|------|--------| +| `test_ap08_operating_model.py` | 14 Tests — Lauf auf Pi via `test.yml` | +| `test_rights_registry.py` | aktualisiert (25 Capabilities) | +| `test_migrations.py` | Migration 007 ergänzt | +| `conftest.py` | Session-Guard: skip lokal ohne DB, kein Migrations-Timeout | +| Vitest | 9/9 grün (lokal) | +| `vite build` | grün (lokal) | + +**Verifikation:** Push auf `develop` → `deploy-dev.yml` + `test.yml` (pytest im Backend-Container gegen PostgreSQL im Compose-Stack). + +--- + +## 13. Abweichungen von diesem Auftrag + +| Punkt | Abweichung | +|-------|------------| +| Auftrag v0.1 | Ersetzt durch v0.2 nach Abgleich System Target State | +| Attention DTO | Zusätzlich `scope_type`, `data_source`, Regel 1b open_blocker | +| NextAction DTO | Explizit dokumentiert und implementiert | +| Milestone API target_date | Router akzeptiert ISO-Date-String | + +Keine fachlichen Scope-Abweichungen. + +--- + +## 14. Offene Punkte für AP0.9 + +- Evidence, Decision, Review, RecurringElement +- Action-Status `ready`, `review_required` +- Due Dates auf Actions + Attention Regeln 7–9 +- Attention-Refactor in `backend/steering/signals/` (wenn Steering Core startet) + +--- + +## 15. Empfehlung für AP0.10 / Validation + +Nach AP0.9 ein reales Testvorhaben durchspielen (Backlog → Action → Blocker → Meilenstein → Attention). Milestone→RoadmapItem-Konsolidierung als ADP vor Phase C klären. + +--- + +*Implementierung abgeschlossen — Backend-pytest und Smoke auf dem Pi nach Push auf `develop`.* diff --git a/frontend/package.json b/frontend/package.json index 15629de..380ad51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.7.0-ap0.7", + "version": "0.8.0-ap0.8", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/attention.js b/frontend/src/api/attention.js new file mode 100644 index 0000000..58090d2 --- /dev/null +++ b/frontend/src/api/attention.js @@ -0,0 +1,9 @@ +import { apiFetch } from './client.js' + +export function getAttentionItems() { + return apiFetch('/api/workspace/attention') +} + +export function getNextActionCandidates(limit = 10) { + return apiFetch(`/api/workspace/next-actions?limit=${limit}`) +} diff --git a/frontend/src/api/backlog.js b/frontend/src/api/backlog.js new file mode 100644 index 0000000..7470fc0 --- /dev/null +++ b/frontend/src/api/backlog.js @@ -0,0 +1,30 @@ +import { apiFetch } from './client.js' + +export function listInitiativeBacklog(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/backlog`) +} + +export function createInitiativeBacklogItem(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/backlog`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function updateBacklogItem(backlogItemId, body) { + return apiFetch(`/api/backlog/${backlogItemId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function deleteBacklogItem(backlogItemId) { + return apiFetch(`/api/backlog/${backlogItemId}`, { method: 'DELETE' }) +} + +export function convertBacklogToAction(backlogItemId, body = {}) { + return apiFetch(`/api/backlog/${backlogItemId}/convert-to-action`, { + method: 'POST', + body: JSON.stringify(body), + }) +} diff --git a/frontend/src/api/blockers.js b/frontend/src/api/blockers.js new file mode 100644 index 0000000..46d33a3 --- /dev/null +++ b/frontend/src/api/blockers.js @@ -0,0 +1,23 @@ +import { apiFetch } from './client.js' + +export function listInitiativeBlockers(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/blockers`) +} + +export function createInitiativeBlocker(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/blockers`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function updateBlocker(blockerId, body) { + return apiFetch(`/api/blockers/${blockerId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function deleteBlocker(blockerId) { + return apiFetch(`/api/blockers/${blockerId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/api/milestones.js b/frontend/src/api/milestones.js new file mode 100644 index 0000000..b386644 --- /dev/null +++ b/frontend/src/api/milestones.js @@ -0,0 +1,23 @@ +import { apiFetch } from './client.js' + +export function listInitiativeMilestones(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/milestones`) +} + +export function createInitiativeMilestone(initiativeId, body) { + return apiFetch(`/api/initiatives/${initiativeId}/milestones`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function updateMilestone(milestoneId, body) { + return apiFetch(`/api/milestones/${milestoneId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export function deleteMilestone(milestoneId) { + return apiFetch(`/api/milestones/${milestoneId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx new file mode 100644 index 0000000..3d1cd45 --- /dev/null +++ b/frontend/src/components/BacklogSection.jsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { + BACKLOG_STATUSES, + BACKLOG_STATUS_LABELS, +} from '../constants/status.js' +import { StatusBadge } from './StatusBadge.jsx' +import { PriorityBadge } from './PriorityBadge.jsx' +import { EmptyState } from './EmptyState.jsx' + +export function BacklogSection({ + items, + canManage, + onCreate, + onUpdateStatus, + onConvert, + onDelete, + busy, +}) { + const [title, setTitle] = useState('') + const [showForm, setShowForm] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + if (!title.trim()) return + await onCreate({ title: title.trim() }) + setTitle('') + setShowForm(false) + } + + return ( +
+
+

Backlog

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + +
+ )} + + {items.length === 0 && } + + +
+ ) +} diff --git a/frontend/src/components/BlockersSection.jsx b/frontend/src/components/BlockersSection.jsx new file mode 100644 index 0000000..e25d852 --- /dev/null +++ b/frontend/src/components/BlockersSection.jsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { + BLOCKER_STATUSES, + BLOCKER_STATUS_LABELS, +} from '../constants/status.js' +import { StatusBadge } from './StatusBadge.jsx' +import { EmptyState } from './EmptyState.jsx' + +export function BlockersSection({ + blockers, + canManage, + onCreate, + onUpdateStatus, + onDelete, + busy, +}) { + const [title, setTitle] = useState('') + const [showForm, setShowForm] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + if (!title.trim()) return + await onCreate({ title: title.trim() }) + setTitle('') + setShowForm(false) + } + + return ( +
+
+

Blocker

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + +
+ )} + + {blockers.length === 0 && } + + +
+ ) +} diff --git a/frontend/src/components/MilestonesSection.jsx b/frontend/src/components/MilestonesSection.jsx new file mode 100644 index 0000000..918bfbe --- /dev/null +++ b/frontend/src/components/MilestonesSection.jsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { + MILESTONE_STATUSES, + MILESTONE_STATUS_LABELS, +} from '../constants/status.js' +import { StatusBadge } from './StatusBadge.jsx' +import { EmptyState } from './EmptyState.jsx' + +export function MilestonesSection({ + milestones, + canManage, + onCreate, + onUpdateStatus, + onDelete, + busy, +}) { + const [title, setTitle] = useState('') + const [showForm, setShowForm] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + if (!title.trim()) return + await onCreate({ title: title.trim() }) + setTitle('') + setShowForm(false) + } + + return ( +
+
+

Meilensteine

+ {canManage && ( + + )} +
+ + {showForm && canManage && ( +
+ + +
+ )} + + {milestones.length === 0 && } + + +
+ ) +} diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx index d73e013..37b78aa 100644 --- a/frontend/src/components/StatusBadge.jsx +++ b/frontend/src/components/StatusBadge.jsx @@ -1,4 +1,4 @@ -import { INITIATIVE_STATUS_LABELS, ACTION_STATUS_LABELS } from '../constants/status.js' +import { STATUS_LABELS_BY_KIND } from '../constants/status.js' const VARIANTS = { active: 'status-active', @@ -10,10 +10,22 @@ const VARIANTS = { blocked: 'status-blocked', done: 'status-done', discarded: 'status-discarded', + new: 'status-open', + triaged: 'status-progress', + accepted: 'status-active', + rejected: 'status-discarded', + converted: 'status-done', + planned: 'status-open', + at_risk: 'status-blocked', + reached: 'status-done', + moved: 'status-paused', + resolved: 'status-done', + accepted_risk: 'status-paused', + dismissed: 'status-discarded', } export function StatusBadge({ kind = 'action', status }) { - const labels = kind === 'initiative' ? INITIATIVE_STATUS_LABELS : ACTION_STATUS_LABELS + const labels = STATUS_LABELS_BY_KIND[kind] || STATUS_LABELS_BY_KIND.action const label = labels[status] || status const variant = VARIANTS[status] || 'status-default' return {label} diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js index 65e6ded..bbff8aa 100644 --- a/frontend/src/constants/status.js +++ b/frontend/src/constants/status.js @@ -1,5 +1,8 @@ export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived'] export const ACTION_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'discarded'] +export const BLOCKER_STATUSES = ['open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed'] +export const BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted'] +export const MILESTONE_STATUSES = ['planned', 'active', 'at_risk', 'reached', 'moved', 'discarded'] export const PRIORITIES = ['low', 'normal', 'high'] export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked'] @@ -18,8 +21,41 @@ export const ACTION_STATUS_LABELS = { discarded: 'Verworfen', } +export const BLOCKER_STATUS_LABELS = { + open: 'Offen', + in_progress: 'In Klärung', + resolved: 'Gelöst', + accepted_risk: 'Risiko akzeptiert', + dismissed: 'Verworfen', +} + +export const BACKLOG_STATUS_LABELS = { + new: 'Neu', + triaged: 'Eingeordnet', + accepted: 'Freigegeben', + rejected: 'Abgelehnt', + converted: 'Umgewandelt', +} + +export const MILESTONE_STATUS_LABELS = { + planned: 'Geplant', + active: 'Aktiv', + at_risk: 'Gefährdet', + reached: 'Erreicht', + moved: 'Verschoben', + discarded: 'Verworfen', +} + export const PRIORITY_LABELS = { low: 'Niedrig', normal: 'Normal', high: 'Hoch', } + +export const STATUS_LABELS_BY_KIND = { + initiative: INITIATIVE_STATUS_LABELS, + action: ACTION_STATUS_LABELS, + blocker: BLOCKER_STATUS_LABELS, + backlog: BACKLOG_STATUS_LABELS, + milestone: MILESTONE_STATUS_LABELS, +} diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/pages/InitiativeDetailPage.jsx index 7c89ca1..b07f1ca 100644 --- a/frontend/src/pages/InitiativeDetailPage.jsx +++ b/frontend/src/pages/InitiativeDetailPage.jsx @@ -6,10 +6,32 @@ import { createInitiativeAction, } from '../api/initiatives.js' import { updateAction, setActionAssignments } from '../api/actions.js' +import { + listInitiativeBlockers, + createInitiativeBlocker, + updateBlocker, + deleteBlocker, +} from '../api/blockers.js' +import { + listInitiativeBacklog, + createInitiativeBacklogItem, + updateBacklogItem, + deleteBacklogItem, + convertBacklogToAction, +} from '../api/backlog.js' +import { + listInitiativeMilestones, + createInitiativeMilestone, + updateMilestone, + deleteMilestone, +} from '../api/milestones.js' import { ACTION_STATUS_LABELS } from '../constants/status.js' import { StatusBadge } from '../components/StatusBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx' import { ActionForm } from '../components/ActionForm.jsx' +import { BlockersSection } from '../components/BlockersSection.jsx' +import { BacklogSection } from '../components/BacklogSection.jsx' +import { MilestonesSection } from '../components/MilestonesSection.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' @@ -20,7 +42,7 @@ import { useSession } from '../context/SessionContext.jsx' export function InitiativeDetailPage() { const { id } = useParams() const { context } = useSession() - const { hasCapability } = useCapabilities() + const { capabilities } = useCapabilities() const { actors, loading: actorsLoading, @@ -31,6 +53,9 @@ export function InitiativeDetailPage() { const [initiative, setInitiative] = useState(null) const [actions, setActions] = useState([]) + const [blockers, setBlockers] = useState([]) + const [backlogItems, setBacklogItems] = useState([]) + const [milestones, setMilestones] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showActionForm, setShowActionForm] = useState(false) @@ -48,12 +73,24 @@ export function InitiativeDetailPage() { ]) setInitiative(initData) setActions(actionData) + + const loads = [] + if (capabilities.has('kairo.blocker.read')) { + loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([]))) + } + if (capabilities.has('kairo.backlog.read')) { + loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([]))) + } + if (capabilities.has('kairo.milestone.read')) { + loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([]))) + } + await Promise.all(loads) } catch (err) { setError(err.message) } finally { setLoading(false) } - }, [id]) + }, [id, capabilities]) useEffect(() => { load() @@ -91,7 +128,7 @@ export function InitiativeDetailPage() { status: payload.status, priority: payload.priority, }) - if (hasCapability('kairo.action.manage')) { + if (capabilities.has('kairo.action.manage')) { await setActionAssignments(actionId, payload.assigned_actor_ids || []) } setEditingAction(null) @@ -104,7 +141,7 @@ export function InitiativeDetailPage() { } async function handleQuickStatus(action, status) { - if (!hasCapability('kairo.action.manage')) return + if (!capabilities.has('kairo.action.manage')) return try { await updateAction(action.id, { status }) await load() @@ -113,6 +150,108 @@ export function InitiativeDetailPage() { } } + async function handleCreateBlocker(body) { + setFormBusy(true) + try { + await createInitiativeBlocker(id, body) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleBlockerStatus(blockerId, status) { + try { + await updateBlocker(blockerId, { status }) + await load() + } catch (err) { + setError(err.message) + } + } + + async function handleDeleteBlocker(blockerId) { + try { + await deleteBlocker(blockerId) + await load() + } catch (err) { + setError(err.message) + } + } + + async function handleCreateBacklog(body) { + setFormBusy(true) + try { + await createInitiativeBacklogItem(id, body) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleBacklogStatus(itemId, status) { + try { + await updateBacklogItem(itemId, { status }) + await load() + } catch (err) { + setError(err.message) + } + } + + async function handleConvertBacklog(itemId) { + setFormBusy(true) + try { + await convertBacklogToAction(itemId) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleDeleteBacklog(itemId) { + try { + await deleteBacklogItem(itemId) + await load() + } catch (err) { + setError(err.message) + } + } + + async function handleCreateMilestone(body) { + setFormBusy(true) + try { + await createInitiativeMilestone(id, body) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleMilestoneStatus(milestoneId, status) { + try { + await updateMilestone(milestoneId, { status }) + await load() + } catch (err) { + setError(err.message) + } + } + + async function handleDeleteMilestone(milestoneId) { + try { + await deleteMilestone(milestoneId) + await load() + } catch (err) { + setError(err.message) + } + } + if (loading) { return (
@@ -168,7 +307,7 @@ export function InitiativeDetailPage() { /> Erledigte ausblenden - {hasCapability('kairo.action.manage') && ( + {capabilities.has('kairo.action.manage') && (