## Summary - add a workspace-scoped baseline compare matrix page under baseline profiles - derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings - add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run - preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics - include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190 ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #221
540 lines
19 KiB
PHP
540 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Support\Inventory\InventoryCoverage;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Operations\OperationLifecyclePolicy;
|
|
use App\Support\Operations\OperationRunFreshnessState;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class OperationRun extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
public const string PROBLEM_CLASS_NONE = 'none';
|
|
|
|
public const string PROBLEM_CLASS_ACTIVE_STALE_ATTENTION = 'active_stale_attention';
|
|
|
|
public const string PROBLEM_CLASS_TERMINAL_FOLLOW_UP = 'terminal_follow_up';
|
|
|
|
protected $guarded = [];
|
|
|
|
protected $casts = [
|
|
'summary_counts' => 'array',
|
|
'failure_summary' => 'array',
|
|
'context' => 'array',
|
|
'started_at' => 'datetime',
|
|
'completed_at' => 'datetime',
|
|
];
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::creating(function (self $operationRun): void {
|
|
if ($operationRun->workspace_id !== null) {
|
|
return;
|
|
}
|
|
|
|
if ($operationRun->tenant_id === null) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
if ($tenant->workspace_id === null) {
|
|
return;
|
|
}
|
|
|
|
$operationRun->workspace_id = (int) $tenant->workspace_id;
|
|
});
|
|
}
|
|
|
|
public function tenant(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Tenant::class)->withTrashed();
|
|
}
|
|
|
|
public function workspace(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Workspace::class);
|
|
}
|
|
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function scopeActive(Builder $query): Builder
|
|
{
|
|
return $query->whereIn('status', [
|
|
OperationRunStatus::Queued->value,
|
|
OperationRunStatus::Running->value,
|
|
]);
|
|
}
|
|
|
|
public function scopeTerminalFailure(Builder $query): Builder
|
|
{
|
|
return $query
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->where('outcome', OperationRunOutcome::Failed->value);
|
|
}
|
|
|
|
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
|
|
{
|
|
$profileId = $profile instanceof BaselineProfile
|
|
? (int) $profile->getKey()
|
|
: (int) $profile;
|
|
|
|
return $query
|
|
->where('type', OperationRunType::BaselineCompare->value)
|
|
->where('context->baseline_profile_id', $profileId);
|
|
}
|
|
|
|
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
|
{
|
|
$policy ??= app(OperationLifecyclePolicy::class);
|
|
|
|
return $query
|
|
->active()
|
|
->where(function (Builder $query) use ($policy): void {
|
|
foreach ($policy->coveredTypeNames() as $type) {
|
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
$typeQuery
|
|
->where('type', $type)
|
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
$stateQuery
|
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
$queuedQuery
|
|
->where('status', OperationRunStatus::Queued->value)
|
|
->whereNull('started_at')
|
|
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
})
|
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
$runningQuery
|
|
->where('status', OperationRunStatus::Running->value)
|
|
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
|
$startedAtQuery
|
|
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
$fallbackQuery
|
|
->whereNull('started_at')
|
|
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
|
{
|
|
$policy ??= app(OperationLifecyclePolicy::class);
|
|
$coveredTypes = $policy->coveredTypeNames();
|
|
|
|
if ($coveredTypes === []) {
|
|
return $query->active();
|
|
}
|
|
|
|
return $query
|
|
->active()
|
|
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
|
$query->whereNotIn('type', $coveredTypes);
|
|
|
|
foreach ($coveredTypes as $type) {
|
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
$typeQuery
|
|
->where('type', $type)
|
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
$stateQuery
|
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
$queuedQuery
|
|
->where('status', OperationRunStatus::Queued->value)
|
|
->where(function (Builder $freshQueuedQuery) use ($policy, $type): void {
|
|
$freshQueuedQuery
|
|
->whereNotNull('started_at')
|
|
->orWhereNull('created_at')
|
|
->orWhere('created_at', '>', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
});
|
|
})
|
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
$runningQuery
|
|
->where('status', OperationRunStatus::Running->value)
|
|
->where(function (Builder $freshRunningQuery) use ($policy, $type): void {
|
|
$freshRunningQuery
|
|
->where('started_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
$fallbackQuery
|
|
->whereNull('started_at')
|
|
->where(function (Builder $createdAtQuery) use ($policy, $type): void {
|
|
$createdAtQuery
|
|
->whereNull('created_at')
|
|
->orWhere('created_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public function scopeDashboardNeedsFollowUp(Builder $query): Builder
|
|
{
|
|
return $query->where(function (Builder $query): void {
|
|
$query
|
|
->where(function (Builder $terminalQuery): void {
|
|
$terminalQuery->terminalFollowUp();
|
|
})
|
|
->orWhere(function (Builder $activeQuery): void {
|
|
$activeQuery->activeStaleAttention();
|
|
});
|
|
});
|
|
}
|
|
|
|
public function scopeActiveStaleAttention(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
|
{
|
|
return $query->likelyStale($policy);
|
|
}
|
|
|
|
public function scopeTerminalFollowUp(Builder $query): Builder
|
|
{
|
|
return $query
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->where(function (Builder $query): void {
|
|
$query
|
|
->whereIn('outcome', [
|
|
OperationRunOutcome::Blocked->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
OperationRunOutcome::Failed->value,
|
|
])
|
|
->orWhereNotNull('context->reconciliation->reconciled_at');
|
|
});
|
|
}
|
|
|
|
public function getSelectionHashAttribute(): ?string
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
return isset($context['selection_hash']) && is_string($context['selection_hash'])
|
|
? $context['selection_hash']
|
|
: null;
|
|
}
|
|
|
|
public function setSelectionHashAttribute(?string $value): void
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
$context['selection_hash'] = $value;
|
|
|
|
$this->context = $context;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getSelectionPayloadAttribute(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
return Arr::only($context, [
|
|
'policy_types',
|
|
'categories',
|
|
'include_foundations',
|
|
'include_dependencies',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $value
|
|
*/
|
|
public function setSelectionPayloadAttribute(?array $value): void
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
if (is_array($value)) {
|
|
$context = array_merge($context, Arr::only($value, [
|
|
'policy_types',
|
|
'categories',
|
|
'include_foundations',
|
|
'include_dependencies',
|
|
]));
|
|
}
|
|
|
|
$this->context = $context;
|
|
}
|
|
|
|
public function getFinishedAtAttribute(): mixed
|
|
{
|
|
return $this->completed_at;
|
|
}
|
|
|
|
public function setFinishedAtAttribute(mixed $value): void
|
|
{
|
|
$this->completed_at = $value;
|
|
}
|
|
|
|
public function inventoryCoverage(): ?InventoryCoverage
|
|
{
|
|
return InventoryCoverage::fromContext($this->context);
|
|
}
|
|
|
|
public function isGovernanceArtifactOperation(): bool
|
|
{
|
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $tenantIds
|
|
* @return \Illuminate\Support\Collection<int, self>
|
|
*/
|
|
public static function latestBaselineCompareRunsForProfile(
|
|
BaselineProfile|int $profile,
|
|
array $tenantIds,
|
|
?int $workspaceId = null,
|
|
bool $completedOnly = false,
|
|
): \Illuminate\Support\Collection {
|
|
if ($tenantIds === []) {
|
|
return collect();
|
|
}
|
|
|
|
$runs = static::query()
|
|
->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
|
|
->whereIn('tenant_id', $tenantIds)
|
|
->baselineCompareForProfile($profile)
|
|
->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value))
|
|
->orderByDesc('completed_at')
|
|
->orderByDesc('id')
|
|
->get();
|
|
|
|
return $runs
|
|
->unique(static fn (self $run): int => (int) $run->tenant_id)
|
|
->values();
|
|
}
|
|
|
|
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
|
{
|
|
if ($tenantId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return static::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('type', 'inventory_sync')
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->whereNotNull('completed_at')
|
|
->latest('completed_at')
|
|
->latest('id')
|
|
->cursor()
|
|
->first(static fn (self $run): bool => $run->inventoryCoverage() instanceof InventoryCoverage);
|
|
}
|
|
|
|
public function supportsOperatorExplanation(): bool
|
|
{
|
|
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
|
}
|
|
|
|
public function governanceArtifactFamily(): ?string
|
|
{
|
|
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function artifactResultContext(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
|
|
|
return array_merge($context, ['result' => $result]);
|
|
}
|
|
|
|
public function relatedArtifactId(): ?int
|
|
{
|
|
return match ($this->governanceArtifactFamily()) {
|
|
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
|
? (int) data_get($this->context, 'result.snapshot_id')
|
|
: null,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function reconciliation(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
$reconciliation = $context['reconciliation'] ?? null;
|
|
|
|
return is_array($reconciliation) ? $reconciliation : [];
|
|
}
|
|
|
|
public function isLifecycleReconciled(): bool
|
|
{
|
|
return $this->reconciliation() !== [];
|
|
}
|
|
|
|
public function lifecycleReconciliationReasonCode(): ?string
|
|
{
|
|
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
|
|
|
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
|
}
|
|
|
|
public function freshnessState(): OperationRunFreshnessState
|
|
{
|
|
return OperationRunFreshnessState::forRun($this);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public static function problemClasses(): array
|
|
{
|
|
return [
|
|
self::PROBLEM_CLASS_NONE,
|
|
self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
|
self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
];
|
|
}
|
|
|
|
public function problemClass(): string
|
|
{
|
|
$freshnessState = $this->freshnessState();
|
|
|
|
if ($freshnessState->isLikelyStale()) {
|
|
return self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
|
}
|
|
|
|
if ($freshnessState->isReconciledFailed()) {
|
|
return self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP;
|
|
}
|
|
|
|
if ((string) $this->status === OperationRunStatus::Completed->value) {
|
|
return in_array((string) $this->outcome, [
|
|
OperationRunOutcome::Blocked->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
OperationRunOutcome::Failed->value,
|
|
], true)
|
|
? self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
: self::PROBLEM_CLASS_NONE;
|
|
}
|
|
|
|
return self::PROBLEM_CLASS_NONE;
|
|
}
|
|
|
|
public function hasStaleLineage(): bool
|
|
{
|
|
return $this->freshnessState()->isReconciledFailed();
|
|
}
|
|
|
|
public function isCurrentlyActive(): bool
|
|
{
|
|
return in_array((string) $this->status, [
|
|
OperationRunStatus::Queued->value,
|
|
OperationRunStatus::Running->value,
|
|
], true);
|
|
}
|
|
|
|
public function requiresOperatorReview(): bool
|
|
{
|
|
return $this->problemClass() !== self::PROBLEM_CLASS_NONE;
|
|
}
|
|
|
|
public function requiresDashboardFollowUp(): bool
|
|
{
|
|
return $this->requiresOperatorReview();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function baselineGapEnvelope(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
return match ((string) $this->type) {
|
|
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
|
? data_get($context, 'baseline_compare.evidence_gaps')
|
|
: [],
|
|
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
|
? data_get($context, 'baseline_capture.gaps')
|
|
: [],
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
public function hasStructuredBaselineGapPayload(): bool
|
|
{
|
|
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
|
|
|
|
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($subjects as $subject) {
|
|
if (! is_array($subject)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ([
|
|
'policy_type',
|
|
'subject_key',
|
|
'subject_class',
|
|
'resolution_path',
|
|
'resolution_outcome',
|
|
'reason_code',
|
|
'operator_action_category',
|
|
'structural',
|
|
'retryable',
|
|
] as $key) {
|
|
if (! array_key_exists($key, $subject)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function hasLegacyBaselineGapPayload(): bool
|
|
{
|
|
$envelope = $this->baselineGapEnvelope();
|
|
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
|
|
|
|
if (array_key_exists('policy_not_found', $byReason)) {
|
|
return true;
|
|
}
|
|
|
|
$subjects = $envelope['subjects'] ?? null;
|
|
|
|
if (! is_array($subjects)) {
|
|
return false;
|
|
}
|
|
|
|
if (! array_is_list($subjects)) {
|
|
return $subjects !== [];
|
|
}
|
|
|
|
if ($subjects === []) {
|
|
return false;
|
|
}
|
|
|
|
return ! $this->hasStructuredBaselineGapPayload();
|
|
}
|
|
}
|