'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 */ public function getSelectionPayloadAttribute(): array { $context = is_array($this->context) ? $this->context : []; return Arr::only($context, [ 'policy_types', 'categories', 'include_foundations', 'include_dependencies', ]); } /** * @param array|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 */ 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 */ 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 */ 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(); } }