414 lines
12 KiB
PHP
414 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\OpsUx;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Support\OperationRunStatus;
|
|
|
|
final class OperationRunProgressContract
|
|
{
|
|
public const string NONE = 'none';
|
|
|
|
public const string ACTIVITY = 'activity';
|
|
|
|
public const string COUNTED = 'counted';
|
|
|
|
public const string PHASED = 'phased';
|
|
|
|
public const string COMPOSITE = 'composite';
|
|
|
|
/**
|
|
* @return array{
|
|
* capability: string,
|
|
* display: string,
|
|
* label: ?string,
|
|
* processed: ?int,
|
|
* total: ?int,
|
|
* percent: ?int
|
|
* }
|
|
*/
|
|
public static function forRun(OperationRun $run): array
|
|
{
|
|
$summaryCounts = SummaryCountsNormalizer::normalize(
|
|
is_array($run->summary_counts) ? $run->summary_counts : [],
|
|
);
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$capability = self::capabilityForRun($run, $summaryCounts, $context);
|
|
|
|
return match ($capability) {
|
|
self::COUNTED => self::countedModel($summaryCounts),
|
|
self::PHASED => self::phasedModel($run, $context),
|
|
self::COMPOSITE => self::compositeModel($run, $summaryCounts, $context),
|
|
self::ACTIVITY => self::indeterminateModel(
|
|
self::ACTIVITY,
|
|
(string) $run->status === OperationRunStatus::Queued->value
|
|
? 'Waiting for worker.'
|
|
: 'Progress details pending.',
|
|
),
|
|
default => self::noneModel(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $summaryCounts
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private static function capabilityForRun(OperationRun $run, array $summaryCounts, array $context): string
|
|
{
|
|
if (! $run->isCurrentlyActive()) {
|
|
return self::NONE;
|
|
}
|
|
|
|
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
|
return self::ACTIVITY;
|
|
}
|
|
|
|
if (self::hasPhasedHint($context)) {
|
|
return self::PHASED;
|
|
}
|
|
|
|
if (self::hasCompositeHint($summaryCounts, $context)) {
|
|
return self::COMPOSITE;
|
|
}
|
|
|
|
if (self::hasCountedHint($summaryCounts)) {
|
|
return self::COUNTED;
|
|
}
|
|
|
|
return self::ACTIVITY;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $summaryCounts
|
|
*/
|
|
private static function hasCountedHint(array $summaryCounts): bool
|
|
{
|
|
$total = $summaryCounts['total'] ?? null;
|
|
$processed = $summaryCounts['processed'] ?? null;
|
|
|
|
return is_int($total)
|
|
&& $total > 0
|
|
&& is_int($processed);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $summaryCounts
|
|
* @return array{
|
|
* capability: string,
|
|
* display: string,
|
|
* label: string,
|
|
* processed: int,
|
|
* total: int,
|
|
* percent: int
|
|
* }
|
|
*/
|
|
private static function countedModel(array $summaryCounts): array
|
|
{
|
|
$total = max(1, (int) ($summaryCounts['total'] ?? 0));
|
|
$processed = min(max(0, (int) ($summaryCounts['processed'] ?? 0)), $total);
|
|
$percent = max(0, min(100, (int) round(($processed / $total) * 100)));
|
|
|
|
return [
|
|
'capability' => self::COUNTED,
|
|
'display' => self::COUNTED,
|
|
'label' => sprintf('%d / %d processed (%d%%)', $processed, $total, $percent),
|
|
'processed' => $processed,
|
|
'total' => $total,
|
|
'percent' => $percent,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{
|
|
* capability: string,
|
|
* display: string,
|
|
* label: string,
|
|
* processed: null,
|
|
* total: null,
|
|
* percent: null
|
|
* }
|
|
*/
|
|
private static function phasedModel(OperationRun $run, array $context): array
|
|
{
|
|
$phase = self::phaseProgressMetadata($context);
|
|
|
|
if ($phase !== null) {
|
|
return self::indeterminateModel(self::PHASED, $phase['label']);
|
|
}
|
|
|
|
return self::indeterminateModel(
|
|
self::PHASED,
|
|
self::legacyPhasedLabel($run, $context) ?? 'Phase progress pending.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $summaryCounts
|
|
* @param array<string, mixed> $context
|
|
* @return array{
|
|
* capability: string,
|
|
* display: string,
|
|
* label: string,
|
|
* processed: null,
|
|
* total: null,
|
|
* percent: null
|
|
* }
|
|
*/
|
|
private static function compositeModel(OperationRun $run, array $summaryCounts, array $context): array
|
|
{
|
|
$label = self::explicitCompositeLabel($context)
|
|
?? self::legacyCompositeLabel($run, $summaryCounts, $context)
|
|
?? 'Composite progress pending.';
|
|
|
|
return self::indeterminateModel(self::COMPOSITE, $label);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* capability: string,
|
|
* display: string,
|
|
* label: string,
|
|
* processed: null,
|
|
* total: null,
|
|
* percent: null
|
|
* }
|
|
*/
|
|
private static function indeterminateModel(string $capability, string $label): array
|
|
{
|
|
return [
|
|
'capability' => $capability,
|
|
'display' => 'indeterminate',
|
|
'label' => $label,
|
|
'processed' => null,
|
|
'total' => null,
|
|
'percent' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* capability: string,
|
|
* display: string,
|
|
* label: null,
|
|
* processed: null,
|
|
* total: null,
|
|
* percent: null
|
|
* }
|
|
*/
|
|
private static function noneModel(): array
|
|
{
|
|
return [
|
|
'capability' => self::NONE,
|
|
'display' => self::NONE,
|
|
'label' => null,
|
|
'processed' => null,
|
|
'total' => null,
|
|
'percent' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private static function hasPhasedHint(array $context): bool
|
|
{
|
|
if (self::phaseProgressMetadata($context) !== null) {
|
|
return true;
|
|
}
|
|
|
|
foreach (['baseline_capture.evidence_capture', 'baseline_compare.evidence_capture'] as $path) {
|
|
$phaseStats = data_get($context, $path);
|
|
|
|
if (is_array($phaseStats) && self::looksLikePhaseStats($phaseStats)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $phaseStats
|
|
*/
|
|
private static function looksLikePhaseStats(array $phaseStats): bool
|
|
{
|
|
return count(array_intersect(
|
|
array_keys($phaseStats),
|
|
['requested', 'succeeded', 'skipped', 'failed', 'throttled'],
|
|
)) >= 2;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $summaryCounts
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private static function hasCompositeHint(array $summaryCounts, array $context): bool
|
|
{
|
|
if (self::explicitCompositeLabel($context) !== null) {
|
|
return true;
|
|
}
|
|
|
|
$operationCount = $summaryCounts['operation_count'] ?? null;
|
|
|
|
if (is_int($operationCount) && $operationCount > 1) {
|
|
return true;
|
|
}
|
|
|
|
foreach (['child_run_ids', 'operation_run_ids'] as $path) {
|
|
$runIds = data_get($context, $path);
|
|
|
|
if (! is_array($runIds)) {
|
|
continue;
|
|
}
|
|
|
|
$numericIds = array_filter($runIds, static fn (mixed $runId): bool => is_numeric($runId));
|
|
|
|
if (count($numericIds) > 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{key: string, label: string}|null
|
|
*/
|
|
private static function phaseProgressMetadata(array $context): ?array
|
|
{
|
|
$phase = data_get($context, 'progress.phase');
|
|
|
|
if (! is_array($phase)) {
|
|
return null;
|
|
}
|
|
|
|
$key = self::cleanString($phase['key'] ?? null);
|
|
|
|
if ($key === null || ! in_array($key, ['preparing', 'fetching', 'processing', 'persisting', 'finalizing'], true)) {
|
|
return null;
|
|
}
|
|
|
|
$label = self::cleanString($phase['label'] ?? null) ?? self::defaultPhaseLabel($key);
|
|
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
];
|
|
}
|
|
|
|
private static function defaultPhaseLabel(string $key): string
|
|
{
|
|
return match ($key) {
|
|
'preparing' => 'Preparing work.',
|
|
'fetching' => 'Collecting required evidence.',
|
|
'processing' => 'Processing current work.',
|
|
'persisting' => 'Saving results.',
|
|
'finalizing' => 'Finalizing operation.',
|
|
default => 'Phase progress pending.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private static function explicitCompositeLabel(array $context): ?string
|
|
{
|
|
return self::cleanString(data_get($context, 'progress.composite.label'));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private static function legacyPhasedLabel(OperationRun $run, array $context): ?string
|
|
{
|
|
return match ((string) $run->type) {
|
|
'baseline_capture' => is_array(data_get($context, 'baseline_capture.evidence_capture'))
|
|
? 'Capturing evidence.'
|
|
: null,
|
|
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
|
? 'Refreshing comparison evidence.'
|
|
: null,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $summaryCounts
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private static function legacyCompositeLabel(OperationRun $run, array $summaryCounts, array $context): ?string
|
|
{
|
|
if ((string) $run->type !== 'tenant.review.compose') {
|
|
return null;
|
|
}
|
|
|
|
$operationCount = self::intOrNull($summaryCounts['operation_count'] ?? data_get($context, 'progress.composite.operation_count'));
|
|
$failedCount = self::intOrNull(data_get($context, 'progress.composite.failed_count'));
|
|
$partialCount = self::intOrNull(data_get($context, 'progress.composite.partial_count'));
|
|
|
|
$baseLabel = $operationCount !== null && $operationCount > 0
|
|
? sprintf('Review composition is aggregating %d %s.', $operationCount, $operationCount === 1 ? 'operation' : 'operations')
|
|
: 'Review composition is aggregating related operations.';
|
|
|
|
$attentionLabel = self::compositeAttentionLabel($failedCount, $partialCount);
|
|
|
|
return $attentionLabel === null
|
|
? $baseLabel
|
|
: sprintf('%s %s', $baseLabel, $attentionLabel);
|
|
}
|
|
|
|
private static function compositeAttentionLabel(?int $failedCount, ?int $partialCount): ?string
|
|
{
|
|
$failedCount = $failedCount !== null && $failedCount > 0 ? $failedCount : null;
|
|
$partialCount = $partialCount !== null && $partialCount > 0 ? $partialCount : null;
|
|
|
|
if ($failedCount === null && $partialCount === null) {
|
|
return null;
|
|
}
|
|
|
|
if ($failedCount !== null && $partialCount !== null) {
|
|
return sprintf(
|
|
'%d %s and %d %s currently need review.',
|
|
$failedCount,
|
|
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
|
$partialCount,
|
|
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
|
);
|
|
}
|
|
|
|
if ($failedCount !== null) {
|
|
return sprintf(
|
|
'%d %s currently need review.',
|
|
$failedCount,
|
|
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
|
);
|
|
}
|
|
|
|
return sprintf(
|
|
'%d %s currently need review.',
|
|
$partialCount,
|
|
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
|
);
|
|
}
|
|
|
|
private static function cleanString(mixed $value): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
private static function intOrNull(mixed $value): ?int
|
|
{
|
|
return is_int($value) ? $value : null;
|
|
}
|
|
} |