AP0.8: Operating Model Extension I (Attention, Blocker, Backlog, Milestone)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 58s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 58s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Schema 007, regelbasierte Attention/NextAction im Data Layer, CRUD fuer Blocker/Backlog/Meilensteine, Workspace-Widget und Initiative-Detail-Sektionen. 25 Capabilities. Tests fuer Remote-Pytest auf Pi angepasst (conftest Session-Guard). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6dd9dd4f62
commit
fe3bc4f057
27
.cursor/rules/kairo-deployment-testing.mdc
Normal file
27
.cursor/rules/kairo-deployment-testing.mdc
Normal file
|
|
@ -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
|
||||||
32
README.md
32
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/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
|
||||||
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
|
| `/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`
|
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
|
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
|
|
||||||
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
|
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
|
||||||
|
|
||||||
|
|
@ -218,9 +243,9 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
|
||||||
|
|
||||||
| Route | Beschreibung |
|
| 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` | 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 |
|
| `/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-*`).
|
**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
|
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
|
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
|
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)
|
### Registries (AP0.4)
|
||||||
|
|
||||||
|
|
|
||||||
418
backend/data_layer/attention.py
Normal file
418
backend/data_layer/attention.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -53,7 +53,20 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
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(auth.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
|
|
@ -62,6 +75,9 @@ app.include_router(prompts.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(initiatives.router)
|
app.include_router(initiatives.router)
|
||||||
app.include_router(actions.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(actors.router)
|
||||||
app.include_router(workspace.router)
|
app.include_router(workspace.router)
|
||||||
|
|
||||||
|
|
|
||||||
52
backend/migrations/007_operating_model_extension_i.sql
Normal file
52
backend/migrations/007_operating_model_extension_i.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
"""Import all module registrations — side effect registers capabilities."""
|
"""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",
|
||||||
|
]
|
||||||
|
|
|
||||||
83
backend/rights_registrations/operating_model_ops.py
Normal file
83
backend/rights_registrations/operating_model_ops.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
103
backend/routers/backlog.py
Normal file
103
backend/routers/backlog.py
Normal file
|
|
@ -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
|
||||||
77
backend/routers/blockers.py
Normal file
77
backend/routers/blockers.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -8,7 +8,10 @@ from capabilities import require_capability
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from services import actions as action_service
|
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 initiatives as initiative_service
|
||||||
|
from services import milestones as milestone_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
|
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
|
||||||
|
|
@ -38,6 +41,28 @@ class ActionCreateRequest(BaseModel):
|
||||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
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("")
|
@router.get("")
|
||||||
def list_initiatives(
|
def list_initiatives(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||||
|
|
@ -153,3 +178,127 @@ def create_initiative_action(
|
||||||
if detail == "Initiative nicht gefunden":
|
if detail == "Initiative nicht gefunden":
|
||||||
raise HTTPException(status_code=404, detail=detail) from exc
|
raise HTTPException(status_code=404, detail=detail) from exc
|
||||||
raise HTTPException(status_code=400, 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
|
||||||
|
|
|
||||||
77
backend/routers/milestones.py
Normal file
77
backend/routers/milestones.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Optional
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
from data_layer import actions as dl_actions
|
from data_layer import actions as dl_actions
|
||||||
from data_layer import actors as dl_actors
|
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 initiatives as dl_initiatives
|
||||||
from data_layer import workspace as dl_workspace
|
from data_layer import workspace as dl_workspace
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
@ -61,3 +62,18 @@ def workspace_actor_workload(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
):
|
):
|
||||||
return dl_actors.get_actor_workload(ctx)
|
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)
|
||||||
|
|
|
||||||
296
backend/services/backlog.py
Normal file
296
backend/services/backlog.py
Normal file
|
|
@ -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}
|
||||||
295
backend/services/blockers.py
Normal file
295
backend/services/blockers.py
Normal file
|
|
@ -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
|
||||||
242
backend/services/milestones.py
Normal file
242
backend/services/milestones.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -23,6 +27,7 @@ def _db_available() -> bool:
|
||||||
dbname=os.getenv("DB_NAME", "kairo_dev"),
|
dbname=os.getenv("DB_NAME", "kairo_dev"),
|
||||||
user=os.getenv("DB_USER", "kairo_dev"),
|
user=os.getenv("DB_USER", "kairo_dev"),
|
||||||
password=os.getenv("DB_PASSWORD", "dev_password"),
|
password=os.getenv("DB_PASSWORD", "dev_password"),
|
||||||
|
connect_timeout=3,
|
||||||
)
|
)
|
||||||
conn.close()
|
conn.close()
|
||||||
return True
|
return True
|
||||||
|
|
@ -30,7 +35,18 @@ def _db_available() -> bool:
|
||||||
return False
|
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)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
|
|
||||||
279
backend/tests/test_ap08_operating_model.py
Normal file
279
backend/tests/test_ap08_operating_model.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -37,6 +37,7 @@ def test_migration_runner_finds_migrations():
|
||||||
assert "004_capabilities_registry" in names
|
assert "004_capabilities_registry" in names
|
||||||
assert "005_prompt_feature_config_registry" in names
|
assert "005_prompt_feature_config_registry" in names
|
||||||
assert "006_initiatives_actions" in names
|
assert "006_initiatives_actions" in names
|
||||||
|
assert "007_operating_model_extension_i" in names
|
||||||
|
|
||||||
|
|
||||||
def test_migration_runner_is_idempotent():
|
def test_migration_runner_is_idempotent():
|
||||||
|
|
@ -52,6 +53,7 @@ def test_migration_runner_is_idempotent():
|
||||||
assert "004_capabilities_registry" in executed
|
assert "004_capabilities_registry" in executed
|
||||||
assert "005_prompt_feature_config_registry" in executed
|
assert "005_prompt_feature_config_registry" in executed
|
||||||
assert "006_initiatives_actions" in executed
|
assert "006_initiatives_actions" in executed
|
||||||
|
assert "007_operating_model_extension_i" in executed
|
||||||
|
|
||||||
|
|
||||||
def test_core_table_exists():
|
def test_core_table_exists():
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ def test_registry_contains_initial_capabilities():
|
||||||
"kairo.action.read",
|
"kairo.action.read",
|
||||||
"kairo.action.manage",
|
"kairo.action.manage",
|
||||||
"kairo.workspace.read",
|
"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:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT COUNT(*) FROM capabilities")
|
cur.execute("SELECT COUNT(*) FROM capabilities")
|
||||||
assert cur.fetchone()[0] == 18
|
assert cur.fetchone()[0] == 25
|
||||||
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
||||||
assert cur.fetchone()[0] >= 5
|
assert cur.fetchone()[0] >= 5
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.7.0-ap0.7"
|
APP_VERSION = "0.8.0-ap0.8"
|
||||||
DB_SCHEMA_VERSION = "006"
|
DB_SCHEMA_VERSION = "007"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
808
docs/architecture/Kairo_System_Target_State_v0.1.md
Normal file
808
docs/architecture/Kairo_System_Target_State_v0.1.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# AP0.8 – Operating Model Extension I
|
# AP0.8 – Operating Model Extension I
|
||||||
## Implementierungsauftrag zur Prüfung v0.1
|
## Implementierungsauftrag zur Prüfung v0.1
|
||||||
|
|
||||||
**Status:** Entwurf zur Freigabe
|
**Status:** ersetzt durch `Sprint0_AP0_8_Assignment_v0.2.md`
|
||||||
**Stand:** 2026-07-05
|
**Stand:** 2026-07-05
|
||||||
**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓
|
**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓
|
||||||
**Zielversion:** `0.8.0-ap0.8` · Schema `007`
|
**Zielversion:** `0.8.0-ap0.8` · Schema `007`
|
||||||
|
|
|
||||||
402
docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md
Normal file
402
docs/sprints/Sprint0_AP0_8_Assignment_v0.2.md
Normal file
|
|
@ -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.*
|
||||||
149
docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md
Normal file
149
docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md
Normal file
|
|
@ -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`.*
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.7.0-ap0.7",
|
"version": "0.8.0-ap0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
9
frontend/src/api/attention.js
Normal file
9
frontend/src/api/attention.js
Normal file
|
|
@ -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}`)
|
||||||
|
}
|
||||||
30
frontend/src/api/backlog.js
Normal file
30
frontend/src/api/backlog.js
Normal file
|
|
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
23
frontend/src/api/blockers.js
Normal file
23
frontend/src/api/blockers.js
Normal file
|
|
@ -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' })
|
||||||
|
}
|
||||||
23
frontend/src/api/milestones.js
Normal file
23
frontend/src/api/milestones.js
Normal file
|
|
@ -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' })
|
||||||
|
}
|
||||||
115
frontend/src/components/BacklogSection.jsx
Normal file
115
frontend/src/components/BacklogSection.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Backlog</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Backlog-Item'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.description && (
|
||||||
|
<p className="list-item-desc">{item.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="backlog" status={item.status} />
|
||||||
|
<PriorityBadge priority={item.priority} />
|
||||||
|
{canManage && item.status !== 'converted' && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={item.status}
|
||||||
|
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||||
|
aria-label="Backlog-Status"
|
||||||
|
>
|
||||||
|
{BACKLOG_STATUSES.filter((s) => s !== 'converted').map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{BACKLOG_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{item.status === 'accepted' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => onConvert(item.id)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
In Maßnahme umwandeln
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
frontend/src/components/BlockersSection.jsx
Normal file
102
frontend/src/components/BlockersSection.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Blocker</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Blocker melden'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blockers.length === 0 && <EmptyState message="Keine Blocker." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{blockers.map((blocker) => (
|
||||||
|
<li key={blocker.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{blocker.title}</strong>
|
||||||
|
{blocker.description && (
|
||||||
|
<p className="list-item-desc">{blocker.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="blocker" status={blocker.status} />
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={blocker.status}
|
||||||
|
onChange={(e) => onUpdateStatus(blocker.id, e.target.value)}
|
||||||
|
aria-label="Blocker-Status"
|
||||||
|
>
|
||||||
|
{BLOCKER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{BLOCKER_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(blocker.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
frontend/src/components/MilestonesSection.jsx
Normal file
105
frontend/src/components/MilestonesSection.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Meilensteine</h2>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : 'Meilenstein'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && canManage && (
|
||||||
|
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{milestones.length === 0 && <EmptyState message="Keine Meilensteine." />}
|
||||||
|
|
||||||
|
<ul className="item-list">
|
||||||
|
{milestones.map((milestone) => (
|
||||||
|
<li key={milestone.id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{milestone.title}</strong>
|
||||||
|
{milestone.goal_description && (
|
||||||
|
<p className="list-item-desc">{milestone.goal_description}</p>
|
||||||
|
)}
|
||||||
|
{milestone.target_date && (
|
||||||
|
<p className="muted list-item-sub">Ziel: {milestone.target_date}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="milestone" status={milestone.status} />
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="inline-select"
|
||||||
|
value={milestone.status}
|
||||||
|
onChange={(e) => onUpdateStatus(milestone.id, e.target.value)}
|
||||||
|
aria-label="Meilenstein-Status"
|
||||||
|
>
|
||||||
|
{MILESTONE_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{MILESTONE_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onDelete(milestone.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 = {
|
const VARIANTS = {
|
||||||
active: 'status-active',
|
active: 'status-active',
|
||||||
|
|
@ -10,10 +10,22 @@ const VARIANTS = {
|
||||||
blocked: 'status-blocked',
|
blocked: 'status-blocked',
|
||||||
done: 'status-done',
|
done: 'status-done',
|
||||||
discarded: 'status-discarded',
|
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 }) {
|
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 label = labels[status] || status
|
||||||
const variant = VARIANTS[status] || 'status-default'
|
const variant = VARIANTS[status] || 'status-default'
|
||||||
return <span className={`badge status-badge ${variant}`}>{label}</span>
|
return <span className={`badge status-badge ${variant}`}>{label}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
|
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
|
||||||
export const ACTION_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'discarded']
|
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 PRIORITIES = ['low', 'normal', 'high']
|
||||||
export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked']
|
export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked']
|
||||||
|
|
||||||
|
|
@ -18,8 +21,41 @@ export const ACTION_STATUS_LABELS = {
|
||||||
discarded: 'Verworfen',
|
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 = {
|
export const PRIORITY_LABELS = {
|
||||||
low: 'Niedrig',
|
low: 'Niedrig',
|
||||||
normal: 'Normal',
|
normal: 'Normal',
|
||||||
high: 'Hoch',
|
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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,32 @@ import {
|
||||||
createInitiativeAction,
|
createInitiativeAction,
|
||||||
} from '../api/initiatives.js'
|
} from '../api/initiatives.js'
|
||||||
import { updateAction, setActionAssignments } from '../api/actions.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 { ACTION_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { ActionForm } from '../components/ActionForm.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 { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
|
@ -20,7 +42,7 @@ import { useSession } from '../context/SessionContext.jsx'
|
||||||
export function InitiativeDetailPage() {
|
export function InitiativeDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { context } = useSession()
|
const { context } = useSession()
|
||||||
const { hasCapability } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const {
|
const {
|
||||||
actors,
|
actors,
|
||||||
loading: actorsLoading,
|
loading: actorsLoading,
|
||||||
|
|
@ -31,6 +53,9 @@ export function InitiativeDetailPage() {
|
||||||
|
|
||||||
const [initiative, setInitiative] = useState(null)
|
const [initiative, setInitiative] = useState(null)
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
|
const [blockers, setBlockers] = useState([])
|
||||||
|
const [backlogItems, setBacklogItems] = useState([])
|
||||||
|
const [milestones, setMilestones] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showActionForm, setShowActionForm] = useState(false)
|
const [showActionForm, setShowActionForm] = useState(false)
|
||||||
|
|
@ -48,12 +73,24 @@ export function InitiativeDetailPage() {
|
||||||
])
|
])
|
||||||
setInitiative(initData)
|
setInitiative(initData)
|
||||||
setActions(actionData)
|
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) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id, capabilities])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
|
|
@ -91,7 +128,7 @@ export function InitiativeDetailPage() {
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
priority: payload.priority,
|
priority: payload.priority,
|
||||||
})
|
})
|
||||||
if (hasCapability('kairo.action.manage')) {
|
if (capabilities.has('kairo.action.manage')) {
|
||||||
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
|
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
|
||||||
}
|
}
|
||||||
setEditingAction(null)
|
setEditingAction(null)
|
||||||
|
|
@ -104,7 +141,7 @@ export function InitiativeDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuickStatus(action, status) {
|
async function handleQuickStatus(action, status) {
|
||||||
if (!hasCapability('kairo.action.manage')) return
|
if (!capabilities.has('kairo.action.manage')) return
|
||||||
try {
|
try {
|
||||||
await updateAction(action.id, { status })
|
await updateAction(action.id, { status })
|
||||||
await load()
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
|
|
@ -168,7 +307,7 @@ export function InitiativeDetailPage() {
|
||||||
/>
|
/>
|
||||||
Erledigte ausblenden
|
Erledigte ausblenden
|
||||||
</label>
|
</label>
|
||||||
{hasCapability('kairo.action.manage') && (
|
{capabilities.has('kairo.action.manage') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-block-mobile"
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
|
@ -232,7 +371,7 @@ export function InitiativeDetailPage() {
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
<StatusBadge status={action.status} />
|
<StatusBadge status={action.status} />
|
||||||
<PriorityBadge priority={action.priority} />
|
<PriorityBadge priority={action.priority} />
|
||||||
{hasCapability('kairo.action.manage') && (
|
{capabilities.has('kairo.action.manage') && (
|
||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
className="inline-select"
|
className="inline-select"
|
||||||
|
|
@ -265,6 +404,40 @@ export function InitiativeDetailPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{capabilities.has('kairo.milestone.read') && (
|
||||||
|
<MilestonesSection
|
||||||
|
milestones={milestones}
|
||||||
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
|
onCreate={handleCreateMilestone}
|
||||||
|
onUpdateStatus={handleMilestoneStatus}
|
||||||
|
onDelete={handleDeleteMilestone}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.backlog.read') && (
|
||||||
|
<BacklogSection
|
||||||
|
items={backlogItems}
|
||||||
|
canManage={capabilities.has('kairo.backlog.manage')}
|
||||||
|
onCreate={handleCreateBacklog}
|
||||||
|
onUpdateStatus={handleBacklogStatus}
|
||||||
|
onConvert={handleConvertBacklog}
|
||||||
|
onDelete={handleDeleteBacklog}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities.has('kairo.blocker.read') && (
|
||||||
|
<BlockersSection
|
||||||
|
blockers={blockers}
|
||||||
|
canManage={capabilities.has('kairo.blocker.manage')}
|
||||||
|
onCreate={handleCreateBlocker}
|
||||||
|
onUpdateStatus={handleBlockerStatus}
|
||||||
|
onDelete={handleDeleteBlocker}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,17 @@ describe('widgetRegistry', () => {
|
||||||
expect(keys).toContain('kairo.blocked_actions')
|
expect(keys).toContain('kairo.blocked_actions')
|
||||||
expect(keys).toContain('kairo.tenant_context')
|
expect(keys).toContain('kairo.tenant_context')
|
||||||
expect(keys).toContain('kairo.workspace_summary')
|
expect(keys).toContain('kairo.workspace_summary')
|
||||||
|
expect(keys).toContain('kairo.attention')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters widgets by capability', () => {
|
it('filters widgets by capability', () => {
|
||||||
const all = getWidgetsForArea('workspace', [
|
const all = getWidgetsForArea('workspace', [
|
||||||
'kairo.action.read',
|
'kairo.action.read',
|
||||||
'kairo.initiative.read',
|
'kairo.initiative.read',
|
||||||
|
'kairo.workspace.read',
|
||||||
|
'kairo.attention.read',
|
||||||
])
|
])
|
||||||
expect(all.length).toBe(4)
|
expect(all.length).toBe(6)
|
||||||
|
|
||||||
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
|
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
|
||||||
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
|
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
|
||||||
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
||||||
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
||||||
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
||||||
|
import { AttentionWidget } from '../widgets/AttentionWidget.jsx'
|
||||||
|
|
||||||
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
||||||
|
|
||||||
|
|
@ -36,6 +37,15 @@ export const WIDGETS = [
|
||||||
defaultOrder: 5,
|
defaultOrder: 5,
|
||||||
component: WorkspaceSummaryWidget,
|
component: WorkspaceSummaryWidget,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.attention',
|
||||||
|
title: 'Aufmerksamkeit',
|
||||||
|
description: 'Regelbasierte Attention-Items',
|
||||||
|
area: 'workspace',
|
||||||
|
requiredCapability: 'kairo.attention.read',
|
||||||
|
defaultOrder: 8,
|
||||||
|
component: AttentionWidget,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kairo.my_open_actions',
|
key: 'kairo.my_open_actions',
|
||||||
title: 'Meine offenen Maßnahmen',
|
title: 'Meine offenen Maßnahmen',
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,33 @@
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attention-severity {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attention-severity--critical {
|
||||||
|
color: var(--jk-danger, #c0392b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attention-severity--warning {
|
||||||
|
color: var(--jk-warning, #d68910);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attention-severity--info {
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attention-list .list-item-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.list-item-main {
|
.list-item-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
89
frontend/src/widgets/AttentionWidget.jsx
Normal file
89
frontend/src/widgets/AttentionWidget.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { getAttentionItems } from '../api/attention.js'
|
||||||
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
|
||||||
|
const SEVERITY_LABELS = {
|
||||||
|
critical: 'Kritisch',
|
||||||
|
warning: 'Warnung',
|
||||||
|
info: 'Hinweis',
|
||||||
|
}
|
||||||
|
|
||||||
|
function attentionLink(item) {
|
||||||
|
if (item.initiative_id && item.action_id) {
|
||||||
|
return `/initiatives/${item.initiative_id}`
|
||||||
|
}
|
||||||
|
if (item.initiative_id) {
|
||||||
|
return `/initiatives/${item.initiative_id}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttentionWidget() {
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getAttentionItems()
|
||||||
|
setItems(Array.isArray(data) ? data.slice(0, 8) : [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetCard
|
||||||
|
title="Aufmerksamkeit"
|
||||||
|
subtitle="Was braucht jetzt Steuerung?"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
|
{!loading && !error && items.length === 0 && (
|
||||||
|
<EmptyState message="Keine Attention-Items — alles im grünen Bereich." />
|
||||||
|
)}
|
||||||
|
{!loading && !error && items.length > 0 && (
|
||||||
|
<ul className="item-list attention-list">
|
||||||
|
{items.map((item) => {
|
||||||
|
const href = attentionLink(item)
|
||||||
|
return (
|
||||||
|
<li key={`${item.kind}-${item.scope_id}`} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<span className={`attention-severity attention-severity--${item.severity}`}>
|
||||||
|
{SEVERITY_LABELS[item.severity] || item.severity}
|
||||||
|
</span>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta">
|
||||||
|
{href ? (
|
||||||
|
<Link to={href} className="btn btn-secondary btn-sm">
|
||||||
|
Ansehen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</WidgetCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user