/** * 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[]} [capabilities] * @property {Record} [filterContext] * @property {object | null} [opsContext] */ /** * @typedef {CompositionProviderDefinition & { instanceKey: string, props: Record }} 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[] | 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} */ 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 }