Automated PR created by Codex via Gitea API. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #459
414 lines
19 KiB
PHP
414 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ReviewPublicationResolution;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\ReviewPublicationResolutionCase;
|
|
use App\Models\ReviewPublicationResolutionStep;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class ReviewPublicationStepPlanner
|
|
{
|
|
public function __construct(
|
|
private readonly ReviewPublicationProofResolver $proofResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $readiness
|
|
* @return array{
|
|
* steps:list<array<string, mixed>>,
|
|
* case_status:ReviewPublicationResolutionCaseStatus,
|
|
* current_step_key:?string
|
|
* }
|
|
*/
|
|
public function plan(EnvironmentReview $review, array $readiness, ?ReviewPublicationResolutionCase $case = null): array
|
|
{
|
|
$existing = $case instanceof ReviewPublicationResolutionCase
|
|
? $case->loadMissing(['steps.operationRun'])->steps->keyBy('step_key')
|
|
: new Collection;
|
|
|
|
$steps = [];
|
|
|
|
foreach ($this->relevantStepKeys($readiness, $existing) as $index => $stepKey) {
|
|
$steps[] = $this->stepPlan(
|
|
review: $review,
|
|
readiness: $readiness,
|
|
stepKey: $stepKey,
|
|
position: $index + 1,
|
|
existingStep: $existing->get($stepKey->value),
|
|
);
|
|
}
|
|
|
|
$steps = $this->activateFirstIncompleteStep($steps);
|
|
$currentStep = collect($steps)->first(
|
|
static fn (array $step): bool => in_array($step['status'], [
|
|
ReviewPublicationResolutionStepStatus::Actionable->value,
|
|
ReviewPublicationResolutionStepStatus::Running->value,
|
|
ReviewPublicationResolutionStepStatus::Failed->value,
|
|
], true),
|
|
);
|
|
|
|
$caseStatus = $this->caseStatus($steps, is_array($currentStep) ? (string) $currentStep['step_key'] : null);
|
|
|
|
return [
|
|
'steps' => $steps,
|
|
'case_status' => $caseStatus,
|
|
'current_step_key' => is_array($currentStep) ? (string) $currentStep['step_key'] : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $readiness
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function stepPlan(
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
int $position,
|
|
?ReviewPublicationResolutionStep $existingStep,
|
|
): array {
|
|
$status = $this->baseStatus($stepKey, $readiness, $existingStep);
|
|
$proofEvaluation = $this->proofResolver->evaluationFor($stepKey, $review, $readiness, $existingStep);
|
|
$proof = $proofEvaluation->toStepPayload();
|
|
|
|
if ($this->requiresCurrentProof($stepKey)) {
|
|
if ($proofEvaluation->canCompleteStep()) {
|
|
$status = ReviewPublicationResolutionStepStatus::Completed;
|
|
} elseif ($this->shouldReopenForCurrentProof($status, $proofEvaluation)) {
|
|
$status = ReviewPublicationResolutionStepStatus::Pending;
|
|
}
|
|
}
|
|
|
|
if ($existingStep instanceof ReviewPublicationResolutionStep
|
|
&& in_array($status, [
|
|
ReviewPublicationResolutionStepStatus::Running,
|
|
ReviewPublicationResolutionStepStatus::Failed,
|
|
], true)
|
|
&& ($proof['proof_visibility'] ?? null) !== ResolutionProofVisibility::Hidden->value) {
|
|
$proof = $this->mergeExistingProofFallback($existingStep, $proof);
|
|
}
|
|
|
|
return [
|
|
'position' => $position,
|
|
'step_key' => $stepKey->value,
|
|
'status' => $status->value,
|
|
'primary_action_key' => $stepKey->primaryActionKey(),
|
|
'operation_run_id' => $proof['operation_run_id'],
|
|
'proof_type' => $proof['proof_type'],
|
|
'proof_id' => $proof['proof_id'],
|
|
'proof_status' => $proof['proof_status'],
|
|
'summary' => array_replace($this->summary($stepKey, $readiness, $status), [
|
|
'proof_label' => $this->proofLabel($proof),
|
|
'proof_state_description' => $this->proofStateDescription($proof),
|
|
'proof_reason_code' => $proof['proof_reason_code'],
|
|
'proof_currentness' => $proof['proof_currentness'],
|
|
'proof_usability' => $proof['proof_usability'],
|
|
'proof_visibility' => $proof['proof_visibility'],
|
|
'proof_summary' => $proof['proof_summary'],
|
|
]),
|
|
'metadata' => [
|
|
'readiness_fingerprint' => (string) $readiness['fingerprint'],
|
|
'planned_at' => now()->toIso8601String(),
|
|
'proof_currentness' => $proof['proof_currentness'],
|
|
'proof_usability' => $proof['proof_usability'],
|
|
'proof_visibility' => $proof['proof_visibility'],
|
|
'proof_reason_code' => $proof['proof_reason_code'],
|
|
'proof_evaluated_at' => $proof['proof_evaluated_at'],
|
|
'proof_timestamp' => $proof['proof_timestamp'],
|
|
'proof_summary' => $proof['proof_summary'],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $readiness
|
|
*/
|
|
private function baseStatus(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
array $readiness,
|
|
?ReviewPublicationResolutionStep $existingStep,
|
|
): ReviewPublicationResolutionStepStatus {
|
|
$readinessStatus = $this->readinessStatus($stepKey, $readiness);
|
|
|
|
if ($readinessStatus === ReviewPublicationResolutionStepStatus::Completed) {
|
|
return ReviewPublicationResolutionStepStatus::Completed;
|
|
}
|
|
|
|
if ($existingStep instanceof ReviewPublicationResolutionStep) {
|
|
if ($stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication
|
|
&& $existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Completed) {
|
|
return ReviewPublicationResolutionStepStatus::Completed;
|
|
}
|
|
|
|
if ($existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Running && $existingStep->operationRun?->status !== OperationRunStatus::Completed->value) {
|
|
return ReviewPublicationResolutionStepStatus::Running;
|
|
}
|
|
|
|
if ($existingStep->operationRun?->status === OperationRunStatus::Completed->value
|
|
&& in_array((string) $existingStep->operationRun->outcome, [OperationRunOutcome::Failed->value, OperationRunOutcome::Blocked->value], true)) {
|
|
return ReviewPublicationResolutionStepStatus::Failed;
|
|
}
|
|
|
|
}
|
|
|
|
return $readinessStatus;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, ReviewPublicationResolutionStep> $existing
|
|
* @return list<ReviewPublicationResolutionStepKey>
|
|
*/
|
|
private function relevantStepKeys(array $readiness, Collection $existing): array
|
|
{
|
|
$keys = [
|
|
ReviewPublicationResolutionStepKey::ValidateReviewReadiness,
|
|
];
|
|
|
|
if (((array) ($readiness['missing_report_dimensions'] ?? [])) !== []
|
|
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CompleteRequiredReports)) {
|
|
$keys[] = ReviewPublicationResolutionStepKey::CompleteRequiredReports;
|
|
}
|
|
|
|
if ((bool) ($readiness['evidence_incomplete'] ?? true)
|
|
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot)) {
|
|
$keys[] = ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot;
|
|
}
|
|
|
|
if ((bool) ($readiness['review_requires_refresh'] ?? true)
|
|
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::RefreshReviewComposition)) {
|
|
$keys[] = ReviewPublicationResolutionStepKey::RefreshReviewComposition;
|
|
}
|
|
|
|
if (! (bool) ($readiness['has_ready_export'] ?? false)
|
|
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::GenerateReviewPack)) {
|
|
$keys[] = ReviewPublicationResolutionStepKey::GenerateReviewPack;
|
|
}
|
|
|
|
$keys[] = ReviewPublicationResolutionStepKey::ReturnToPublication;
|
|
|
|
return $keys;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, ReviewPublicationResolutionStep> $existing
|
|
*/
|
|
private function hasExistingStep(Collection $existing, ReviewPublicationResolutionStepKey $stepKey): bool
|
|
{
|
|
$step = $existing->get($stepKey->value);
|
|
|
|
return $step instanceof ReviewPublicationResolutionStep
|
|
&& $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Superseded;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $readiness
|
|
*/
|
|
private function readinessStatus(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
array $readiness,
|
|
): ReviewPublicationResolutionStepStatus {
|
|
return match ($stepKey) {
|
|
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => ReviewPublicationResolutionStepStatus::Completed,
|
|
ReviewPublicationResolutionStepKey::CompleteRequiredReports => ((array) ($readiness['missing_report_dimensions'] ?? [])) === []
|
|
? ReviewPublicationResolutionStepStatus::Completed
|
|
: ReviewPublicationResolutionStepStatus::Pending,
|
|
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => (bool) ($readiness['evidence_incomplete'] ?? true)
|
|
? ReviewPublicationResolutionStepStatus::Pending
|
|
: ReviewPublicationResolutionStepStatus::Completed,
|
|
ReviewPublicationResolutionStepKey::RefreshReviewComposition => (bool) ($readiness['review_requires_refresh'] ?? true)
|
|
? ReviewPublicationResolutionStepStatus::Pending
|
|
: ReviewPublicationResolutionStepStatus::Completed,
|
|
ReviewPublicationResolutionStepKey::GenerateReviewPack => (bool) ($readiness['has_ready_export'] ?? false)
|
|
? ReviewPublicationResolutionStepStatus::Completed
|
|
: ReviewPublicationResolutionStepStatus::Pending,
|
|
ReviewPublicationResolutionStepKey::ReturnToPublication => ReviewPublicationResolutionStepStatus::Pending,
|
|
};
|
|
}
|
|
|
|
private function requiresCurrentProof(ReviewPublicationResolutionStepKey $stepKey): bool
|
|
{
|
|
return in_array($stepKey, [
|
|
ReviewPublicationResolutionStepKey::CompleteRequiredReports,
|
|
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot,
|
|
ReviewPublicationResolutionStepKey::RefreshReviewComposition,
|
|
ReviewPublicationResolutionStepKey::GenerateReviewPack,
|
|
], true);
|
|
}
|
|
|
|
private function shouldReopenForCurrentProof(
|
|
ReviewPublicationResolutionStepStatus $status,
|
|
ResolutionProofEvaluation $proofEvaluation,
|
|
): bool {
|
|
return match ($status) {
|
|
ReviewPublicationResolutionStepStatus::Completed => true,
|
|
ReviewPublicationResolutionStepStatus::Running => ! $this->isCurrentRunningOperationProof($proofEvaluation),
|
|
ReviewPublicationResolutionStepStatus::Failed => ! $this->isCurrentTerminalOperationProof($proofEvaluation),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
private function isCurrentRunningOperationProof(ResolutionProofEvaluation $proofEvaluation): bool
|
|
{
|
|
return $proofEvaluation->status === ResolutionProofStatus::Running
|
|
&& $proofEvaluation->currentness === ResolutionProofCurrentness::Current
|
|
&& $proofEvaluation->usability === ResolutionProofUsability::InspectionOnly
|
|
&& $proofEvaluation->visibility === ResolutionProofVisibility::OperatorVisible
|
|
&& $proofEvaluation->reasonCode === 'proof.operation_running';
|
|
}
|
|
|
|
private function isCurrentTerminalOperationProof(ResolutionProofEvaluation $proofEvaluation): bool
|
|
{
|
|
return $proofEvaluation->status === ResolutionProofStatus::Failed
|
|
&& $proofEvaluation->currentness === ResolutionProofCurrentness::Current
|
|
&& $proofEvaluation->usability === ResolutionProofUsability::InspectionOnly
|
|
&& $proofEvaluation->visibility === ResolutionProofVisibility::OperatorVisible
|
|
&& $proofEvaluation->reasonCode === 'proof.operation_terminal_without_current_artifact';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $proof
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function mergeExistingProofFallback(ReviewPublicationResolutionStep $existingStep, array $proof): array
|
|
{
|
|
if (! is_string($existingStep->proof_type) && ! is_numeric($existingStep->proof_id) && ! is_numeric($existingStep->operation_run_id)) {
|
|
return $proof;
|
|
}
|
|
|
|
return array_replace($proof, [
|
|
'proof_type' => is_string($existingStep->proof_type) ? $existingStep->proof_type : $proof['proof_type'],
|
|
'proof_id' => is_numeric($existingStep->proof_id) ? (int) $existingStep->proof_id : $proof['proof_id'],
|
|
'proof_status' => is_string($existingStep->proof_status) ? $existingStep->proof_status : $proof['proof_status'],
|
|
'operation_run_id' => is_numeric($existingStep->operation_run_id) ? (int) $existingStep->operation_run_id : $proof['operation_run_id'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $proof
|
|
*/
|
|
private function proofLabel(array $proof): string
|
|
{
|
|
$status = (string) ($proof['proof_status'] ?? '');
|
|
$currentness = (string) ($proof['proof_currentness'] ?? '');
|
|
$usability = (string) ($proof['proof_usability'] ?? '');
|
|
$visibility = (string) ($proof['proof_visibility'] ?? '');
|
|
$reasonCode = (string) ($proof['proof_reason_code'] ?? '');
|
|
|
|
return match (true) {
|
|
$visibility === ResolutionProofVisibility::Hidden->value,
|
|
$visibility === ResolutionProofVisibility::OperatorLimited->value => 'Not available with your permissions',
|
|
$status === ResolutionProofStatus::Missing->value => 'Proof missing',
|
|
$status === ResolutionProofStatus::Running->value => 'Operation running',
|
|
$status === ResolutionProofStatus::Failed->value => 'Action failed',
|
|
$currentness === ResolutionProofCurrentness::Stale->value => 'Outdated proof',
|
|
$currentness === ResolutionProofCurrentness::Superseded->value,
|
|
str_contains($reasonCode, 'supersede') => 'Superseded by newer result',
|
|
$usability === ResolutionProofUsability::Usable->value,
|
|
$usability === ResolutionProofUsability::UsableWithWarning->value => 'Current proof',
|
|
default => 'Proof cannot be verified',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $proof
|
|
*/
|
|
private function proofStateDescription(array $proof): string
|
|
{
|
|
$reasonCode = (string) ($proof['proof_reason_code'] ?? '');
|
|
|
|
return match (true) {
|
|
str_contains($reasonCode, 'supersede') => 'A newer current artifact is available, so the older operation result is diagnostics-only.',
|
|
str_contains($reasonCode, 'without_artifact') => 'The operation finished, but the expected artifact is still missing.',
|
|
str_contains($reasonCode, 'running') => 'The linked operation can be inspected, but it does not complete this step yet.',
|
|
str_contains($reasonCode, 'stale') => 'The linked proof no longer matches the current review inputs.',
|
|
str_contains($reasonCode, 'missing') => 'TenantPilot has not found current proof for this step.',
|
|
str_contains($reasonCode, 'type_mismatch') => 'The linked operation does not match this resolution step.',
|
|
str_contains($reasonCode, 'context_missing'),
|
|
str_contains($reasonCode, 'context_mismatch') => 'The linked operation does not match this review publication case.',
|
|
str_contains($reasonCode, 'scope_mismatch') => 'Proof is not available for this workspace or environment.',
|
|
default => 'TenantPilot evaluated proof against the current review-publication state.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $steps
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function activateFirstIncompleteStep(array $steps): array
|
|
{
|
|
foreach ($steps as $index => $step) {
|
|
if ($step['status'] !== ReviewPublicationResolutionStepStatus::Pending->value) {
|
|
continue;
|
|
}
|
|
|
|
$steps[$index]['status'] = ReviewPublicationResolutionStepStatus::Actionable->value;
|
|
$steps[$index]['summary']['state_description'] = 'Ready for operator action.';
|
|
|
|
break;
|
|
}
|
|
|
|
return $steps;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $steps
|
|
*/
|
|
private function caseStatus(array $steps, ?string $currentStepKey): ReviewPublicationResolutionCaseStatus
|
|
{
|
|
if ($currentStepKey === null) {
|
|
return ReviewPublicationResolutionCaseStatus::Completed;
|
|
}
|
|
|
|
$currentStep = collect($steps)->firstWhere('step_key', $currentStepKey);
|
|
|
|
if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Running->value) {
|
|
return ReviewPublicationResolutionCaseStatus::WaitingForRun;
|
|
}
|
|
|
|
if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Failed->value) {
|
|
return ReviewPublicationResolutionCaseStatus::Blocked;
|
|
}
|
|
|
|
if ($currentStepKey === ReviewPublicationResolutionStepKey::ReturnToPublication->value) {
|
|
return ReviewPublicationResolutionCaseStatus::ReadyToContinue;
|
|
}
|
|
|
|
return ReviewPublicationResolutionCaseStatus::InProgress;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $readiness
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function summary(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
array $readiness,
|
|
ReviewPublicationResolutionStepStatus $status,
|
|
): array {
|
|
return [
|
|
'label' => $stepKey->label(),
|
|
'description' => match ($stepKey) {
|
|
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Review readiness has been evaluated from current evidence and section state.',
|
|
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required report-backed evidence dimensions must be current before publication can continue.',
|
|
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The evidence snapshot must be complete and current for the review output.',
|
|
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Review sections must be recomposed from current evidence before publication.',
|
|
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'A current review pack is required before returning to publication.',
|
|
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review publication action after blockers are resolved.',
|
|
},
|
|
'state_description' => $status === ReviewPublicationResolutionStepStatus::Completed
|
|
? 'Requirement is satisfied.'
|
|
: 'Waiting for prerequisite steps.',
|
|
'publication_blocker_count' => count((array) ($readiness['publication_blockers'] ?? [])),
|
|
'missing_report_dimensions' => array_values((array) ($readiness['missing_report_dimensions'] ?? [])),
|
|
'evidence_state' => (string) ($readiness['evidence_state'] ?? ''),
|
|
'review_status' => (string) ($readiness['review_status'] ?? ''),
|
|
'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false),
|
|
];
|
|
}
|
|
}
|