TenantAtlas/apps/platform/app/Support/Operations/Actionability/OperationRunActionabilityResolver.php
Ahmed Darrazi 0329cb5420
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m0s
feat: implement operation run actionability system
2026-06-08 15:19:55 +02:00

405 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
use App\Models\BackupSet;
use App\Models\BaselineSnapshot;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
final class OperationRunActionabilityResolver
{
public function __construct(
private readonly OperationRunActionabilityRegistry $registry,
) {}
public function evaluate(OperationRun $run): OperationRunActionabilityResult
{
return $this->evaluateMany(collect([$run]))->get((int) $run->getKey())
?? $this->defaultResult($run);
}
/**
* @param Collection<int, OperationRun> $runs
* @return Collection<int, OperationRunActionabilityResult>
*/
public function evaluateMany(Collection $runs): Collection
{
$runs = $runs->values();
$context = new OperationRunActionabilityEvaluationContext($runs, $this->registry);
return $runs->mapWithKeys(fn (OperationRun $run): array => [
(int) $run->getKey() => $this->evaluateWithContext($run, $context)->withRun($run),
]);
}
/**
* Applies current terminal follow-up semantics to an already-scoped query.
*/
public function applyCurrentTerminalFollowUpScope(Builder $query): Builder
{
$candidateRuns = (clone $query)
->terminalFollowUp()
->select('operation_runs.*')
->get();
if ($candidateRuns->isEmpty()) {
return $query->whereRaw('1 = 0');
}
$actionableIds = $this->evaluateMany($candidateRuns)
->filter(static fn (OperationRunActionabilityResult $result): bool => $result->requiresCurrentFollowUp())
->keys()
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
if ($actionableIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn('operation_runs.id', $actionableIds);
}
private function evaluateWithContext(
OperationRun $run,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
if (! $this->isTerminalProblem($run)) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::NotTerminal,
reasonCode: 'not_terminal_problem',
explanation: 'This run is not a terminal problem and does not drive current follow-up.',
policyIdentifier: 'terminal_problem_gate_v1',
);
}
$definition = $this->registry->forCanonicalType($run->canonicalOperationType());
if (! $definition instanceof OperationRunActionabilityPolicyDefinition) {
return $this->manualReview(
reasonCode: 'unknown_operation_type',
explanation: 'This operation type has no explicit actionability policy, so it remains visible for manual review.',
policyIdentifier: 'unknown_manual_review_v1',
);
}
return match ($definition->kind) {
'provider_connection' => $this->providerConnectionResult($run, $definition, $context),
'repeatable' => $this->laterSuccessResult($run, $definition, $context),
'artifact_or_later_success' => $this->artifactOrLaterSuccessResult($run, $definition, $context),
'manual_review' => $this->manualReview(
reasonCode: 'manual_review_required',
explanation: 'This operation family is high impact or destructive-like, so terminal problems require deliberate review.',
policyIdentifier: $definition->policyIdentifier,
),
'informational' => new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::InformationalOnly,
reasonCode: 'informational_history',
explanation: 'This terminal record is historical context and does not represent current operator follow-up.',
policyIdentifier: $definition->policyIdentifier,
),
default => $this->manualReview(
reasonCode: 'unsupported_policy_kind',
explanation: 'This actionability policy kind is not supported, so the run remains visible for manual review.',
policyIdentifier: $definition->policyIdentifier,
),
};
}
private function providerConnectionResult(
OperationRun $run,
OperationRunActionabilityPolicyDefinition $definition,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
$laterSuccess = $context->laterSuccessfulRun(
$run,
$definition->supersededByCanonicalTypes,
$definition->matchContextKeys,
);
if ($laterSuccess instanceof OperationRun) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::SupersededByLaterSuccess,
reasonCode: 'later_provider_check_succeeded',
explanation: 'A later same-scope provider connection check succeeded, so this old terminal blocker is not current follow-up.',
supersedingRunId: (int) $laterSuccess->getKey(),
policyIdentifier: $definition->policyIdentifier,
);
}
$connection = $context->healthyProviderConnection($run);
if ($connection !== null) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::ResolvedByCurrentState,
reasonCode: 'provider_connection_currently_healthy',
explanation: 'The same provider connection is currently enabled, consented, and healthy.',
resolvingModelType: 'provider_connection',
resolvingModelId: (int) $connection->getKey(),
policyIdentifier: $definition->policyIdentifier,
);
}
return $this->actionable(
reasonCode: 'provider_connection_still_needs_review',
explanation: 'No later same-scope successful check or healthy provider connection state proves this blocker is resolved.',
policyIdentifier: $definition->policyIdentifier,
);
}
private function laterSuccessResult(
OperationRun $run,
OperationRunActionabilityPolicyDefinition $definition,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
$laterSuccess = $context->laterSuccessfulRun(
$run,
$definition->supersededByCanonicalTypes,
$definition->matchContextKeys,
);
if ($laterSuccess instanceof OperationRun) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::SupersededByLaterSuccess,
reasonCode: 'later_same_scope_success',
explanation: 'A later same-scope successful run proves this old terminal problem is no longer current follow-up.',
supersedingRunId: (int) $laterSuccess->getKey(),
policyIdentifier: $definition->policyIdentifier,
);
}
return $this->actionable(
reasonCode: 'no_later_same_scope_success',
explanation: 'No later same-scope successful run proves this terminal problem has been resolved.',
policyIdentifier: $definition->policyIdentifier,
);
}
private function artifactOrLaterSuccessResult(
OperationRun $run,
OperationRunActionabilityPolicyDefinition $definition,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
$later = $this->laterSuccessResult($run, $definition, $context);
if (! $later->requiresCurrentFollowUp()) {
return $later;
}
$artifact = $this->resolvingArtifact($run);
if ($artifact !== null) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::ResolvedByCurrentState,
reasonCode: 'current_artifact_available',
explanation: 'A same-scope current artifact exists for this operation family, so this old terminal problem is not current follow-up.',
resolvingModelType: $artifact['type'],
resolvingModelId: $artifact['id'],
policyIdentifier: $definition->policyIdentifier,
);
}
return $later;
}
/**
* @return array{type:string,id:int}|null
*/
private function resolvingArtifact(OperationRun $run): ?array
{
return match ($run->canonicalOperationType()) {
'baseline.capture' => $this->baselineSnapshotArtifact($run),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotArtifact($run),
'environment.review.compose' => $this->environmentReviewArtifact($run),
'environment.review_pack.generate' => $this->reviewPackArtifact($run),
'backup_set.update', 'backup.schedule.execute', 'backup.schedule.retention' => $this->backupSetArtifact($run),
default => null,
};
}
/**
* @return array{type:string,id:int}|null
*/
private function baselineSnapshotArtifact(OperationRun $run): ?array
{
$snapshotId = $run->reconciledRelatedBaselineSnapshotId()
?? (is_numeric(data_get($run->context, 'baseline_snapshot_id')) ? (int) data_get($run->context, 'baseline_snapshot_id') : null)
?? (is_numeric(data_get($run->context, 'result.snapshot_id')) ? (int) data_get($run->context, 'result.snapshot_id') : null);
if ($snapshotId === null) {
return null;
}
$snapshot = BaselineSnapshot::query()
->whereKey($snapshotId)
->where('workspace_id', (int) $run->workspace_id)
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->first();
return $snapshot instanceof BaselineSnapshot
? ['type' => 'baseline_snapshot', 'id' => (int) $snapshot->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function evidenceSnapshotArtifact(OperationRun $run): ?array
{
$snapshotId = $run->reconciledRelatedEvidenceSnapshotId();
$query = EvidenceSnapshot::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->where('status', EvidenceSnapshotStatus::Active->value)
->where('completeness_state', EvidenceCompletenessState::Complete->value);
if ($snapshotId !== null) {
$query->whereKey($snapshotId);
} else {
$query->where('operation_run_id', (int) $run->getKey());
}
$snapshot = $query->latest('id')->first();
return $snapshot instanceof EvidenceSnapshot
? ['type' => 'evidence_snapshot', 'id' => (int) $snapshot->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function environmentReviewArtifact(OperationRun $run): ?array
{
$reviewId = $run->reconciledRelatedReviewId();
$query = EnvironmentReview::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id);
if ($reviewId !== null) {
$query->whereKey($reviewId);
} else {
$query->where('operation_run_id', (int) $run->getKey());
}
$review = $query->latest('id')->first();
return $review instanceof EnvironmentReview
? ['type' => 'environment_review', 'id' => (int) $review->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function reviewPackArtifact(OperationRun $run): ?array
{
$packId = $run->reconciledRelatedReviewPackId();
$query = ReviewPack::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->where('status', ReviewPack::STATUS_READY);
if ($packId !== null) {
$query->whereKey($packId);
} else {
$query->where('operation_run_id', (int) $run->getKey());
}
$pack = $query->latest('id')->first();
return $pack instanceof ReviewPack
? ['type' => 'review_pack', 'id' => (int) $pack->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function backupSetArtifact(OperationRun $run): ?array
{
$backupSetId = $run->reconciledRelatedBackupSetId()
?? (is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null);
if ($backupSetId === null) {
return null;
}
$backupSet = BackupSet::query()
->whereKey($backupSetId)
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->whereNotNull('completed_at')
->first();
return $backupSet instanceof BackupSet
? ['type' => 'backup_set', 'id' => (int) $backupSet->getKey()]
: null;
}
private function isTerminalProblem(OperationRun $run): bool
{
if ((string) $run->status !== OperationRunStatus::Completed->value) {
return false;
}
if ((string) $run->outcome === OperationRunOutcome::Succeeded->value) {
return false;
}
if ($run->isLifecycleReconciled()) {
return true;
}
return in_array((string) $run->outcome, [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
], true);
}
private function actionable(string $reasonCode, string $explanation, string $policyIdentifier): OperationRunActionabilityResult
{
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::Actionable,
reasonCode: $reasonCode,
explanation: $explanation,
policyIdentifier: $policyIdentifier,
);
}
private function manualReview(string $reasonCode, string $explanation, string $policyIdentifier): OperationRunActionabilityResult
{
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::RequiresManualReview,
reasonCode: $reasonCode,
explanation: $explanation,
policyIdentifier: $policyIdentifier,
);
}
private function defaultResult(OperationRun $run): OperationRunActionabilityResult
{
return $this->manualReview(
reasonCode: 'evaluation_missing',
explanation: 'Actionability could not be evaluated, so the run remains visible for manual review.',
policyIdentifier: 'evaluation_missing_v1',
);
}
}