This PR introduces the Operation Run Actionability System. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #439
405 lines
15 KiB
PHP
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',
|
|
);
|
|
}
|
|
}
|