|null, * secondary_actions: list>, * disabled_actions: list>, * disabled_reasons: array, * attention_reason: string, * mutation_scope: ?string, * high_risk: bool, * freshness_state: string * } */ public function forRun(OperationRun $run, ?User $user = null): array { $run->loadMissing(['workspace', 'tenant']); $canonicalType = $run->canonicalOperationType(); $freshnessState = $run->freshnessState(); $highRisk = $this->isHighRisk($canonicalType); $disabledReasons = []; $secondaryActions = []; $actionability = $this->actionabilityResolver->evaluate($run); if ($user instanceof User && Gate::forUser($user)->inspect('view', $run)->denied()) { return $this->result( primaryAction: null, secondaryActions: [], disabledReasons: [ 'view_details' => __('localization.operations.actions.disabled.scope_unavailable'), 'reconcile' => __('localization.operations.actions.disabled.scope_unavailable'), 'retry' => __('localization.operations.actions.disabled.scope_unavailable'), ], attentionReason: __('localization.operations.actions.attention.scope_unavailable'), mutationScope: null, highRisk: $highRisk, freshnessState: $freshnessState->value, ); } if ($run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP && ! $actionability->requiresCurrentFollowUp()) { $disabledReasons['reconcile'] = __('localization.operations.actions.disabled.no_current_follow_up'); $disabledReasons['retry'] = __('localization.operations.actions.disabled.no_current_follow_up'); $resolvedArtifactPrimary = $actionability->resolvingModelType !== null && $actionability->resolvingModelType !== 'provider_connection' ? $this->primaryRelatedAction($run) : null; if ($this->canViewDiagnostics($run, $user)) { $secondaryActions[] = $this->diagnosticsAction(); } else { $disabledReasons['open_support_diagnostics'] = __('localization.operations.actions.disabled.missing_diagnostics_capability'); } return $this->result( primaryAction: $resolvedArtifactPrimary ?? $this->viewDetailsAction($run), secondaryActions: $secondaryActions, disabledReasons: $disabledReasons, attentionReason: $actionability->explanation, mutationScope: null, highRisk: $highRisk, freshnessState: $freshnessState->value, ); } $relatedPrimary = $this->primaryRelatedAction($run); $canReconcile = $this->canOfferReconcile($run, $user); $reconcileAction = $this->reconcileAction($run); if (! $canReconcile) { $disabledReasons['reconcile'] = $this->reconcileDisabledReason($run, $user); } $retryReason = $this->retryDisabledReason($run, $highRisk); $disabledReasons['retry'] = $retryReason; if ($this->canViewDiagnostics($run, $user)) { $secondaryActions[] = $this->diagnosticsAction(); } else { $disabledReasons['open_support_diagnostics'] = __('localization.operations.actions.disabled.missing_diagnostics_capability'); } $primaryAction = match (true) { $canReconcile && ! $highRisk => $reconcileAction, $relatedPrimary !== null => $relatedPrimary, default => $this->viewDetailsAction($run), }; if ($canReconcile && $primaryAction['key'] !== 'reconcile') { array_unshift($secondaryActions, $reconcileAction); } return $this->result( primaryAction: $primaryAction, secondaryActions: $secondaryActions, disabledReasons: $disabledReasons, attentionReason: $this->attentionReason($run, $primaryAction, $retryReason, $actionability->explanation), mutationScope: $primaryAction['mutation_scope'] ?? null, highRisk: $highRisk, freshnessState: $freshnessState->value, ); } /** * @param list> $secondaryActions * @param array $disabledReasons * @return array{ * primary_action: array|null, * secondary_actions: list>, * disabled_actions: list>, * disabled_reasons: array, * attention_reason: string, * mutation_scope: ?string, * high_risk: bool, * freshness_state: string * } */ private function result( ?array $primaryAction, array $secondaryActions, array $disabledReasons, string $attentionReason, ?string $mutationScope, bool $highRisk, string $freshnessState, ): array { return [ 'primary_action' => $primaryAction, 'secondary_actions' => array_values($secondaryActions), 'disabled_actions' => array_map( static fn (string $key, string $reason): array => [ 'key' => $key, 'label' => __('localization.operations.actions.disabled_labels.'.$key), 'disabled_reason' => $reason, ], array_keys($disabledReasons), array_values($disabledReasons), ), 'disabled_reasons' => $disabledReasons, 'attention_reason' => $attentionReason, 'mutation_scope' => $mutationScope, 'high_risk' => $highRisk, 'freshness_state' => $freshnessState, ]; } private function canOfferReconcile(OperationRun $run, ?User $user): bool { if (! $run->isCurrentlyActive()) { return false; } $adapter = $this->reconciliationRegistry->forType($run->canonicalOperationType()); if (! $adapter instanceof OperationRunReconciliationAdapter) { return false; } if (! $run->freshnessState()->isLikelyStale()) { return false; } if ($run->isLifecycleReconciled()) { return false; } if ($user instanceof User && Gate::forUser($user)->inspect('reconcile', $run)->denied()) { return false; } return $this->hasSufficientReconciliationProof($adapter, $run); } private function reconcileDisabledReason(OperationRun $run, ?User $user): string { if ($run->isLifecycleReconciled()) { return __('localization.operations.actions.disabled.already_reconciled'); } if (! $run->isCurrentlyActive()) { return __('localization.operations.actions.disabled.terminal_run'); } $adapter = $this->reconciliationRegistry->forType($run->canonicalOperationType()); if (! $adapter instanceof OperationRunReconciliationAdapter) { return __('localization.operations.actions.disabled.unsupported_reconcile'); } if (! $run->freshnessState()->isLikelyStale()) { return __('localization.operations.actions.disabled.lifecycle_fresh'); } if ($user instanceof User && Gate::forUser($user)->inspect('reconcile', $run)->denied()) { return __('localization.operations.actions.disabled.missing_capability'); } if (! $this->hasSufficientReconciliationProof($adapter, $run)) { return __('localization.operations.actions.disabled.insufficient_proof'); } return __('localization.operations.actions.disabled.insufficient_proof'); } private function hasSufficientReconciliationProof( OperationRunReconciliationAdapter $adapter, OperationRun $run, ): bool { try { $result = $adapter->reconcile($run); } catch (Throwable) { return false; } return $result instanceof ReconciliationResult && $result->safeForAutoCompletion && $result->shouldFinalizeRun() && $result->decision !== 'attention_required'; } private function retryDisabledReason(OperationRun $run, bool $highRisk): string { if ($highRisk) { return __('localization.operations.actions.disabled.high_risk_retry'); } if ((string) $run->status === OperationRunStatus::Completed->value && (string) $run->outcome === OperationRunOutcome::Succeeded->value) { return __('localization.operations.actions.disabled.completed_succeeded'); } return __('localization.operations.actions.disabled.retry_deferred'); } private function canViewDiagnostics(OperationRun $run, ?User $user): bool { if (! $user instanceof User) { return false; } $tenant = $run->tenant; if ($tenant instanceof ManagedEnvironment) { return $this->tenantCapabilities->isMember($user, $tenant) && $this->tenantCapabilities->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW); } $workspace = $run->workspace; return $workspace instanceof Workspace && $this->workspaceCapabilities->isMember($user, $workspace) && $this->workspaceCapabilities->can($user, $workspace, Capabilities::AUDIT_VIEW); } private function isHighRisk(string $canonicalType): bool { if (in_array($canonicalType, [ OperationRunType::RestoreExecute->value, OperationRunType::PromotionExecute->value, ], true)) { return true; } if (! array_key_exists($canonicalType, OperationCatalog::canonicalInventory())) { return true; } foreach (['restore', 'delete', 'purge', 'force_delete', 'promotion'] as $needle) { if (str_contains($canonicalType, $needle)) { return true; } } return false; } /** * @return array|null */ private function primaryRelatedAction(OperationRun $run): ?array { $tenant = $run->tenant instanceof ManagedEnvironment ? $run->tenant : null; $links = OperationRunLinks::related($run, $tenant); unset($links[OperationRunLinks::collectionLabel()]); if ($links === []) { return null; } $canonicalType = $run->canonicalOperationType(); $preferred = match ($canonicalType) { OperationRunType::EnvironmentReviewCompose->value => ['ManagedEnvironment Review', 'Review'], OperationRunType::EvidenceSnapshotGenerate->value => ['Evidence Snapshot'], OperationRunType::ReviewPackGenerate->value => ['Review Pack', 'Report'], OperationRunType::RestoreExecute->value => ['Restore Run', 'Restore Runs'], OperationRunType::BackupScheduleExecute->value => ['Backup Set', 'Backup Sets'], OperationRunType::InventorySync->value => ['Inventory Coverage', 'Inventory'], default => [], }; $label = null; $url = null; foreach ($preferred as $candidate) { if (isset($links[$candidate])) { $label = $candidate; $url = $links[$candidate]; break; } } if ($label === null || $url === null) { $label = array_key_first($links); $url = $label !== null ? $links[$label] : null; } if (! is_string($label) || ! is_string($url) || $url === '') { return null; } return [ 'key' => $this->relatedActionKey($canonicalType, $label), 'label' => $this->relatedActionLabel($canonicalType, $label), 'type' => 'url', 'url' => $url, 'icon' => 'heroicon-o-arrow-top-right-on-square', 'color' => $canonicalType === OperationRunType::RestoreExecute->value ? 'warning' : 'primary', 'requires_confirmation' => false, 'mutation_scope' => null, 'related_label' => $label, ]; } private function relatedActionKey(string $canonicalType, string $label): string { return match ($canonicalType) { OperationRunType::EnvironmentReviewCompose->value => 'view_review', OperationRunType::EvidenceSnapshotGenerate->value => 'view_evidence', OperationRunType::ReviewPackGenerate->value => 'view_report', OperationRunType::RestoreExecute->value => 'view_restore_details', OperationRunType::BackupScheduleExecute->value => 'view_backup_details', OperationRunType::InventorySync->value => 'view_affected_families', default => 'view_'.str($label)->slug('_')->toString(), }; } private function relatedActionLabel(string $canonicalType, string $fallbackLabel): string { return match ($canonicalType) { OperationRunType::EnvironmentReviewCompose->value => __('localization.operations.actions.view_review'), OperationRunType::EvidenceSnapshotGenerate->value => __('localization.operations.actions.view_evidence'), OperationRunType::ReviewPackGenerate->value => __('localization.operations.actions.view_report'), OperationRunType::RestoreExecute->value => __('localization.operations.actions.view_restore_details'), OperationRunType::BackupScheduleExecute->value => __('localization.operations.actions.view_backup_details'), OperationRunType::InventorySync->value => __('localization.operations.actions.view_affected_families'), default => $fallbackLabel, }; } /** * @return array */ private function reconcileAction(OperationRun $run): array { return [ 'key' => 'reconcile', 'label' => __('localization.operations.actions.reconcile'), 'type' => 'action', 'url' => null, 'icon' => 'heroicon-o-wrench-screwdriver', 'color' => 'warning', 'requires_confirmation' => true, 'mutation_scope' => __('localization.operations.actions.mutation_scope_reconcile'), 'modal_heading' => __('localization.operations.actions.reconcile_heading'), 'modal_description' => __('localization.operations.actions.reconcile_description'), ]; } /** * @return array */ private function diagnosticsAction(): array { return [ 'key' => 'open_support_diagnostics', 'label' => __('localization.operations.actions.open_diagnostics'), 'type' => 'modal', 'url' => null, 'icon' => 'heroicon-o-lifebuoy', 'color' => 'gray', 'requires_confirmation' => false, 'mutation_scope' => null, ]; } /** * @return array */ private function viewDetailsAction(OperationRun $run): array { return [ 'key' => 'view_details', 'label' => __('localization.operations.actions.view_details'), 'type' => 'url', 'url' => OperationRunLinks::tenantlessView($run), 'icon' => 'heroicon-o-document-magnifying-glass', 'color' => 'gray', 'requires_confirmation' => false, 'mutation_scope' => null, ]; } /** * @param array|null $primaryAction */ private function attentionReason(OperationRun $run, ?array $primaryAction, string $retryReason, ?string $actionabilityExplanation = null): string { if ($run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP && is_string($actionabilityExplanation) && trim($actionabilityExplanation) !== '') { return $actionabilityExplanation; } if ($run->isLifecycleReconciled() && $run->reconciledRelatedType() !== null) { return __('localization.operations.actions.attention.related_available'); } if ($run->freshnessState()->isLikelyStale()) { return ($primaryAction['key'] ?? null) === 'reconcile' ? __('localization.operations.actions.attention.reconcile_available') : __('localization.operations.actions.attention.stale_review'); } if ($this->isHighRisk($run->canonicalOperationType())) { return $retryReason; } if ((string) $run->outcome === OperationRunOutcome::PartiallySucceeded->value) { return __('localization.operations.actions.attention.partial'); } if ((string) $run->outcome === OperationRunOutcome::Blocked->value) { return __('localization.operations.actions.attention.blocked'); } if ((string) $run->outcome === OperationRunOutcome::Failed->value) { return __('localization.operations.actions.attention.failed'); } if ($run->isCurrentlyActive()) { return __('localization.operations.actions.attention.active'); } return __('localization.operations.actions.attention.default'); } }