This PR introduces the Operation Run Actionability System. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #439
492 lines
19 KiB
PHP
492 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Operations;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Operations\Actionability\OperationRunActionabilityResolver;
|
|
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
|
|
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
|
|
use App\Support\Operations\Reconciliation\ReconciliationResult;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Throwable;
|
|
|
|
final class OperationRunActionEligibility
|
|
{
|
|
public function __construct(
|
|
private readonly OperationRunReconciliationRegistry $reconciliationRegistry,
|
|
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
|
private readonly CapabilityResolver $tenantCapabilities,
|
|
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
|
|
private readonly OperationRunActionabilityResolver $actionabilityResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* primary_action: array<string, mixed>|null,
|
|
* secondary_actions: list<array<string, mixed>>,
|
|
* disabled_actions: list<array<string, mixed>>,
|
|
* disabled_reasons: array<string, string>,
|
|
* 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<array<string, mixed>> $secondaryActions
|
|
* @param array<string, string> $disabledReasons
|
|
* @return array{
|
|
* primary_action: array<string, mixed>|null,
|
|
* secondary_actions: list<array<string, mixed>>,
|
|
* disabled_actions: list<array<string, mixed>>,
|
|
* disabled_reasons: array<string, string>,
|
|
* 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<string, mixed>|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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>|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');
|
|
}
|
|
}
|