AP2.2b: Kritischer Pfad in Kontrolle für A2 Linear.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m57s
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 20s

CriticalPathPanel aus Execution Graph, Next-Action-Begruendung und Integrationstest fuer sequential_dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-12 19:26:08 +02:00
parent f6aba94864
commit 00567bb097
8 changed files with 469 additions and 1 deletions

View File

@ -0,0 +1,78 @@
"""AP2.2b — A2 linear project: critical path in execution graph + Next Action."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_linear_critical_path_and_next_action(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Neue Küche",
archetype_key="initiative.linear_project",
)
initiative_id = created.json()["id"]
a = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Rohbau", "status": "open", "sort_order": 0},
headers=_auth(token),
)
b = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Elektrik", "status": "open", "sort_order": 1},
headers=_auth(token),
)
c = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Endabnahme", "status": "open", "sort_order": 2},
headers=_auth(token),
)
assert a.status_code == 201 and b.status_code == 201 and c.status_code == 201
a_id, b_id, c_id = a.json()["id"], b.json()["id"], c.json()["id"]
dep1 = client.post(
f"/api/initiatives/{initiative_id}/execution/dependencies",
json={
"predecessor_action_id": a_id,
"successor_action_id": b_id,
"dependency_kind": "requires",
},
headers=_auth(token),
)
dep2 = client.post(
f"/api/initiatives/{initiative_id}/execution/dependencies",
json={
"predecessor_action_id": b_id,
"successor_action_id": c_id,
"dependency_kind": "requires",
},
headers=_auth(token),
)
assert dep1.status_code == 201 and dep2.status_code == 201
graph = client.get(
f"/api/initiatives/{initiative_id}/execution/graph-state",
headers=_auth(token),
)
assert graph.status_code == 200
body = graph.json()
assert body["critical_path"] == [a_id, b_id, c_id]
assert a_id in body["ready_actions"]
assert b_id in body["blocked_actions"]
snap = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snap.status_code == 200
next_actions = snap.json().get("next_actions") or []
assert next_actions
top = next_actions[0]
assert top.get("action_id") == a_id
assert top.get("reason_code") in ("execution_ready", "execution_critical_path")

View File

@ -0,0 +1,134 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
import { LoadingState } from './LoadingState.jsx'
import { ErrorState } from './ErrorState.jsx'
import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx'
import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
import {
buildCriticalPathSteps,
findNextReadyOnCriticalPath,
summarizeCriticalPath,
} from '../utils/executionGraph.js'
import { actionPath, scopedPath } from '../utils/routes.js'
export function CriticalPathPanel({
initiativeId,
actions = [],
embedded = true,
}) {
const [graphState, setGraphState] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (!initiativeId) {
setGraphState(null)
setLoading(false)
return undefined
}
let cancelled = false
setLoading(true)
setError(null)
getInitiativeExecutionGraphState(initiativeId)
.then((data) => {
if (!cancelled) setGraphState(data)
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [initiativeId])
const steps = buildCriticalPathSteps(graphState, actions)
const summary = summarizeCriticalPath(graphState, actions)
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
const body = (
<>
{loading && <LoadingState message="Lade kritischer Pfad …" />}
{!loading && error && (
<ErrorState message={error} onRetry={() => window.location.reload()} />
)}
{!loading && !error && steps.length === 0 && (
<EmptyState message="Noch kein kritischer Pfad — Arbeitspakete mit Abhängigkeiten unter Plan → Zielzustände / Durchführungsplan anlegen." />
)}
{!loading && !error && steps.length > 0 && (
<>
<p className="critical-path-summary muted">{summary.message}</p>
{nextReady && (
<div className="critical-path-next card-list-item">
<span className="badge status-badge status-active">Nächster Schritt</span>
<strong>{nextReady.action.title}</strong>
<p className="muted">
Kritischer Pfad ausführungsbereit, keine blockierenden Vorgänger.
</p>
<Link
to={actionPath(nextReady.actionId)}
className="btn btn-primary btn-sm"
>
Arbeitspaket öffnen
</Link>
</div>
)}
<ol className="critical-path-steps item-list">
{steps.map((step) => (
<li
key={step.actionId}
className={
'list-item card-list-item critical-path-step' +
(step.isNextReady ? ' critical-path-step--next' : '') +
(step.status === 'done' ? ' critical-path-step--done' : '')
}
>
<span className="critical-path-step__index muted">{step.index}</span>
<div className="list-item-main">
<Link to={actionPath(step.actionId)} className="link-inline">
<strong>{step.title}</strong>
</Link>
<p className="list-item-desc muted">{step.reason}</p>
</div>
<div className="list-item-meta">
<StatusBadge status={step.status} />
<ExecutionFlowBadge meta={step.meta} actionStatus={step.status} />
</div>
</li>
))}
</ol>
<p className="muted critical-path-hint">
Graph unter{' '}
<Link to={scopedPath('/plan/gates', { initiativeId })} className="link-inline">
Plan Zielzustände
</Link>
{' '}pflegen.
</p>
</>
)}
</>
)
if (embedded) {
return (
<section className="card critical-path-panel">
<div className="section-header">
<div>
<h2>Kritischer Pfad</h2>
<p className="section-lead muted">
Längster Abhängigkeitspfad aus dem Execution Graph Steuerungsantwort für
lineare Vorhaben (A2).
</p>
</div>
</div>
{body}
</section>
)
}
return body
}

View File

@ -60,6 +60,13 @@ export const SIGNAL_LABELS = {
work_in_progress: 'Arbeit in Umsetzung', work_in_progress: 'Arbeit in Umsetzung',
} }
export const NEXT_ACTION_REASON_LABELS = {
execution_ready: 'Ausführungsbereit',
execution_critical_path: 'Kritischer Pfad',
execution_waiting: 'Wartet (Reihenfolge)',
planning_debt: 'Durchführungsplan fehlt',
}
export const NEXT_ACTION_KIND_LABELS = { export const NEXT_ACTION_KIND_LABELS = {
action: 'Arbeitspaket', action: 'Arbeitspaket',
resolve_blocker: 'Blocker klären', resolve_blocker: 'Blocker klären',

View File

@ -2,11 +2,22 @@ import { Link } from 'react-router-dom'
import { scopedPath } from '../../utils/routes.js' import { scopedPath } from '../../utils/routes.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
function usesCriticalPathControl(initiative, steeringSnapshot) {
const archetypeKey = initiative?.archetype_key
const methodKey = steeringSnapshot?.method_key
return (
archetypeKey === 'initiative.linear_project' || methodKey === 'sequential_dependency'
)
}
export function InitiativeOverviewPage() { export function InitiativeOverviewPage() {
const { const {
initiativeId, initiativeId,
initiative,
actions,
error, error,
steeringSnapshot, steeringSnapshot,
steeringSnapshotLoading, steeringSnapshotLoading,
@ -18,6 +29,7 @@ export function InitiativeOverviewPage() {
} = useInitiativeOperations() } = useInitiativeOperations()
const counts = steeringSnapshot?.counts || {} const counts = steeringSnapshot?.counts || {}
const showCriticalPath = usesCriticalPathControl(initiative, steeringSnapshot)
return ( return (
<> <>
@ -35,6 +47,10 @@ export function InitiativeOverviewPage() {
/> />
)} )}
{showCriticalPath && capabilities.has('kairo.action.read') && (
<CriticalPathPanel initiativeId={initiativeId} actions={actions} />
)}
{capabilities.has('kairo.workspace.read') && ( {capabilities.has('kairo.workspace.read') && (
<NextActionWidget <NextActionWidget
scope="initiative" scope="initiative"
@ -42,6 +58,16 @@ export function InitiativeOverviewPage() {
items={steeringSnapshot?.next_actions} items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading} loading={steeringSnapshotLoading}
embedded embedded
title={
showCriticalPath
? 'Nächster Schritt am kritischen Pfad'
: undefined
}
subtitle={
showCriticalPath
? 'Empfehlung aus sequential_dependency — bereite Arbeitspakete zuerst.'
: undefined
}
/> />
)} )}

View File

@ -704,3 +704,56 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
/* AP2.2b — kritischer Pfad (Kontrolle / A2) */
.critical-path-panel .critical-path-summary {
margin: 0 0 12px;
font-size: 14px;
}
.critical-path-next {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-bottom: 16px;
padding: 12px;
border-left: 3px solid var(--jk-primary);
background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
border-radius: 6px;
}
.critical-path-steps {
margin: 0;
padding: 0;
}
.critical-path-step {
display: flex;
align-items: flex-start;
gap: 10px;
}
.critical-path-step--next {
border-color: var(--jk-primary);
box-shadow: inset 3px 0 0 var(--jk-primary);
}
.critical-path-step--done {
opacity: 0.75;
}
.critical-path-step__index {
min-width: 1.5rem;
font-weight: 600;
font-size: 13px;
}
.critical-path-hint {
margin: 12px 0 0;
font-size: 13px;
}
.next-action-reason {
margin-left: 4px;
}

View File

@ -44,3 +44,126 @@ export function dependenciesForAction(dependencies, actionId) {
successors: list.filter((dep) => dep.predecessor_action_id === actionId), successors: list.filter((dep) => dep.predecessor_action_id === actionId),
} }
} }
/**
* @param {Array<{ id: string }>} actions
*/
export function actionByIdMap(actions) {
return Object.fromEntries((actions || []).map((a) => [a.id, a]))
}
/**
* First ready action on critical_path (AP2.2b).
* @param {Record<string, unknown>|null|undefined} graphState
* @param {Array<{ id: string, title?: string, status?: string }>} actions
*/
export function findNextReadyOnCriticalPath(graphState, actions) {
const path = graphState?.critical_path
if (!Array.isArray(path) || path.length === 0) return null
const byId = actionByIdMap(actions)
for (const rawId of path) {
const actionId = String(rawId)
const meta = getActionExecutionMeta(graphState, actionId)
if (!meta?.ready) continue
const action = byId[actionId]
if (!action || action.status === 'done' || action.status === 'discarded') continue
return { actionId, action, meta }
}
return null
}
/**
* @param {Record<string, unknown>|null|undefined} graphState
* @param {string} actionId
* @param {Array<{ id: string, title?: string }>} actions
*/
export function buildCriticalPathStepReason(graphState, actionId, actions) {
const meta = getActionExecutionMeta(graphState, actionId)
if (!meta) return 'Keine Graph-Daten'
if (meta.ready) {
return 'Bereit — keine offenen Vorgänger auf dem kritischen Pfad.'
}
if (meta.blocked && meta.blocked_by?.length) {
const waitingOn = formatBlockedByTitles(actions, meta.blocked_by)
return waitingOn
? `Wartet auf: ${waitingOn}`
: 'Wartet auf Vorgänger-Arbeitspakete.'
}
return 'Noch nicht ausführungsbereit.'
}
/**
* @param {Record<string, unknown>|null|undefined} graphState
* @param {Array<{ id: string, title?: string, status?: string }>} actions
*/
export function buildCriticalPathSteps(graphState, actions) {
const path = graphState?.critical_path
if (!Array.isArray(path) || path.length === 0) return []
const byId = actionByIdMap(actions)
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
return path.map((rawId, index) => {
const actionId = String(rawId)
const action = byId[actionId]
const meta = getActionExecutionMeta(graphState, actionId)
return {
index: index + 1,
actionId,
title: action?.title || actionId.slice(0, 8),
status: action?.status || meta?.status || 'open',
meta,
isNextReady: nextReady?.actionId === actionId,
reason: buildCriticalPathStepReason(graphState, actionId, actions),
}
})
}
/**
* @param {Record<string, unknown>|null|undefined} graphState
* @param {Array<{ id: string, title?: string, status?: string }>} actions
*/
export function summarizeCriticalPath(graphState, actions) {
const steps = buildCriticalPathSteps(graphState, actions)
if (steps.length === 0) {
return {
hasPath: false,
message: 'Noch kein kritischer Pfad — lege Arbeitspakete mit Abhängigkeiten an.',
}
}
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
if (nextReady) {
return {
hasPath: true,
message: `Nächster Schritt am kritischen Pfad: „${nextReady.action.title || 'Arbeitspaket'}".`,
nextActionId: nextReady.actionId,
}
}
const openOnPath = steps.filter(
(step) => step.status !== 'done' && step.status !== 'discarded',
)
if (openOnPath.length === 0) {
return {
hasPath: true,
message: 'Kritischer Pfad abgeschlossen — alle Schritte erledigt.',
}
}
const waiting = openOnPath.find((step) => step.meta?.blocked)
if (waiting) {
return {
hasPath: true,
message: `Kritischer Pfad wartet bei „${waiting.title}" — ${waiting.reason}`,
blockedActionId: waiting.actionId,
}
}
return {
hasPath: true,
message: 'Kritischer Pfad vorhanden — kein bereiter Schritt.',
}
}

View File

@ -4,6 +4,9 @@ import {
formatBlockedByTitles, formatBlockedByTitles,
getActionExecutionMeta, getActionExecutionMeta,
sortActionsForExecutionPlan, sortActionsForExecutionPlan,
findNextReadyOnCriticalPath,
summarizeCriticalPath,
buildCriticalPathSteps,
} from './executionGraph.js' } from './executionGraph.js'
describe('executionGraph utils', () => { describe('executionGraph utils', () => {
@ -42,4 +45,40 @@ describe('executionGraph utils', () => {
expect(dependenciesForAction(deps, 's1').predecessors).toHaveLength(1) expect(dependenciesForAction(deps, 's1').predecessors).toHaveLength(1)
expect(dependenciesForAction(deps, 'p1').successors).toHaveLength(1) expect(dependenciesForAction(deps, 'p1').successors).toHaveLength(1)
}) })
it('finds next ready step on critical path', () => {
const graphState = {
critical_path: ['a', 'b', 'c'],
items: {
a: { ready: false, blocked: true, blocked_by: ['x'] },
b: { ready: true, blocked: false, blocked_by: [] },
c: { ready: false, blocked: true, blocked_by: ['b'] },
},
}
const actions = [
{ id: 'a', title: 'Schritt A', status: 'open' },
{ id: 'b', title: 'Schritt B', status: 'ready' },
{ id: 'c', title: 'Schritt C', status: 'open' },
]
const next = findNextReadyOnCriticalPath(graphState, actions)
expect(next?.actionId).toBe('b')
expect(summarizeCriticalPath(graphState, actions).message).toContain('Schritt B')
})
it('builds critical path steps with reasons', () => {
const graphState = {
critical_path: ['first', 'second'],
items: {
first: { ready: true, blocked: false, blocked_by: [] },
second: { ready: false, blocked: true, blocked_by: ['first'] },
},
}
const actions = [
{ id: 'first', title: 'Erstes AP', status: 'done' },
{ id: 'second', title: 'Zweites AP', status: 'open' },
]
const steps = buildCriticalPathSteps(graphState, actions)
expect(steps).toHaveLength(2)
expect(steps[1].reason).toContain('Erstes AP')
})
}) })

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getNextActionCandidates } from '../api/attention.js' import { getNextActionCandidates } from '../api/attention.js'
import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js' import { NEXT_ACTION_KIND_LABELS, NEXT_ACTION_REASON_LABELS } from '../constants/operating.js'
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'
@ -52,6 +52,14 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
<span className="badge status-badge status-active next-action-kind"> <span className="badge status-badge status-active next-action-kind">
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind} {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
</span> </span>
{item.reason_code && NEXT_ACTION_REASON_LABELS[item.reason_code] && (
<span
className="badge status-badge status-progress next-action-reason"
title={item.reason_code}
>
{NEXT_ACTION_REASON_LABELS[item.reason_code]}
</span>
)}
<strong>{item.title}</strong> <strong>{item.title}</strong>
{item.summary && <p className="list-item-desc muted">{item.summary}</p>} {item.summary && <p className="list-item-desc muted">{item.summary}</p>}
{item.recommended_action && ( {item.recommended_action && (