All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 4m54s
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 13s
PO No-Go A1 adressieren: Tages-Tracking mit Messnotiz, Work-UI für Übung, Next-Action-Link, Gate-Fortschritt in Kontrolle, Eingang aus A1-Nav entfernt. Co-authored-by: Cursor <cursoragent@cursor.com>
311 lines
9.8 KiB
JavaScript
311 lines
9.8 KiB
JavaScript
/**
|
|
* AP-UX-1 — UX Composition Resolver (Frontend-Spiegel des Steering Kernels).
|
|
*/
|
|
|
|
import { hasSteeringElement, steeringElementUi } from '../registry/steeringElementRegistry.js'
|
|
import { getWidgetByKey } from '../registry/widgetRegistry.js'
|
|
import { filterSprintProposals } from '../utils/steeringProposals.js'
|
|
import { COMPOSITION_PROVIDERS, getProvidersForSlot } from './compositionProviders.js'
|
|
import { COMPOSITION_SURFACES, getCompositionSurface, surfaceKeyFor } from './uiSlotRegistry.js'
|
|
|
|
/** @typedef {import('./compositionProviders.js').CompositionProviderDefinition} CompositionProviderDefinition */
|
|
|
|
/**
|
|
* @typedef {Object} CompositionInput
|
|
* @property {string} [surfaceKey]
|
|
* @property {string} [mode]
|
|
* @property {string | null} [routeKey]
|
|
* @property {'portfolio' | 'initiative'} [scope]
|
|
* @property {string[] | null} [steeringElements]
|
|
* @property {object | null} [operatingContext]
|
|
* @property {object | null} [steeringSnapshot]
|
|
* @property {Set<string> | string[]} [capabilities]
|
|
* @property {Record<string, unknown>} [filterContext]
|
|
* @property {object | null} [opsContext]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {CompositionProviderDefinition & { instanceKey: string, props: Record<string, unknown> }} ResolvedProvider
|
|
*/
|
|
|
|
const CONDITIONAL_PREDICATES = {
|
|
hasRoadblockers(ctx) {
|
|
const counts = ctx.steeringSnapshot?.counts || {}
|
|
return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0
|
|
},
|
|
hasActiveWorkCycle(ctx) {
|
|
const cycle =
|
|
ctx.steeringSnapshot?.active_work_cycle
|
|
|| ctx.opsContext?.activeWorkCycle
|
|
|| null
|
|
return Boolean(cycle?.id)
|
|
},
|
|
isProductWithoutActiveSprint(ctx) {
|
|
if (CONDITIONAL_PREDICATES.hasActiveWorkCycle(ctx)) return false
|
|
const features =
|
|
ctx.operatingContext?.ui_features
|
|
|| ctx.opsContext?.uiFeatures
|
|
|| {}
|
|
const slices =
|
|
ctx.operatingContext?.data_slices
|
|
|| ctx.opsContext?.dataSlices
|
|
|| []
|
|
return Boolean(features.continuousProductWorkMode && slices.includes('work_cycles'))
|
|
},
|
|
}
|
|
|
|
/**
|
|
* @param {Set<string> | string[] | undefined} capabilities
|
|
* @param {string | undefined} required
|
|
*/
|
|
function hasCapability(capabilities, required) {
|
|
if (!required) return true
|
|
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities || [])
|
|
return capSet.has(required)
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionInput} input
|
|
*/
|
|
function resolveSteeringElements(input) {
|
|
if (Array.isArray(input.steeringElements)) return input.steeringElements
|
|
return input.operatingContext?.steering_elements || []
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionInput} input
|
|
*/
|
|
function resolveKernelProposals(input) {
|
|
return input.steeringSnapshot?.steering_kernel?.proposals || {}
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionInput} input
|
|
*/
|
|
function resolveAgentSlots(input) {
|
|
return (
|
|
input.steeringSnapshot?.agent_slots
|
|
|| input.steeringSnapshot?.steering_kernel?.agent_slots
|
|
|| []
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Next-Action-Überschriften: kritischster passender steering_element (Priorität).
|
|
* @param {string[]} elements
|
|
* @param {object | null | undefined} steeringSnapshot
|
|
*/
|
|
export function resolveNextActionUi(elements, steeringSnapshot) {
|
|
const priority = [
|
|
'critical_path',
|
|
'recurring_rhythm',
|
|
'work_cycle_scope',
|
|
'maturity_stage',
|
|
'gate_fulfillment',
|
|
'queue_inbox',
|
|
]
|
|
const hasActiveSprint = Boolean(steeringSnapshot?.active_work_cycle)
|
|
for (const key of priority) {
|
|
if (key === 'work_cycle_scope' && !hasActiveSprint) continue
|
|
const ui = steeringElementUi(elements, key)
|
|
if (ui?.nextActionTitle) return ui
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionProviderDefinition} provider
|
|
* @param {CompositionInput} input
|
|
*/
|
|
function isProviderActive(provider, input) {
|
|
const scope = input.scope || 'portfolio'
|
|
const scopeTypes = provider.scopeTypes || ['portfolio', 'initiative']
|
|
if (!scopeTypes.includes(scope)) return false
|
|
if (!hasCapability(input.capabilities, provider.requiresCapability)) return false
|
|
|
|
const elements = resolveSteeringElements(input)
|
|
const proposals = resolveKernelProposals(input)
|
|
const agentSlots = resolveAgentSlots(input)
|
|
|
|
switch (provider.kind) {
|
|
case 'steering_element':
|
|
return hasSteeringElement(elements, provider.steeringElement)
|
|
case 'proposal': {
|
|
const key = provider.proposalKey
|
|
if (!key) return false
|
|
let items = proposals[key] || []
|
|
if (key === 'sprint_commit' && input.filterContext?.workCycleId) {
|
|
items = filterSprintProposals(items, input.filterContext.workCycleId)
|
|
}
|
|
return items.length > 0
|
|
}
|
|
case 'agent_slots':
|
|
return agentSlots.length > 0
|
|
case 'widget': {
|
|
const widget = getWidgetByKey(provider.widgetKey)
|
|
if (!widget) return false
|
|
return hasCapability(input.capabilities, widget.requiredCapability)
|
|
}
|
|
case 'conditional': {
|
|
const fn = CONDITIONAL_PREDICATES[provider.predicateKey]
|
|
return typeof fn === 'function' ? fn(input) : false
|
|
}
|
|
case 'core':
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionProviderDefinition} provider
|
|
* @param {CompositionInput} input
|
|
*/
|
|
export function buildProviderProps(provider, input) {
|
|
const ops = input.opsContext || {}
|
|
const proposals = resolveKernelProposals(input)
|
|
const agentSlots = resolveAgentSlots(input)
|
|
const elements = resolveSteeringElements(input)
|
|
const capabilities = input.capabilities instanceof Set
|
|
? input.capabilities
|
|
: new Set(input.capabilities || [])
|
|
|
|
switch (provider.componentKey) {
|
|
case 'SteeringSnapshotPanel':
|
|
return {
|
|
snapshot: input.steeringSnapshot,
|
|
loading: ops.steeringSnapshotLoading,
|
|
error: ops.steeringSnapshotError,
|
|
methods: ops.steeringMethods,
|
|
canManageMethod: capabilities.has('kairo.initiative.manage'),
|
|
onMethodChange: ops.handleMethodChange,
|
|
methodBusy: ops.methodBusy,
|
|
showGateHorizon: hasSteeringElement(elements, 'gate_fulfillment'),
|
|
}
|
|
case 'CriticalPathPanel': {
|
|
const kernel = input.steeringSnapshot?.steering_kernel
|
|
return {
|
|
initiativeId: ops.initiativeId,
|
|
actions: ops.actions || [],
|
|
graphState: kernel?.read_models?.execution_graph ?? null,
|
|
graphLoading: ops.steeringSnapshotLoading,
|
|
scopeRoadmapItemId: kernel?.horizon?.gate_roadmap_item_id ?? null,
|
|
}
|
|
}
|
|
case 'MaturityStagePanel':
|
|
return {
|
|
initiativeId: ops.initiativeId,
|
|
roadmapItems: ops.roadmapItems || [],
|
|
}
|
|
case 'RecurringRhythmPanel':
|
|
return {
|
|
initiativeId: ops.initiativeId,
|
|
recurringItems: ops.recurringItems || [],
|
|
}
|
|
case 'WorkTodayPracticePanel':
|
|
return {
|
|
initiativeId: ops.initiativeId,
|
|
recurringItems: ops.recurringItems || [],
|
|
canManage: capabilities.has('kairo.recurring.manage'),
|
|
busy: ops.formBusy,
|
|
onCompleted: async () => {
|
|
if (typeof ops.refreshOm === 'function') {
|
|
await ops.refreshOm(['recurring', 'steering_snapshot'])
|
|
}
|
|
},
|
|
}
|
|
case 'NextActionWidget': {
|
|
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
|
|
return {
|
|
scope: 'initiative',
|
|
initiativeId: ops.initiativeId,
|
|
items: input.steeringSnapshot?.next_actions,
|
|
loading: ops.steeringSnapshotLoading,
|
|
embedded: true,
|
|
title: nextActionUi?.nextActionTitle,
|
|
subtitle: nextActionUi?.nextActionSubtitle,
|
|
}
|
|
}
|
|
case 'AgentSlotsPanel':
|
|
return {
|
|
slots: agentSlots,
|
|
loading: ops.steeringSnapshotLoading,
|
|
}
|
|
case 'RoadblockersStrip': {
|
|
const counts = input.steeringSnapshot?.counts || {}
|
|
return {
|
|
initiativeId: ops.initiativeId,
|
|
actionsBlocked: counts.actions_blocked || 0,
|
|
blockersOpen: counts.blockers_open || 0,
|
|
}
|
|
}
|
|
case 'SteeringProposalsPanel':
|
|
return {
|
|
proposalsByKey: proposals,
|
|
proposalKeys: provider.proposalKey ? [provider.proposalKey] : undefined,
|
|
filterContext: input.filterContext || {},
|
|
backlogVocabulary: input.operatingContext?.backlog_vocabulary,
|
|
canManage: capabilities.has('kairo.backlog.manage'),
|
|
onAcceptBacklogProposal: ops.onAcceptBacklogProposal,
|
|
busy: ops.formBusy,
|
|
}
|
|
case 'WidgetHost': {
|
|
const widget = getWidgetByKey(provider.widgetKey)
|
|
return { widget }
|
|
}
|
|
case 'WorkActionsPanel':
|
|
return {
|
|
variant: provider.workActionsVariant || 'today',
|
|
defaultCollapsed: true,
|
|
}
|
|
default:
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionProviderDefinition} provider
|
|
* @param {CompositionInput} input
|
|
* @returns {ResolvedProvider | null}
|
|
*/
|
|
export function resolveProviderInstance(provider, input) {
|
|
if (!isProviderActive(provider, input)) return null
|
|
return {
|
|
...provider,
|
|
instanceKey: `${provider.key}:${provider.slotKeys[0]}`,
|
|
props: buildProviderProps(provider, input),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} slotKey
|
|
* @param {CompositionInput} input
|
|
* @returns {ResolvedProvider[]}
|
|
*/
|
|
export function resolveSlotComposition(slotKey, input) {
|
|
return getProvidersForSlot(slotKey)
|
|
.map((provider) => resolveProviderInstance(provider, input))
|
|
.filter(Boolean)
|
|
}
|
|
|
|
/**
|
|
* @param {CompositionInput} input
|
|
*/
|
|
export function resolveSteeringComposition(input) {
|
|
const surfaceKey = input.surfaceKey
|
|
|| surfaceKeyFor(/** @type {import('./uiSlotRegistry.js').CompositionMode} */ (input.mode), input.routeKey ?? null)
|
|
const surface = getCompositionSurface(surfaceKey)
|
|
if (!surface) {
|
|
return { surfaceKey, slots: {} }
|
|
}
|
|
|
|
/** @type {Record<string, ResolvedProvider[]>} */
|
|
const slots = {}
|
|
for (const slotKey of surface.slotKeys) {
|
|
slots[slotKey] = resolveSlotComposition(slotKey, input)
|
|
}
|
|
return { surfaceKey, slots }
|
|
}
|
|
|
|
/** Exported for tests */
|
|
export { COMPOSITION_PROVIDERS, isProviderActive, CONDITIONAL_PREDICATES }
|