evaluateMany(collect([$run]))->get((int) $run->getKey()) ?? $this->defaultResult($run); } /** * @param Collection $runs * @return Collection */ 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', ); } }