TenantAtlas/app/Models/OperationRun.php
ahmido 3a2a06e8d7 feat: align tenant dashboard truth surfaces (#204)
## Summary
- align tenant dashboard KPI, attention, compare, and operations truth so the page does not read calmer than the tenant's actual state
- preserve tenant-safe drill-through continuity into findings, baseline compare, and canonical operations, including disabled helper states for permission-limited members
- add the Spec 173 artifact set and focused regression coverage for dashboard truth alignment and drill-through behavior

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Findings/FindingsListDefaultsTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Findings/FindingAdminTenantParityTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Filament/TenantDashboardTenantScopeTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Feature/Filament/TableStandardsBaselineTest.php tests/Feature/Filament/TableDetailVisibilityTest.php`
- integrated browser smoke on the tenant dashboard, including a permission-limited member scenario

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #204
2026-04-03 20:26:15 +00:00

410 lines
15 KiB
PHP

<?php
namespace App\Models;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
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;
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 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
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
]);
})
->orWhere(function (Builder $activeQuery): void {
$activeQuery->likelyStale();
});
});
}
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 isGovernanceArtifactOperation(): bool
{
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
}
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);
}
public function requiresDashboardFollowUp(): bool
{
if ((string) $this->status === OperationRunStatus::Completed->value) {
return in_array((string) $this->outcome, [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
], true);
}
return $this->freshnessState()->isLikelyStale();
}
/**
* @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();
}
}