TenantAtlas/apps/platform/app/Support/Operations/OperationRunActionEligibility.php
ahmido 564da05096 feat: implement operation run actionability system (#439)
This PR introduces the Operation Run Actionability System.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #439
2026-06-08 13:34:25 +00:00

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');
}
}