Compare commits
6 Commits
158-artifa
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 |
12
.github/agents/copilot-instructions.md
vendored
12
.github/agents/copilot-instructions.md
vendored
@ -100,6 +100,12 @@ ## Active Technologies
|
||||
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -119,8 +125,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
||||
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 162-baseline-gap-details: Added PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PurgeLegacyBaselineGapRuns extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||
{--workspace=* : Limit cleanup to workspace ids}
|
||||
{--limit=500 : Maximum candidate runs to inspect}
|
||||
{--force : Actually delete matched legacy runs}';
|
||||
|
||||
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
$this->error('This cleanup command is limited to local and testing environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$types = $this->normalizedTypes();
|
||||
$workspaceIds = array_values(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||
(array) $this->option('workspace'),
|
||||
),
|
||||
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||
));
|
||||
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$dryRun = ! (bool) $this->option('force');
|
||||
|
||||
$query = OperationRun::query()
|
||||
->whereIn('type', $types)
|
||||
->orderBy('id')
|
||||
->limit($limit);
|
||||
|
||||
if ($workspaceIds !== []) {
|
||||
$query->whereIn('workspace_id', $workspaceIds);
|
||||
}
|
||||
|
||||
if ($tenantIds !== []) {
|
||||
$query->whereIn('tenant_id', $tenantIds);
|
||||
}
|
||||
|
||||
$candidates = $query->get();
|
||||
$matched = $candidates
|
||||
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
||||
->values();
|
||||
|
||||
if ($matched->isEmpty()) {
|
||||
$this->info('No legacy baseline gap runs matched the current filters.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
||||
$matched
|
||||
->map(fn (OperationRun $run): array => [
|
||||
'Run' => (string) $run->getKey(),
|
||||
'Type' => (string) $run->type,
|
||||
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
||||
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||
'Legacy signal' => $this->legacySignal($run),
|
||||
])
|
||||
->all(),
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn(sprintf(
|
||||
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
||||
$matched->count(),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
OperationRun::query()
|
||||
->whereKey($matched->modelKeys())
|
||||
->delete();
|
||||
|
||||
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizedTypes(): array
|
||||
{
|
||||
$types = array_values(array_unique(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||
(array) $this->option('type'),
|
||||
),
|
||||
)));
|
||||
|
||||
if ($types === []) {
|
||||
return ['baseline_compare', 'baseline_capture'];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$types,
|
||||
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
{
|
||||
if ($tenantIdentifiers === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tenantIds));
|
||||
}
|
||||
|
||||
private function legacySignal(OperationRun $run): string
|
||||
{
|
||||
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
||||
$byReason = is_array($byReason) ? $byReason : [];
|
||||
|
||||
if (array_key_exists('policy_not_found', $byReason)) {
|
||||
return 'legacy_reason_code';
|
||||
}
|
||||
|
||||
return 'legacy_subject_shape';
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -18,8 +19,10 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
||||
): int {
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'running') {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.stalled',
|
||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
||||
|
||||
if ($change !== null) {
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
|
||||
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileOperationRuns extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:operation-runs:reconcile
|
||||
{--type=* : Limit reconciliation to one or more covered operation types}
|
||||
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
||||
{--workspace=* : Limit reconciliation to workspace ids}
|
||||
{--limit=100 : Maximum number of active runs to inspect}
|
||||
{--dry-run : Report the changes without writing them}';
|
||||
|
||||
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
||||
|
||||
public function handle(
|
||||
OperationLifecycleReconciler $reconciler,
|
||||
OperationLifecyclePolicy $policy,
|
||||
): int {
|
||||
$types = array_values(array_filter(
|
||||
(array) $this->option('type'),
|
||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||
));
|
||||
$workspaceIds = array_values(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||
(array) $this->option('workspace'),
|
||||
),
|
||||
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||
));
|
||||
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($types === []) {
|
||||
$types = $policy->coveredTypeNames();
|
||||
}
|
||||
|
||||
$result = $reconciler->reconcile([
|
||||
'types' => $types,
|
||||
'tenant_ids' => $tenantIds,
|
||||
'workspace_ids' => $workspaceIds,
|
||||
'limit' => max(1, (int) $this->option('limit')),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
$rows = collect($result['changes'] ?? [])
|
||||
->map(static function (array $change): array {
|
||||
return [
|
||||
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
||||
'Type' => (string) ($change['type'] ?? '—'),
|
||||
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
||||
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Inspected %d run(s); reconciled %d; skipped %d.',
|
||||
(int) ($result['candidates'] ?? 0),
|
||||
(int) ($result['reconciled'] ?? 0),
|
||||
(int) ($result['skipped'] ?? 0),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment('Dry-run: no changes written.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
{
|
||||
if ($tenantIdentifiers === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tenantIds));
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -59,6 +60,8 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?int $duplicateNamePoliciesCount = null;
|
||||
|
||||
public ?int $duplicateNameSubjectsCount = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
@ -86,9 +89,21 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $evidenceGapsTopReasons = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $evidenceGapSummary = null;
|
||||
|
||||
/** @var list<array<string, mixed>>|null */
|
||||
public ?array $evidenceGapBuckets = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $baselineCompareDiagnostics = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $rbacRoleDefinitionSummary = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $operatorExplanation = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -123,6 +138,7 @@ public function refreshStats(): void
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
@ -139,7 +155,17 @@ public function refreshStats(): void
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
||||
? $stats->evidenceGapDetails['summary']
|
||||
: null;
|
||||
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
||||
? $stats->evidenceGapDetails['buckets']
|
||||
: null;
|
||||
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
||||
? $stats->baselineCompareDiagnostics
|
||||
: null;
|
||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -152,26 +178,32 @@ public function refreshStats(): void
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||
? (int) $evidenceGapSummary['count']
|
||||
: (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
||||
? (string) $evidenceGapSummary['detail_state']
|
||||
: 'no_gaps';
|
||||
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
||||
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
if ($hasEvidenceGaps) {
|
||||
$parts = array_map(
|
||||
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||
BaselineCompareEvidenceGapDetails::topReasons(
|
||||
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||
5,
|
||||
),
|
||||
);
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
@ -207,6 +239,9 @@ protected function getViewData(): array
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
@ -307,9 +342,22 @@ private function compareNowAction(): Action
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||
default => 'Reason: '.$reasonCode,
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||
->body($message)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
@ -82,14 +83,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($this->selectedAuditLogId !== null) {
|
||||
$this->selectedAuditLog();
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->selectedAuditLogId = $requestedEventId;
|
||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,31 +101,10 @@ public function mount(): void
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
|
||||
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
||||
$actions[] = Action::make('clear_selected_audit_event')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->clearSelectedAuditLog();
|
||||
});
|
||||
|
||||
$relatedLink = $this->selectedAuditLink();
|
||||
|
||||
if (is_array($relatedLink)) {
|
||||
$actions[] = Action::make('open_selected_audit_target')
|
||||
->label($relatedLink['label'])
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url($relatedLink['url']);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -195,9 +177,19 @@ public function table(Table $table): Table
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (AuditLogModel $record): void {
|
||||
->before(function (AuditLogModel $record): void {
|
||||
$this->selectedAuditLogId = (int) $record->getKey();
|
||||
}),
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
|
||||
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
|
||||
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||
'selectedAudit' => $record,
|
||||
'selectedAuditLink' => $this->auditTargetLink($record),
|
||||
])),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
@ -209,48 +201,11 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clearSelectedAuditLog(): void
|
||||
{
|
||||
$this->selectedAuditLogId = null;
|
||||
}
|
||||
|
||||
public function selectedAuditLog(): ?AuditLogModel
|
||||
{
|
||||
if (! is_numeric($this->selectedAuditLogId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->auditBaseQuery()
|
||||
->whereKey((int) $this->selectedAuditLogId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function selectedAuditLink(): ?array
|
||||
{
|
||||
$record = $this->selectedAuditLog();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
@ -323,6 +278,54 @@ private function auditBaseQuery(): Builder
|
||||
->latestFirst();
|
||||
}
|
||||
|
||||
private function resolveAuditLog(int $auditLogId): AuditLogModel
|
||||
{
|
||||
$record = $this->auditBaseQuery()
|
||||
->whereKey($auditLogId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function selectedAuditRecord(): ?AuditLogModel
|
||||
{
|
||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||
} catch (NotFoundHttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function selectedAuditTargetLink(): ?array
|
||||
{
|
||||
$record = $this->selectedAuditRecord();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
private function auditTargetLink(AuditLogModel $record): ?array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -165,6 +166,68 @@ public function table(Table $table): Table
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{likely_stale:int,reconciled:int}
|
||||
*/
|
||||
public function lifecycleVisibilitySummary(): array
|
||||
{
|
||||
$baseQuery = $this->scopedSummaryQuery();
|
||||
|
||||
if (! $baseQuery instanceof Builder) {
|
||||
return [
|
||||
'likely_stale' => 0,
|
||||
'reconciled' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$reconciled = (clone $baseQuery)
|
||||
->whereNotNull('context->reconciliation->reconciled_at')
|
||||
->count();
|
||||
|
||||
$policy = app(OperationLifecyclePolicy::class);
|
||||
$likelyStale = (clone $baseQuery)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->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)));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'likely_stale' => $likelyStale,
|
||||
'reconciled' => $reconciled,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyActiveTab(Builder $query): Builder
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
@ -187,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private function scopedSummaryQuery(): ?Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -170,11 +172,18 @@ public function blockedExecutionBanner(): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||
];
|
||||
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||
? array_values(array_filter([
|
||||
$operatorExplanation->headline,
|
||||
$operatorExplanation->dominantCauseExplanation,
|
||||
OperationUxPresenter::surfaceGuidance($this->run),
|
||||
]))
|
||||
: ($reasonEnvelope?->toBodyLines() ?? [
|
||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||
]);
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
@ -183,6 +192,40 @@ public function blockedExecutionBanner(): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
public function lifecycleBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||
|
||||
if ($attention === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
||||
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
||||
|
||||
return match ($this->run->freshnessState()->value) {
|
||||
'likely_stale' => [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Likely stale run',
|
||||
'body' => $body,
|
||||
],
|
||||
'reconciled_failed' => [
|
||||
'tone' => 'rose',
|
||||
'title' => 'Automatically reconciled',
|
||||
'body' => $body,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
@ -417,4 +460,13 @@ private function relatedLinksTenant(): ?Tenant
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
)->allowed ? $tenant : null;
|
||||
}
|
||||
|
||||
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
||||
{
|
||||
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,7 +122,7 @@ public function table(Table $table): Table
|
||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
@ -154,7 +154,7 @@ public function table(Table $table): Table
|
||||
)->iconColor),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
|
||||
@ -6,19 +6,28 @@
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -288,15 +297,32 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('None'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Baseline truth')
|
||||
->schema([
|
||||
TextEntry::make('current_snapshot_truth')
|
||||
->label('Current snapshot')
|
||||
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
||||
TextEntry::make('latest_attempted_snapshot_truth')
|
||||
->label('Latest attempt')
|
||||
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
||||
TextEntry::make('compare_readiness')
|
||||
->label('Compare readiness')
|
||||
->badge()
|
||||
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
||||
TextEntry::make('baseline_next_step')
|
||||
->label('Next step')
|
||||
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('createdByUser.name')
|
||||
->label('Created by')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot yet'),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
@ -355,10 +381,27 @@ public static function table(Table $table): Table
|
||||
TextColumn::make('tenant_assignments_count')
|
||||
->label('Assigned tenants')
|
||||
->counts('tenantAssignments'),
|
||||
TextColumn::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot'),
|
||||
TextColumn::make('current_snapshot_truth')
|
||||
->label('Current snapshot')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
||||
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('latest_attempted_snapshot_truth')
|
||||
->label('Latest attempt')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
||||
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('compare_readiness')
|
||||
->label('Compare readiness')
|
||||
->badge()
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
||||
->wrap(),
|
||||
TextColumn::make('baseline_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||
->wrap(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@ -545,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
||||
{
|
||||
$snapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
return 'No complete snapshot';
|
||||
}
|
||||
|
||||
return self::snapshotReference($snapshot);
|
||||
}
|
||||
|
||||
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
||||
{
|
||||
$snapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
||||
}
|
||||
|
||||
return $snapshot->captured_at?->toDayDateTimeString();
|
||||
}
|
||||
|
||||
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
||||
{
|
||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||
|
||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||
return 'No capture attempts yet';
|
||||
}
|
||||
|
||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||
return 'Matches current snapshot';
|
||||
}
|
||||
|
||||
return self::snapshotReference($latestAttempt);
|
||||
}
|
||||
|
||||
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
||||
{
|
||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||
|
||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||
return 'No newer attempt is pending.';
|
||||
}
|
||||
|
||||
return $latestAttempt->captured_at?->toDayDateTimeString();
|
||||
}
|
||||
|
||||
private static function compareReadinessLabel(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
||||
}
|
||||
|
||||
private static function compareReadinessColor(BaselineProfile $profile): string
|
||||
{
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'success',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||
{
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'heroicon-m-check-badge',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||
default => 'heroicon-m-exclamation-triangle',
|
||||
};
|
||||
}
|
||||
|
||||
private static function profileNextStep(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
||||
}
|
||||
|
||||
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
||||
}
|
||||
|
||||
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||
}
|
||||
|
||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||
{
|
||||
$status = $profile->status instanceof BaselineProfileStatus
|
||||
? $profile->status
|
||||
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
||||
|
||||
if ($status !== BaselineProfileStatus::Active) {
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
}
|
||||
|
||||
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$reasonCode = $resolution['reason_code'] ?? null;
|
||||
|
||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||
return trim($reasonCode);
|
||||
}
|
||||
|
||||
if (! self::hasEligibleCompareTarget($profile)) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$reasonCode = self::compareAvailabilityReason($profile);
|
||||
|
||||
if (! is_string($reasonCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
}
|
||||
|
||||
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
||||
|
||||
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
||||
}
|
||||
|
||||
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return Tenant::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('id', $tenantIds)
|
||||
->get(['id'])
|
||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ private function compareNowAction(): Action
|
||||
|
||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
||||
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
||||
|
||||
return Action::make('compareNow')
|
||||
->label($label)
|
||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
||||
->action(function (array $data): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -256,7 +256,11 @@ private function compareNowAction(): Action
|
||||
$message = match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
@ -395,4 +399,12 @@ private function hasManageCapability(): bool
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private function profileHasConsumableSnapshot(): bool
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,12 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
@ -177,7 +179,23 @@ public static function table(Table $table): Table
|
||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
|
||||
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
|
||||
->sortable(),
|
||||
TextColumn::make('current_truth')
|
||||
->label('Current truth')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
@ -185,15 +203,8 @@ public static function table(Table $table): Table
|
||||
->wrap(),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
|
||||
])
|
||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
@ -203,10 +214,10 @@ public static function table(Table $table): Table
|
||||
->label('Baseline')
|
||||
->options(static::baselineProfileOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('snapshot_state')
|
||||
->label('State')
|
||||
->options(static::snapshotStateOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||
SelectFilter::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
->options(static::lifecycleOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
])
|
||||
->actions([
|
||||
@ -267,9 +278,9 @@ private static function baselineProfileOptions(): array
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function snapshotStateOptions(): array
|
||||
private static function lifecycleOptions(): array
|
||||
{
|
||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
||||
}
|
||||
|
||||
public static function resolveWorkspace(): ?Workspace
|
||||
@ -343,32 +354,26 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
return self::gapsCount($snapshot) > 0;
|
||||
}
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return self::gapSpec($snapshot)->label;
|
||||
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||
}
|
||||
|
||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$gapCountExpression = self::gapCountExpression($query);
|
||||
|
||||
return match ($value) {
|
||||
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
default => $query,
|
||||
};
|
||||
return $query->where('lifecycle_state', trim($value));
|
||||
}
|
||||
|
||||
private static function gapCountExpression(Builder $query): string
|
||||
{
|
||||
return match ($query->getConnection()->getDriverName()) {
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
|
||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
|
||||
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
|
||||
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
|
||||
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
|
||||
};
|
||||
}
|
||||
|
||||
@ -384,4 +389,51 @@ private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruth
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
}
|
||||
|
||||
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'Current baseline',
|
||||
'historical' => 'Historical trace',
|
||||
default => 'Not compare input',
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
|
||||
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
|
||||
default => self::truthEnvelope($snapshot)->primaryExplanation,
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthColor(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'success',
|
||||
'historical' => 'gray',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'heroicon-m-check-badge',
|
||||
'historical' => 'heroicon-m-clock',
|
||||
default => 'heroicon-m-exclamation-triangle',
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthState(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
if (! $snapshot->isConsumable()) {
|
||||
return 'unusable';
|
||||
}
|
||||
|
||||
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
|
||||
? 'historical'
|
||||
: 'current';
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ public function mount(int|string $record): void
|
||||
$snapshot = $this->getRecord();
|
||||
|
||||
if ($snapshot instanceof BaselineSnapshot) {
|
||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
||||
|
||||
$relatedContext = app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
@ -128,10 +129,11 @@ public static function table(Table $table): Table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
@ -154,10 +156,10 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
])
|
||||
->filters([
|
||||
@ -253,13 +255,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
{
|
||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||
$targetScope = static::targetScopeDisplay($record);
|
||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||
: null;
|
||||
$artifactTruth = $record->supportsOperatorExplanation()
|
||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||
: null;
|
||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||
$artifactTruthBadge = $artifactTruth !== null
|
||||
? $factory->statusBadge(
|
||||
$artifactTruth->primaryBadgeSpec()->label,
|
||||
$artifactTruth->primaryBadgeSpec()->color,
|
||||
$artifactTruth->primaryBadgeSpec()->icon,
|
||||
$artifactTruth->primaryBadgeSpec()->iconColor,
|
||||
)
|
||||
: null;
|
||||
|
||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||
@ -294,8 +308,8 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
||||
visible: $record->isGovernanceArtifactOperation(),
|
||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
||||
visible: $artifactTruth !== null,
|
||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
@ -315,6 +329,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||
$artifactTruth !== null
|
||||
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||
: null,
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
'Tenant lifecycle',
|
||||
@ -333,6 +356,26 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: null,
|
||||
static::freshnessLabel($record) !== null
|
||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
||||
: null,
|
||||
static::reconciliationHeadline($record) !== null
|
||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
||||
: null,
|
||||
static::reconciledAtLabel($record) !== null
|
||||
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
||||
: null,
|
||||
static::reconciliationSourceLabel($record) !== null
|
||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
|
||||
: ($artifactTruth !== null
|
||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
||||
: null),
|
||||
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||
: null,
|
||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||
: null,
|
||||
@ -399,9 +442,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (static::reconciliationPayload($record) !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'reconciliation',
|
||||
kind: 'operational_context',
|
||||
title: 'Lifecycle reconciliation',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
||||
|
||||
if ($baselineCompareFacts !== []) {
|
||||
$builder->addSection(
|
||||
@ -414,6 +473,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_compare_gap_details',
|
||||
kind: 'operational_context',
|
||||
title: 'Evidence gap details',
|
||||
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
|
||||
view: 'filament.infolists.entries.evidence-gap-subjects',
|
||||
viewData: [
|
||||
'summary' => $gapSummary,
|
||||
'buckets' => $gapBuckets,
|
||||
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
|
||||
],
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($baselineCompareEvidence !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
@ -538,6 +616,8 @@ private static function baselineCompareFacts(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$facts = [];
|
||||
|
||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||
@ -569,6 +649,30 @@ private static function baselineCompareFacts(
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||
$facts[] = $factory->keyFact(
|
||||
'Evidence gap detail',
|
||||
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
||||
'structured_details_recorded' => 'Structured subject details available',
|
||||
'details_not_recorded' => 'Detailed rows were not recorded',
|
||||
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
|
||||
default => 'No evidence gaps recorded',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
|
||||
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
|
||||
}
|
||||
|
||||
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
|
||||
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
|
||||
}
|
||||
|
||||
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
|
||||
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
|
||||
}
|
||||
|
||||
if ($uncoveredTypes !== []) {
|
||||
sort($uncoveredTypes, SORT_STRING);
|
||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||
@ -709,6 +813,82 @@ private static function contextPayload(OperationRun $record): array
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,freshness_state:string}
|
||||
*/
|
||||
private static function statusBadgeState(OperationRun $record): array
|
||||
{
|
||||
return [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{outcome:string,status:string,freshness_state:string}
|
||||
*/
|
||||
private static function outcomeBadgeState(OperationRun $record): array
|
||||
{
|
||||
return [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
];
|
||||
}
|
||||
|
||||
private static function freshnessLabel(OperationRun $record): ?string
|
||||
{
|
||||
return match ($record->freshnessState()->value) {
|
||||
'fresh_active' => 'Fresh activity',
|
||||
'likely_stale' => 'Likely stale',
|
||||
'reconciled_failed' => 'Automatically reconciled',
|
||||
'terminal_normal' => 'Terminal truth confirmed',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function reconciliationHeadline(OperationRun $record): ?string
|
||||
{
|
||||
if (! $record->isLifecycleReconciled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
|
||||
}
|
||||
|
||||
private static function reconciledAtLabel(OperationRun $record): ?string
|
||||
{
|
||||
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
|
||||
|
||||
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
|
||||
}
|
||||
|
||||
private static function reconciliationSourceLabel(OperationRun $record): ?string
|
||||
{
|
||||
$source = data_get($record->reconciliation(), 'source');
|
||||
|
||||
if (! is_string($source) || trim($source) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (trim($source)) {
|
||||
'failed_callback' => 'Direct failed() bridge',
|
||||
'scheduled_reconciler' => 'Scheduled reconciler',
|
||||
'adapter_reconciler' => 'Adapter reconciler',
|
||||
default => ucfirst(str_replace('_', ' ', trim($source))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function reconciliationPayload(OperationRun $record): array
|
||||
{
|
||||
$reconciliation = $record->reconciliation();
|
||||
|
||||
return $reconciliation;
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||
|
||||
@ -257,7 +257,7 @@ public static function table(Table $table): Table
|
||||
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
@ -295,7 +295,7 @@ public static function table(Table $table): Table
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
@ -563,6 +563,7 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
|
||||
return [
|
||||
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
|
||||
@ -22,6 +22,8 @@ protected function getViewData(): array
|
||||
|
||||
$empty = [
|
||||
'hasAssignment' => false,
|
||||
'state' => 'no_assignment',
|
||||
'message' => null,
|
||||
'profileName' => null,
|
||||
'findingsCount' => 0,
|
||||
'highCount' => 0,
|
||||
@ -43,6 +45,8 @@ protected function getViewData(): array
|
||||
|
||||
return [
|
||||
'hasAssignment' => true,
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'profileName' => $stats->profileName,
|
||||
'findingsCount' => $stats->findingsCount ?? 0,
|
||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||
|
||||
@ -44,8 +44,10 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'shouldShow' => $hasWarnings && $runUrl !== null,
|
||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
||||
'runUrl' => $runUrl,
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'coverageStatus' => $coverageStatus,
|
||||
'fidelity' => $stats->fidelity,
|
||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
@ -11,11 +12,18 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public int $bulkRunId = 0;
|
||||
|
||||
@ -68,32 +76,6 @@ public function handle(OperationRunService $runs): void
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
$run = $this->operationRun;
|
||||
|
||||
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
||||
$run = OperationRun::query()->find($this->bulkRunId);
|
||||
}
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
failures: [[
|
||||
'code' => 'bulk_job.failed',
|
||||
'message' => $e->getMessage(),
|
||||
]],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveOperationRun(): OperationRun
|
||||
{
|
||||
if ($this->operationRun instanceof OperationRun) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
@ -15,7 +16,15 @@
|
||||
|
||||
class BulkTenantSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -21,7 +22,9 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -33,10 +36,19 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
@ -60,13 +72,13 @@ public function handle(
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
@ -96,6 +108,7 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
@ -115,6 +128,7 @@ public function handle(
|
||||
scope: $effectiveScope,
|
||||
identity: $identity,
|
||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||
policyTypes: $truthfulTypes,
|
||||
);
|
||||
|
||||
$subjects = $inventoryResult['subjects'];
|
||||
@ -208,16 +222,17 @@ public function handle(
|
||||
],
|
||||
];
|
||||
|
||||
$snapshot = $this->findOrCreateSnapshot(
|
||||
$snapshotResult = $this->captureSnapshotArtifact(
|
||||
$profile,
|
||||
$identityHash,
|
||||
$items,
|
||||
$snapshotSummary,
|
||||
);
|
||||
|
||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||
$snapshot = $snapshotResult['snapshot'];
|
||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||
|
||||
if ($profile->status === BaselineProfileStatus::Active) {
|
||||
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
}
|
||||
|
||||
@ -249,6 +264,9 @@ public function handle(
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||
? array_values($phaseResult['gap_subjects'])
|
||||
: null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
],
|
||||
@ -258,6 +276,7 @@ public function handle(
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $snapshotItems['items_count'],
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
@ -282,7 +301,7 @@ public function handle(
|
||||
/**
|
||||
* @return array{
|
||||
* subjects_total: int,
|
||||
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
||||
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
|
||||
* inventory_by_key: array<string, array{
|
||||
* tenant_subject_external_id: string,
|
||||
* workspace_subject_external_id: string,
|
||||
@ -303,6 +322,7 @@ private function collectInventorySubjects(
|
||||
BaselineScope $scope,
|
||||
BaselineSnapshotIdentity $identity,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
?array $policyTypes = null,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
@ -311,7 +331,7 @@ private function collectInventorySubjects(
|
||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||
}
|
||||
|
||||
$query->whereIn('policy_type', $scope->allTypes());
|
||||
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
|
||||
|
||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
||||
$inventoryByKey = [];
|
||||
@ -399,6 +419,7 @@ private function collectInventorySubjects(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||
'subject_key' => (string) $item['subject_key'],
|
||||
],
|
||||
$inventoryByKey,
|
||||
));
|
||||
@ -411,6 +432,27 @@ private function collectInventorySubjects(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return list<string>
|
||||
*/
|
||||
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
||||
{
|
||||
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
||||
|
||||
if (is_array($truthfulTypes)) {
|
||||
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
||||
|
||||
if ($truthfulTypes !== []) {
|
||||
sort($truthfulTypes, SORT_STRING);
|
||||
|
||||
return $truthfulTypes;
|
||||
}
|
||||
}
|
||||
|
||||
return $effectiveScope->allTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{
|
||||
* tenant_subject_external_id: string,
|
||||
@ -508,29 +550,151 @@ private function buildSnapshotItems(
|
||||
];
|
||||
}
|
||||
|
||||
private function findOrCreateSnapshot(
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $snapshotItems
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||
*/
|
||||
private function captureSnapshotArtifact(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $snapshotItems,
|
||||
array $summaryJsonb,
|
||||
): BaselineSnapshot {
|
||||
): array {
|
||||
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
||||
|
||||
if ($existing instanceof BaselineSnapshot) {
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $existing,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: false,
|
||||
expectedItems: count($snapshotItems),
|
||||
persistedItems: count($snapshotItems),
|
||||
);
|
||||
|
||||
return [
|
||||
'snapshot' => $existing,
|
||||
'was_new_snapshot' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$expectedItems = count($snapshotItems);
|
||||
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: $expectedItems,
|
||||
persistedItems: 0,
|
||||
);
|
||||
|
||||
try {
|
||||
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
||||
|
||||
if ($persistedItems !== $expectedItems) {
|
||||
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
||||
}
|
||||
|
||||
$snapshot->markComplete($identityHash, [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => $expectedItems,
|
||||
'persisted_items' => $persistedItems,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => $expectedItems === 0,
|
||||
]);
|
||||
|
||||
$snapshot->refresh();
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: $expectedItems,
|
||||
persistedItems: $persistedItems,
|
||||
);
|
||||
|
||||
return [
|
||||
'snapshot' => $snapshot,
|
||||
'was_new_snapshot' => true,
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
$persistedItems = (int) BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->count();
|
||||
|
||||
$reasonCode = $exception instanceof RuntimeException
|
||||
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
||||
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
||||
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
||||
|
||||
$snapshot->markIncomplete($reasonCode, [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => $expectedItems,
|
||||
'persisted_items' => $persistedItems,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => $expectedItems === 0,
|
||||
]);
|
||||
|
||||
$snapshot->refresh();
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: $expectedItems,
|
||||
persistedItems: $persistedItems,
|
||||
reasonCode: $reasonCode,
|
||||
);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||
{
|
||||
$existing = BaselineSnapshot::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
->where('snapshot_identity_hash', $identityHash)
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof BaselineSnapshot) {
|
||||
return $existing;
|
||||
}
|
||||
return $existing instanceof BaselineSnapshot ? $existing : null;
|
||||
}
|
||||
|
||||
$snapshot = BaselineSnapshot::create([
|
||||
/**
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
*/
|
||||
private function createBuildingSnapshot(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $summaryJsonb,
|
||||
int $expectedItems,
|
||||
): BaselineSnapshot {
|
||||
return BaselineSnapshot::create([
|
||||
'workspace_id' => (int) $profile->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
||||
'captured_at' => now(),
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
||||
'summary_jsonb' => $summaryJsonb,
|
||||
'completion_meta_jsonb' => [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => $expectedItems,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => $expectedItems === 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $snapshotItems
|
||||
*/
|
||||
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
|
||||
{
|
||||
$persistedItems = 0;
|
||||
|
||||
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||
$rows = array_map(
|
||||
@ -549,9 +713,56 @@ private function findOrCreateSnapshot(
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::insert($rows);
|
||||
$persistedItems += count($rows);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
return $persistedItems;
|
||||
}
|
||||
|
||||
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
||||
{
|
||||
return hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
'building',
|
||||
(string) $profile->getKey(),
|
||||
(string) $this->operationRun->getKey(),
|
||||
(string) microtime(true),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private function rememberSnapshotOnRun(
|
||||
BaselineSnapshot $snapshot,
|
||||
string $identityHash,
|
||||
bool $wasNewSnapshot,
|
||||
int $expectedItems,
|
||||
int $persistedItems,
|
||||
?string $reasonCode = null,
|
||||
): void {
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
||||
$context['result'] = array_merge(
|
||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||
[
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $persistedItems,
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
'expected_items' => $expectedItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||
$context['reason_code'] = $reasonCode;
|
||||
$context['result']['snapshot_reason_code'] = $reasonCode;
|
||||
} else {
|
||||
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
||||
}
|
||||
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -18,6 +19,7 @@
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
@ -37,13 +39,16 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -54,7 +59,15 @@
|
||||
|
||||
class CompareBaselineToTenantJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
@ -84,6 +97,7 @@ public function handle(
|
||||
?SettingsResolver $settingsResolver = null,
|
||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
@ -92,6 +106,7 @@ public function handle(
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
@ -130,7 +145,7 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$effectiveTypes = $effectiveScope->allTypes();
|
||||
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
@ -278,12 +293,52 @@ public function handle(
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->whereKey($snapshotId)
|
||||
->first(['id', 'captured_at']);
|
||||
->first();
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
||||
}
|
||||
|
||||
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
|
||||
|
||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
|
||||
? (string) $snapshotResolution['reason_code']
|
||||
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$context['baseline_compare'] = array_merge(
|
||||
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||
[
|
||||
'reason_code' => $reasonCode,
|
||||
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
|
||||
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
|
||||
],
|
||||
);
|
||||
$context['result'] = array_merge(
|
||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||
[
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
);
|
||||
$context = $this->withCompareReasonTranslation($context, $reasonCode);
|
||||
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$operationRunService->finalizeBlockedRun(
|
||||
run: $this->operationRun,
|
||||
reasonCode: $reasonCode,
|
||||
message: $this->snapshotBlockedMessage($reasonCode),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BaselineSnapshot $snapshot */
|
||||
$snapshot = $snapshotResolution['snapshot'];
|
||||
$snapshotId = (int) $snapshot->getKey();
|
||||
|
||||
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
||||
? CarbonImmutable::instance($snapshot->captured_at)
|
||||
: null;
|
||||
@ -309,6 +364,7 @@ public function handle(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['subject_external_id'],
|
||||
'subject_key' => (string) $item['subject_key'],
|
||||
],
|
||||
$currentItems,
|
||||
));
|
||||
@ -334,6 +390,7 @@ public function handle(
|
||||
];
|
||||
$phaseResult = [];
|
||||
$phaseGaps = [];
|
||||
$phaseGapSubjects = [];
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
@ -362,6 +419,7 @@ public function handle(
|
||||
|
||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||
}
|
||||
|
||||
@ -441,6 +499,12 @@ public function handle(
|
||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||
$gapsCount = array_sum($gapsByReason);
|
||||
|
||||
$gapSubjects = $this->collectGapSubjects(
|
||||
ambiguousKeys: $ambiguousKeys,
|
||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||
);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
@ -518,6 +582,7 @@ public function handle(
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
...$gapsByReason,
|
||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'coverage' => [
|
||||
@ -545,6 +610,10 @@ public function handle(
|
||||
'findings_resolved' => $resolvedCount,
|
||||
'severity_breakdown' => $severityBreakdown,
|
||||
];
|
||||
$updatedContext = $this->withCompareReasonTranslation(
|
||||
$updatedContext,
|
||||
$reasonCode?->value,
|
||||
);
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted(
|
||||
@ -790,6 +859,7 @@ private function completeWithCoverageWarning(
|
||||
'findings_resolved' => 0,
|
||||
'severity_breakdown' => [],
|
||||
];
|
||||
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
||||
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
@ -896,6 +966,34 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
|
||||
{
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
unset($context['reason_translation'], $context['next_steps']);
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||
|
||||
if ($translation === null) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$context['reason_translation'] = $translation->toArray();
|
||||
$context['reason_code'] = $reasonCode;
|
||||
|
||||
if ($translation->toLegacyNextSteps() !== []) {
|
||||
$context['next_steps'] = $translation->toLegacyNextSteps();
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current inventory items keyed by "policy_type|subject_key".
|
||||
*
|
||||
@ -1004,6 +1102,38 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
return $run instanceof OperationRun ? $run : null;
|
||||
}
|
||||
|
||||
private function snapshotBlockedMessage(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
|
||||
default => 'No consumable baseline snapshot is currently available for compare.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return list<string>
|
||||
*/
|
||||
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
||||
{
|
||||
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
||||
|
||||
if (is_array($truthfulTypes)) {
|
||||
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
||||
|
||||
if ($truthfulTypes !== []) {
|
||||
sort($truthfulTypes, SORT_STRING);
|
||||
|
||||
return $truthfulTypes;
|
||||
}
|
||||
}
|
||||
|
||||
return $effectiveScope->allTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare baseline items vs current inventory and produce drift results.
|
||||
*
|
||||
@ -1036,6 +1166,7 @@ private function computeDrift(
|
||||
): array {
|
||||
$drift = [];
|
||||
$evidenceGaps = [];
|
||||
$evidenceGapSubjects = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
@ -1077,6 +1208,7 @@ private function computeDrift(
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1141,6 +1273,7 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1157,12 +1290,14 @@ private function computeDrift(
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1176,6 +1311,7 @@ private function computeDrift(
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1256,6 +1392,7 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1271,6 +1408,7 @@ private function computeDrift(
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1330,6 +1468,7 @@ private function computeDrift(
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
@ -1841,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
|
||||
return $merged;
|
||||
}
|
||||
|
||||
private const GAP_SUBJECTS_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* @param list<string> $ambiguousKeys
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
|
||||
{
|
||||
$subjects = [];
|
||||
$seen = [];
|
||||
|
||||
if ($ambiguousKeys !== []) {
|
||||
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
|
||||
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
|
||||
|
||||
if ($policyType === null || $subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
||||
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
||||
|
||||
if (isset($seen[$fingerprint])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$fingerprint] = true;
|
||||
$subjects[] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
|
||||
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||
|
||||
if (isset($seen[$fingerprint])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$fingerprint] = true;
|
||||
$subjects[] = $record;
|
||||
}
|
||||
|
||||
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
|
||||
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||
|
||||
if (isset($seen[$fingerprint])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$fingerprint] = true;
|
||||
$subjects[] = $record;
|
||||
}
|
||||
|
||||
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function normalizeStructuredGapSubjects(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjects = [];
|
||||
|
||||
foreach ($value as $record) {
|
||||
if (! is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjects[] = $record;
|
||||
}
|
||||
|
||||
return $subjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function normalizeLegacyGapSubjects(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjects = [];
|
||||
|
||||
foreach ($value as $reasonCode => $keys) {
|
||||
if (! is_string($reasonCode) || ! is_array($keys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
|
||||
|
||||
if ($policyType === null || $subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||
$outcome = match ($reasonCode) {
|
||||
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||
};
|
||||
|
||||
$record = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||
$record['reason_code'] = $reasonCode;
|
||||
$subjects[] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
return $subjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string}
|
||||
*/
|
||||
private function splitGapSubjectKey(string $value): array
|
||||
{
|
||||
$parts = explode('|', $value, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
[$policyType, $subjectKey] = $parts;
|
||||
$policyType = trim($policyType);
|
||||
$subjectKey = trim($subjectKey);
|
||||
|
||||
if ($policyType === '' || $subjectKey === '') {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [$policyType, $subjectKey];
|
||||
}
|
||||
|
||||
private function subjectResolver(): SubjectResolver
|
||||
{
|
||||
return app(SubjectResolver::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\OperationRunService;
|
||||
@ -17,8 +18,13 @@
|
||||
|
||||
class ComposeTenantReviewJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantReviewId,
|
||||
public int $operationRunId,
|
||||
|
||||
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Concerns;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Throwable;
|
||||
|
||||
trait BridgesFailedOperationRun
|
||||
{
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$operationRun = $this->failedBridgeOperationRun();
|
||||
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
|
||||
}
|
||||
|
||||
protected function failedBridgeOperationRun(): ?OperationRun
|
||||
{
|
||||
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
|
||||
return $this->operationRun;
|
||||
}
|
||||
|
||||
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
|
||||
return $this->run;
|
||||
}
|
||||
|
||||
$candidateIds = [];
|
||||
|
||||
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
|
||||
if (! property_exists($this, $property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->{$property};
|
||||
|
||||
if (is_numeric($value) && (int) $value > 0) {
|
||||
$candidateIds[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
|
||||
$operationRun = OperationRun::query()->find($candidateId);
|
||||
|
||||
if ($operationRun instanceof OperationRun) {
|
||||
return $operationRun;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,10 @@ class EntraGroupSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
|
||||
@ -25,6 +25,10 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 420;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
|
||||
@ -19,6 +19,10 @@ class GenerateEvidenceSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $snapshotId,
|
||||
public int $operationRunId,
|
||||
|
||||
@ -28,6 +28,10 @@ class GenerateReviewPackJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $reviewPackId,
|
||||
public int $operationRunId,
|
||||
|
||||
@ -40,6 +40,10 @@ class RunBackupScheduleJob implements ShouldQueue
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* Compatibility-only legacy field.
|
||||
*
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
@ -24,7 +25,15 @@
|
||||
|
||||
class RunInventorySyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
@ -21,7 +22,15 @@
|
||||
|
||||
class SyncPoliciesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -20,6 +20,10 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
|
||||
216
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
216
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BaselineCompareEvidenceGapTable extends TableComponent
|
||||
{
|
||||
/**
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $gapRows = [];
|
||||
|
||||
public string $context = 'default';
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $buckets
|
||||
*/
|
||||
public function mount(array $buckets = [], string $context = 'default'): void
|
||||
{
|
||||
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
|
||||
->defaultSort('reason_label')
|
||||
->defaultPaginationPageOption(10)
|
||||
->paginated(TablePaginationProfiles::picker())
|
||||
->searchable()
|
||||
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
|
||||
->records(function (
|
||||
?string $sortColumn,
|
||||
?string $sortDirection,
|
||||
?string $search,
|
||||
array $filters,
|
||||
int $page,
|
||||
int $recordsPerPage
|
||||
): LengthAwarePaginator {
|
||||
$rows = $this->filterRows(
|
||||
rows: collect($this->gapRows),
|
||||
search: $search,
|
||||
filters: $filters,
|
||||
);
|
||||
|
||||
$rows = $this->sortRows(
|
||||
rows: $rows,
|
||||
sortColumn: $sortColumn,
|
||||
sortDirection: $sortDirection,
|
||||
);
|
||||
|
||||
return $this->paginateRows(
|
||||
rows: $rows,
|
||||
page: $page,
|
||||
recordsPerPage: $recordsPerPage,
|
||||
);
|
||||
})
|
||||
->filters([
|
||||
SelectFilter::make('reason_code')
|
||||
->label(__('baseline-compare.evidence_gap_reason'))
|
||||
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
|
||||
SelectFilter::make('policy_type')
|
||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
|
||||
SelectFilter::make('subject_class')
|
||||
->label(__('baseline-compare.evidence_gap_subject_class'))
|
||||
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
|
||||
SelectFilter::make('operator_action_category')
|
||||
->label(__('baseline-compare.evidence_gap_next_action'))
|
||||
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
|
||||
])
|
||||
->striped()
|
||||
->deferLoading(! app()->runningUnitTests())
|
||||
->columns([
|
||||
TextColumn::make('reason_label')
|
||||
->label(__('baseline-compare.evidence_gap_reason'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('policy_type')
|
||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('subject_class_label')
|
||||
->label(__('baseline-compare.evidence_gap_subject_class'))
|
||||
->badge()
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('resolution_outcome_label')
|
||||
->label(__('baseline-compare.evidence_gap_outcome'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('operator_action_category_label')
|
||||
->label(__('baseline-compare.evidence_gap_next_action'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('subject_key')
|
||||
->label(__('baseline-compare.evidence_gap_subject_key'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap()
|
||||
->extraAttributes(['class' => 'font-mono text-xs']),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
|
||||
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.baseline-compare-evidence-gap-table');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||
{
|
||||
$normalizedSearch = Str::lower(trim((string) $search));
|
||||
$reasonCode = $filters['reason_code']['value'] ?? null;
|
||||
$policyType = $filters['policy_type']['value'] ?? null;
|
||||
$subjectClass = $filters['subject_class']['value'] ?? null;
|
||||
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
|
||||
|
||||
return $rows
|
||||
->when(
|
||||
$normalizedSearch !== '',
|
||||
function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
|
||||
});
|
||||
}
|
||||
)
|
||||
->when(
|
||||
filled($reasonCode),
|
||||
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
|
||||
)
|
||||
->when(
|
||||
filled($policyType),
|
||||
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
|
||||
)
|
||||
->when(
|
||||
filled($subjectClass),
|
||||
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
|
||||
)
|
||||
->when(
|
||||
filled($operatorActionCategory),
|
||||
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
|
||||
)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
if (! filled($sortColumn)) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
return $rows->sortBy(
|
||||
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
|
||||
SORT_NATURAL | SORT_FLAG_CASE,
|
||||
$direction === 'desc'
|
||||
)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$perPage = max(1, $recordsPerPage);
|
||||
$currentPage = max(1, $page);
|
||||
$total = $rows->count();
|
||||
$items = $rows->forPage($currentPage, $perPage)->values();
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$currentPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -121,6 +122,37 @@ public function snapshots(): HasMany
|
||||
return $this->hasMany(BaselineSnapshot::class);
|
||||
}
|
||||
|
||||
public function resolveCurrentConsumableSnapshot(): ?BaselineSnapshot
|
||||
{
|
||||
$activeSnapshot = $this->relationLoaded('activeSnapshot')
|
||||
? $this->getRelation('activeSnapshot')
|
||||
: $this->activeSnapshot()->first();
|
||||
|
||||
if ($activeSnapshot instanceof BaselineSnapshot && $activeSnapshot->isConsumable()) {
|
||||
return $activeSnapshot;
|
||||
}
|
||||
|
||||
return $this->snapshots()
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function resolveLatestAttemptedSnapshot(): ?BaselineSnapshot
|
||||
{
|
||||
return $this->snapshots()
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function hasConsumableSnapshot(): bool
|
||||
{
|
||||
return $this->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot;
|
||||
}
|
||||
|
||||
public function tenantAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaselineTenantAssignment::class);
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use RuntimeException;
|
||||
|
||||
class BaselineSnapshot extends Model
|
||||
{
|
||||
@ -13,10 +19,20 @@ class BaselineSnapshot extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'summary_jsonb' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
];
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
||||
'summary_jsonb' => 'array',
|
||||
'completion_meta_jsonb' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
@ -32,4 +48,100 @@ public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaselineSnapshotItem::class);
|
||||
}
|
||||
|
||||
public function scopeConsumable(Builder $query): Builder
|
||||
{
|
||||
return $query->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value);
|
||||
}
|
||||
|
||||
public function scopeLatestConsumable(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->consumable()
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function isConsumable(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
||||
}
|
||||
|
||||
public function isBuilding(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Building;
|
||||
}
|
||||
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
||||
}
|
||||
|
||||
public function isIncomplete(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Incomplete;
|
||||
}
|
||||
|
||||
public function markBuilding(array $completionMeta = []): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building,
|
||||
'completed_at' => null,
|
||||
'failed_at' => null,
|
||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markComplete(string $identityHash, array $completionMeta = []): void
|
||||
{
|
||||
if ($this->isIncomplete()) {
|
||||
throw new RuntimeException('Incomplete baseline snapshots cannot transition back to complete.');
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete,
|
||||
'completed_at' => now(),
|
||||
'failed_at' => null,
|
||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markIncomplete(?string $reasonCode = null, array $completionMeta = []): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete,
|
||||
'completed_at' => null,
|
||||
'failed_at' => now(),
|
||||
'completion_meta_jsonb' => $this->mergedCompletionMeta(array_filter([
|
||||
'finalization_reason_code' => $reasonCode ?? BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
||||
...$completionMeta,
|
||||
], static fn (mixed $value): bool => $value !== null)),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function lifecycleState(): BaselineSnapshotLifecycleState
|
||||
{
|
||||
if ($this->lifecycle_state instanceof BaselineSnapshotLifecycleState) {
|
||||
return $this->lifecycle_state;
|
||||
}
|
||||
|
||||
if (is_string($this->lifecycle_state) && BaselineSnapshotLifecycleState::tryFrom($this->lifecycle_state) instanceof BaselineSnapshotLifecycleState) {
|
||||
return BaselineSnapshotLifecycleState::from($this->lifecycle_state);
|
||||
}
|
||||
|
||||
return BaselineSnapshotLifecycleState::Incomplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $completionMeta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergedCompletionMeta(array $completionMeta): array
|
||||
{
|
||||
$existing = is_array($this->completion_meta_jsonb) ? $this->completion_meta_jsonb : [];
|
||||
|
||||
return array_replace($existing, $completionMeta);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -134,6 +135,11 @@ 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);
|
||||
@ -159,4 +165,109 @@ public function relatedArtifactId(): ?int
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
|
||||
}
|
||||
|
||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||
->requiredCapabilityForType((string) $run->type);
|
||||
->requiredCapabilityForRun($run);
|
||||
|
||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||
return true;
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
@ -202,7 +203,7 @@ public function panel(Panel $panel): Panel
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||
}
|
||||
|
||||
return $panel;
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Http\Middleware\UseSystemSessionCookie;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -60,6 +61,6 @@ public function panel(Panel $panel): Panel
|
||||
Authenticate::class,
|
||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
])
|
||||
->viteTheme('resources/css/filament/system/theme.css');
|
||||
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
@ -112,7 +113,7 @@ public function panel(Panel $panel): Panel
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||
}
|
||||
|
||||
return $panel;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -151,25 +152,23 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
$runs->updateRunWithReconciliation(
|
||||
run: $run,
|
||||
status: $opStatus,
|
||||
outcome: $opOutcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
||||
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
|
||||
source: 'adapter_reconciler',
|
||||
evidence: [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'restore_status' => $restoreStatus?->value,
|
||||
],
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$updatedContext = is_array($run->context) ? $run->context : [];
|
||||
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
||||
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
||||
$reconciliation['reason'] = 'adapter_out_of_sync';
|
||||
|
||||
$updatedContext['reconciliation'] = $reconciliation;
|
||||
|
||||
$run->context = $updatedContext;
|
||||
|
||||
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||
$run->started_at = $restoreRun->started_at;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineCaptureService
|
||||
@ -22,6 +23,7 @@ final class BaselineCaptureService
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -53,7 +55,7 @@ public function startCapture(
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
|
||||
@ -17,17 +17,21 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
|
||||
final class BaselineCompareService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
||||
*/
|
||||
public function startCompare(
|
||||
Tenant $tenant,
|
||||
@ -40,38 +44,45 @@ public function startCompare(
|
||||
->first();
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||
}
|
||||
|
||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||
}
|
||||
|
||||
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
||||
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
||||
$precondition = $this->validatePreconditions($profile);
|
||||
|
||||
if ($precondition !== null) {
|
||||
return ['ok' => false, 'reason_code' => $precondition];
|
||||
return $this->failedStart($precondition);
|
||||
}
|
||||
|
||||
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
||||
$selectedSnapshot = null;
|
||||
|
||||
if ($snapshotId > 0) {
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
||||
$selectedSnapshot = BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->whereKey($snapshotId)
|
||||
->first(['id']);
|
||||
->whereKey((int) $baselineSnapshotId)
|
||||
->first();
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
||||
}
|
||||
} else {
|
||||
$snapshotId = (int) $profile->active_snapshot_id;
|
||||
}
|
||||
|
||||
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
||||
|
||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
||||
}
|
||||
|
||||
/** @var BaselineSnapshot $snapshot */
|
||||
$snapshot = $snapshotResolution['snapshot'];
|
||||
$snapshotId = (int) $snapshot->getKey();
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
@ -92,7 +103,7 @@ public function startCompare(
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
@ -113,7 +124,7 @@ public function startCompare(
|
||||
return ['ok' => true, 'run' => $run];
|
||||
}
|
||||
|
||||
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||
{
|
||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
@ -123,10 +134,20 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
|
||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||
}
|
||||
|
||||
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
||||
*/
|
||||
private function failedStart(string $reasonCode): array
|
||||
{
|
||||
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||
|
||||
return array_filter([
|
||||
'ok' => false,
|
||||
'reason_code' => $reasonCode,
|
||||
'reason_translation' => $translation?->toArray(),
|
||||
], static fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,22 +10,28 @@
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Baselines\ResolutionOutcomeRecord;
|
||||
use App\Support\Baselines\ResolutionPath;
|
||||
use App\Support\Baselines\SubjectDescriptor;
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use Throwable;
|
||||
|
||||
final class BaselineContentCapturePhase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
private readonly ?SubjectResolver $subjectResolver = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
||||
*
|
||||
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
||||
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
|
||||
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
||||
* @return array{
|
||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||
* gaps: array<string, int>,
|
||||
* gap_subjects: list<array<string, mixed>>,
|
||||
* resume_token: ?string,
|
||||
* captured_versions: array<string, array{
|
||||
* policy_type: string,
|
||||
@ -76,6 +82,8 @@ public function capture(
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
/** @var list<array<string, mixed>> $gapSubjects */
|
||||
$gapSubjects = [];
|
||||
$capturedVersions = [];
|
||||
|
||||
/**
|
||||
@ -87,24 +95,40 @@ public function capture(
|
||||
foreach ($chunk as $subject) {
|
||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
|
||||
$descriptor = $this->resolver()->describeForCapture(
|
||||
$policyType !== '' ? $policyType : 'unknown',
|
||||
$externalId !== '' ? $externalId : null,
|
||||
$subjectKey !== '' ? $subjectKey : null,
|
||||
);
|
||||
|
||||
if ($policyType === '' || $externalId === '') {
|
||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectKey = $policyType.'|'.$externalId;
|
||||
$captureKey = $policyType.'|'.$externalId;
|
||||
|
||||
if (isset($seen[$subjectKey])) {
|
||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||
if (isset($seen[$captureKey])) {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$subjectKey] = true;
|
||||
$seen[$captureKey] = true;
|
||||
|
||||
if (
|
||||
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|
||||
|| $descriptor->resolutionPath === ResolutionPath::Inventory
|
||||
) {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -113,7 +137,7 @@ public function capture(
|
||||
->first();
|
||||
|
||||
if (! $policy instanceof Policy) {
|
||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
@ -152,7 +176,7 @@ public function capture(
|
||||
$version = $result['version'] ?? null;
|
||||
|
||||
if ($version instanceof PolicyVersion) {
|
||||
$capturedVersions[$subjectKey] = [
|
||||
$capturedVersions[$captureKey] = [
|
||||
'policy_type' => $policyType,
|
||||
'subject_external_id' => $externalId,
|
||||
'version' => $version,
|
||||
@ -178,10 +202,10 @@ public function capture(
|
||||
}
|
||||
|
||||
if ($isThrottled) {
|
||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
||||
$stats['throttled']++;
|
||||
} else {
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
||||
$stats['failed']++;
|
||||
}
|
||||
|
||||
@ -201,7 +225,22 @@ public function capture(
|
||||
|
||||
$remainingCount = max(0, count($subjects) - $processed);
|
||||
if ($remainingCount > 0) {
|
||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
||||
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
||||
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
|
||||
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
|
||||
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
|
||||
|
||||
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$remainingDescriptor = $this->resolver()->describeForCapture(
|
||||
$remainingPolicyType,
|
||||
$remainingExternalId,
|
||||
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
|
||||
);
|
||||
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,11 +249,27 @@ public function capture(
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'gaps' => $gaps,
|
||||
'gap_subjects' => $gapSubjects,
|
||||
'resume_token' => $resumeTokenOut,
|
||||
'captured_versions' => $capturedVersions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $gaps
|
||||
* @param list<array<string, mixed>> $gapSubjects
|
||||
*/
|
||||
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
|
||||
{
|
||||
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
|
||||
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||
}
|
||||
|
||||
private function resolver(): SubjectResolver
|
||||
{
|
||||
return $this->subjectResolver ?? app(SubjectResolver::class);
|
||||
}
|
||||
|
||||
private function retryDelayMs(int $attempt): int
|
||||
{
|
||||
$attempt = max(0, $attempt);
|
||||
|
||||
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
|
||||
final class BaselineSnapshotTruthResolver
|
||||
{
|
||||
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* snapshot: ?BaselineSnapshot,
|
||||
* effective_snapshot: ?BaselineSnapshot,
|
||||
* latest_attempted_snapshot: ?BaselineSnapshot,
|
||||
* reason_code: ?string
|
||||
* }
|
||||
*/
|
||||
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
|
||||
{
|
||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
|
||||
|
||||
if ($explicitSnapshot instanceof BaselineSnapshot) {
|
||||
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|
||||
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'snapshot' => null,
|
||||
'effective_snapshot' => $effectiveSnapshot,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
|
||||
];
|
||||
}
|
||||
|
||||
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
|
||||
|
||||
return [
|
||||
'ok' => $reasonCode === null,
|
||||
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
|
||||
'effective_snapshot' => $effectiveSnapshot,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => $reasonCode,
|
||||
];
|
||||
}
|
||||
|
||||
if ($effectiveSnapshot instanceof BaselineSnapshot) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'snapshot' => $effectiveSnapshot,
|
||||
'effective_snapshot' => $effectiveSnapshot,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'snapshot' => null,
|
||||
'effective_snapshot' => null,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
|
||||
];
|
||||
}
|
||||
|
||||
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
|
||||
{
|
||||
$effectiveSnapshot ??= BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $snapshot->isConsumable()
|
||||
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
|
||||
}
|
||||
|
||||
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
|
||||
{
|
||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||
$snapshot->loadMissing('baselineProfile');
|
||||
|
||||
$profile = $snapshot->baselineProfile;
|
||||
|
||||
if ($profile instanceof BaselineProfile) {
|
||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||
}
|
||||
}
|
||||
|
||||
if ($snapshot->isBuilding()) {
|
||||
return BaselineReasonCodes::SNAPSHOT_BUILDING;
|
||||
}
|
||||
|
||||
if ($snapshot->isIncomplete()) {
|
||||
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
|
||||
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||
? trim($reasonCode)
|
||||
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
|
||||
}
|
||||
|
||||
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
|
||||
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function compareBlockedReasonForSnapshot(
|
||||
BaselineSnapshot $snapshot,
|
||||
?BaselineSnapshot $effectiveSnapshot,
|
||||
bool $explicitSelection,
|
||||
): ?string {
|
||||
if ($snapshot->isBuilding()) {
|
||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
|
||||
}
|
||||
|
||||
if ($snapshot->isIncomplete()) {
|
||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
|
||||
}
|
||||
|
||||
if (! $snapshot->isConsumable()) {
|
||||
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||
}
|
||||
|
||||
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
|
||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
|
||||
{
|
||||
return match (true) {
|
||||
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
|
||||
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Carbon;
|
||||
@ -99,13 +100,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
{
|
||||
$rendered = $this->present($snapshot);
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
|
||||
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
||||
$stateBadge = $factory->statusBadge(
|
||||
$stateSpec->label,
|
||||
$stateSpec->color,
|
||||
$stateSpec->icon,
|
||||
$stateSpec->iconColor,
|
||||
$truthBadge = $factory->statusBadge(
|
||||
$truth->primaryBadgeSpec()->label,
|
||||
$truth->primaryBadgeSpec()->color,
|
||||
$truth->primaryBadgeSpec()->icon,
|
||||
$truth->primaryBadgeSpec()->iconColor,
|
||||
);
|
||||
|
||||
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||
$lifecycleBadge = $factory->statusBadge(
|
||||
$lifecycleSpec->label,
|
||||
$lifecycleSpec->color,
|
||||
$lifecycleSpec->icon,
|
||||
$lifecycleSpec->iconColor,
|
||||
);
|
||||
|
||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||
@ -120,20 +129,27 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||
$rendered->summaryRows,
|
||||
));
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
$currentTruth = $this->currentTruthPresentation($truth);
|
||||
$currentTruthBadge = $factory->statusBadge(
|
||||
$currentTruth['label'],
|
||||
$currentTruth['color'],
|
||||
$currentTruth['icon'],
|
||||
$currentTruth['iconColor'],
|
||||
);
|
||||
$operatorExplanation = $truth->operatorExplanation;
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
||||
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
||||
statusBadges: [$stateBadge, $fidelityBadge],
|
||||
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->viewSection(
|
||||
@ -175,11 +191,30 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Snapshot status',
|
||||
title: 'Snapshot truth',
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||
: null,
|
||||
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||
: null,
|
||||
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
|
||||
])),
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'coverage',
|
||||
title: 'Coverage',
|
||||
items: [
|
||||
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
@ -187,6 +222,8 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
title: 'Capture timing',
|
||||
items: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
|
||||
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
|
||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||
],
|
||||
),
|
||||
@ -338,6 +375,33 @@ private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, color: string, icon: string, iconColor: string}
|
||||
*/
|
||||
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
||||
{
|
||||
return match ($truth->artifactExistence) {
|
||||
'historical_only' => [
|
||||
'label' => 'Historical trace',
|
||||
'color' => 'gray',
|
||||
'icon' => 'heroicon-m-clock',
|
||||
'iconColor' => 'gray',
|
||||
],
|
||||
'created_but_not_usable' => [
|
||||
'label' => 'Not compare input',
|
||||
'color' => 'warning',
|
||||
'icon' => 'heroicon-m-exclamation-triangle',
|
||||
'iconColor' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'label' => 'Current baseline',
|
||||
'color' => 'success',
|
||||
'icon' => 'heroicon-m-check-badge',
|
||||
'iconColor' => 'success',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private function typeLabel(string $policyType): string
|
||||
{
|
||||
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\ExecutionAuthorityMode;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
||||
use App\Support\OpsUx\BulkRunContext;
|
||||
@ -62,15 +63,45 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
||||
|
||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||
{
|
||||
return $this->updateRun(
|
||||
return $this->forceFailNonTerminalRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'run.stale_queued',
|
||||
'message' => $message,
|
||||
],
|
||||
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
|
||||
message: $message,
|
||||
source: 'scheduled_reconciler',
|
||||
evidence: [
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'created_at' => $run->created_at?->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
|
||||
{
|
||||
if ($run->status !== OperationRunStatus::Running->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$startedAt = $run->started_at ?? $run->created_at;
|
||||
|
||||
if ($startedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
|
||||
}
|
||||
|
||||
public function failStaleRunningRun(
|
||||
OperationRun $run,
|
||||
string $message = 'Run stopped reporting progress and was marked failed.',
|
||||
): OperationRun {
|
||||
return $this->forceFailNonTerminalRun(
|
||||
$run,
|
||||
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
|
||||
message: $message,
|
||||
source: 'scheduled_reconciler',
|
||||
evidence: [
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -721,6 +752,136 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evidence
|
||||
* @param array<string, mixed> $summaryCounts
|
||||
*/
|
||||
public function forceFailNonTerminalRun(
|
||||
OperationRun $run,
|
||||
string $reasonCode,
|
||||
string $message,
|
||||
string $source = 'scheduled_reconciler',
|
||||
array $evidence = [],
|
||||
array $summaryCounts = [],
|
||||
): OperationRun {
|
||||
return $this->updateRunWithReconciliation(
|
||||
run: $run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: [[
|
||||
'code' => $reasonCode,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message,
|
||||
]],
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $message,
|
||||
source: $source,
|
||||
evidence: $evidence,
|
||||
);
|
||||
}
|
||||
|
||||
public function bridgeFailedJobFailure(
|
||||
OperationRun $run,
|
||||
Throwable $exception,
|
||||
string $source = 'failed_callback',
|
||||
): OperationRun {
|
||||
$reason = $this->bridgeReasonForThrowable($exception);
|
||||
$message = $reason->defaultMessage();
|
||||
$exceptionMessage = $this->sanitizeMessage($exception->getMessage());
|
||||
|
||||
if ($exceptionMessage !== '') {
|
||||
$message = $exceptionMessage;
|
||||
}
|
||||
|
||||
return $this->forceFailNonTerminalRun(
|
||||
$run,
|
||||
reasonCode: $reason->value,
|
||||
message: $message,
|
||||
source: $source,
|
||||
evidence: [
|
||||
'exception_class' => $exception::class,
|
||||
'bridge_source' => $source,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summaryCounts
|
||||
* @param array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}> $failures
|
||||
* @param array<string, mixed> $evidence
|
||||
*/
|
||||
public function updateRunWithReconciliation(
|
||||
OperationRun $run,
|
||||
string $status,
|
||||
string $outcome,
|
||||
array $summaryCounts,
|
||||
array $failures,
|
||||
string $reasonCode,
|
||||
string $reasonMessage,
|
||||
string $source = 'scheduled_reconciler',
|
||||
array $evidence = [],
|
||||
): OperationRun {
|
||||
/** @var OperationRun $updated */
|
||||
$updated = DB::transaction(function () use (
|
||||
$run,
|
||||
$status,
|
||||
$outcome,
|
||||
$summaryCounts,
|
||||
$failures,
|
||||
$reasonCode,
|
||||
$reasonMessage,
|
||||
$source,
|
||||
$evidence,
|
||||
): OperationRun {
|
||||
$locked = OperationRun::query()
|
||||
->whereKey($run->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked instanceof OperationRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
if ((string) $locked->status === OperationRunStatus::Completed->value) {
|
||||
return $locked;
|
||||
}
|
||||
|
||||
$context = is_array($locked->context) ? $locked->context : [];
|
||||
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||
$context['reconciliation'] = $this->reconciliationMetadata(
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
source: $source,
|
||||
evidence: $evidence,
|
||||
);
|
||||
|
||||
$translatedContext = $this->withReasonTranslationContext(
|
||||
run: $locked,
|
||||
context: $context,
|
||||
failures: $failures,
|
||||
);
|
||||
|
||||
$locked->update([
|
||||
'context' => $translatedContext ?? $context,
|
||||
]);
|
||||
|
||||
$locked->refresh();
|
||||
|
||||
return $this->updateRun(
|
||||
$locked,
|
||||
status: $status,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
);
|
||||
});
|
||||
|
||||
$updated->refresh();
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
||||
*
|
||||
@ -1033,16 +1194,49 @@ private function isDirectlyTranslatableReason(string $reasonCode): bool
|
||||
|
||||
return ProviderReasonCodes::isKnown($reasonCode)
|
||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evidence
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reconciliationMetadata(
|
||||
string $reasonCode,
|
||||
string $reasonMessage,
|
||||
string $source,
|
||||
array $evidence,
|
||||
): array {
|
||||
return [
|
||||
'reconciled_at' => now()->toIso8601String(),
|
||||
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||
'reason_message' => $this->sanitizeMessage($reasonMessage),
|
||||
'source' => $this->sanitizeFailureCode($source),
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
|
||||
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
|
||||
{
|
||||
$className = strtolower(class_basename($exception));
|
||||
|
||||
if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
|
||||
return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
|
||||
}
|
||||
|
||||
return LifecycleReconciliationReason::QueueFailureBridge;
|
||||
}
|
||||
|
||||
private function writeTerminalAudit(OperationRun $run): void
|
||||
{
|
||||
$tenant = $run->tenant;
|
||||
$workspace = $run->workspace;
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
||||
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
|
||||
$action = match ($run->outcome) {
|
||||
@ -1072,6 +1266,7 @@ private function writeTerminalAudit(OperationRun $run): void
|
||||
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
||||
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
||||
'blocked_by' => $context['blocked_by'] ?? null,
|
||||
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
|
||||
],
|
||||
],
|
||||
workspace: $workspace,
|
||||
|
||||
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use RuntimeException;
|
||||
|
||||
final class OperationLifecyclePolicyValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationLifecyclePolicy $policy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* valid:bool,
|
||||
* errors:array<int, string>,
|
||||
* definitions:array<string, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function validate(): array
|
||||
{
|
||||
$errors = [];
|
||||
$definitions = [];
|
||||
|
||||
foreach ($this->policy->coveredTypeNames() as $operationType) {
|
||||
$definition = $this->policy->definition($operationType);
|
||||
|
||||
if ($definition === null) {
|
||||
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$definitions[$operationType] = $definition;
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$timeout = $this->jobTimeoutSeconds($operationType);
|
||||
|
||||
if (! is_int($timeout) || $timeout <= 0) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
|
||||
}
|
||||
|
||||
if (! $this->jobFailsOnTimeout($operationType)) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
|
||||
}
|
||||
|
||||
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
|
||||
}
|
||||
|
||||
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
|
||||
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
|
||||
|
||||
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
|
||||
$errors[] = sprintf(
|
||||
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
|
||||
$operationType,
|
||||
$timeout,
|
||||
$retryAfter,
|
||||
$safetyMargin,
|
||||
);
|
||||
}
|
||||
|
||||
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
|
||||
|
||||
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
|
||||
$errors[] = sprintf(
|
||||
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
|
||||
$operationType,
|
||||
$expectedMaxRuntime,
|
||||
$retryAfter,
|
||||
$safetyMargin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $errors === [],
|
||||
'errors' => $errors,
|
||||
'definitions' => $definitions,
|
||||
];
|
||||
}
|
||||
|
||||
public function assertValid(): void
|
||||
{
|
||||
$result = $this->validate();
|
||||
|
||||
if (($result['valid'] ?? false) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
|
||||
}
|
||||
|
||||
public function jobTimeoutSeconds(string $operationType): ?int
|
||||
{
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
|
||||
|
||||
return is_numeric($timeout) ? (int) $timeout : null;
|
||||
}
|
||||
|
||||
public function jobFailsOnTimeout(string $operationType): bool
|
||||
{
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
|
||||
}
|
||||
|
||||
public function jobUsesDirectFailedBridge(string $operationType): bool
|
||||
{
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
|
||||
}
|
||||
}
|
||||
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class OperationLifecycleReconciler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationLifecyclePolicy $policy,
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* types?: array<int, string>,
|
||||
* tenant_ids?: array<int, int>,
|
||||
* workspace_ids?: array<int, int>,
|
||||
* limit?: int,
|
||||
* dry_run?: bool
|
||||
* } $options
|
||||
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function reconcile(array $options = []): array
|
||||
{
|
||||
$types = array_values(array_filter(
|
||||
$options['types'] ?? $this->policy->coveredTypeNames(),
|
||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||
));
|
||||
$tenantIds = array_values(array_filter(
|
||||
$options['tenant_ids'] ?? [],
|
||||
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
|
||||
));
|
||||
$workspaceIds = array_values(array_filter(
|
||||
$options['workspace_ids'] ?? [],
|
||||
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
|
||||
));
|
||||
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
|
||||
$dryRun = (bool) ($options['dry_run'] ?? false);
|
||||
|
||||
$runs = OperationRun::query()
|
||||
->with(['tenant', 'user'])
|
||||
->whereIn('type', $types)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->when(
|
||||
$tenantIds !== [],
|
||||
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
|
||||
)
|
||||
->when(
|
||||
$workspaceIds !== [],
|
||||
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
|
||||
)
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$changes = [];
|
||||
$reconciled = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($runs as $run) {
|
||||
$change = $this->reconcileRun($run, $dryRun);
|
||||
|
||||
if ($change === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = $change;
|
||||
|
||||
if (($change['applied'] ?? false) === true) {
|
||||
$reconciled++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'candidates' => $runs->count(),
|
||||
'reconciled' => $reconciled,
|
||||
'skipped' => $skipped,
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
|
||||
{
|
||||
$assessment = $this->assessment($run);
|
||||
|
||||
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
|
||||
];
|
||||
$after = [
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'applied' => false,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'reason_code' => $assessment['reason_code'],
|
||||
'reason_message' => $assessment['reason_message'],
|
||||
'evidence' => $assessment['evidence'],
|
||||
];
|
||||
}
|
||||
|
||||
$updated = $this->operationRunService->forceFailNonTerminalRun(
|
||||
run: $run,
|
||||
reasonCode: (string) $assessment['reason_code'],
|
||||
message: (string) $assessment['reason_message'],
|
||||
source: 'scheduled_reconciler',
|
||||
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
|
||||
);
|
||||
|
||||
return [
|
||||
'applied' => true,
|
||||
'operation_run_id' => (int) $updated->getKey(),
|
||||
'type' => (string) $updated->type,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'reason_code' => $assessment['reason_code'],
|
||||
'reason_message' => $assessment['reason_message'],
|
||||
'evidence' => $assessment['evidence'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
|
||||
*/
|
||||
public function assessment(OperationRun $run): ?array
|
||||
{
|
||||
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
|
||||
|
||||
if (! $freshnessState->isLikelyStale()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = (string) $run->status === OperationRunStatus::Queued->value
|
||||
? LifecycleReconciliationReason::StaleQueued
|
||||
: LifecycleReconciliationReason::StaleRunning;
|
||||
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
|
||||
? $run->created_at
|
||||
: ($run->started_at ?? $run->created_at);
|
||||
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
|
||||
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
|
||||
: $this->policy->runningStaleAfterSeconds((string) $run->type);
|
||||
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
|
||||
|
||||
return [
|
||||
'should_reconcile' => true,
|
||||
'reason_code' => $reason->value,
|
||||
'reason_message' => $reason->defaultMessage(),
|
||||
'evidence' => [
|
||||
'evaluated_at' => now()->toIso8601String(),
|
||||
'freshness_state' => $freshnessState->value,
|
||||
'threshold_seconds' => $thresholdSeconds,
|
||||
'reference_time' => $referenceTime?->toIso8601String(),
|
||||
'status' => (string) $run->status,
|
||||
'execution_legitimacy' => $legitimacy,
|
||||
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
|
||||
? 'direct_and_scheduled'
|
||||
: 'scheduled_only',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,9 @@ final class BadgeCatalog
|
||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
||||
BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
|
||||
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
|
||||
BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class,
|
||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||
|
||||
@ -11,6 +11,9 @@ enum BadgeDomain: string
|
||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||
case OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result';
|
||||
case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness';
|
||||
case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle';
|
||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
|
||||
final class BaselineSnapshotLifecycleBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
BaselineSnapshotLifecycleState::Building->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-arrow-path'),
|
||||
BaselineSnapshotLifecycleState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-check-circle'),
|
||||
BaselineSnapshotLifecycleState::Incomplete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-x-circle'),
|
||||
'superseded' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-clock'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,46 @@
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
|
||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = null;
|
||||
|
||||
if (is_array($value)) {
|
||||
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
|
||||
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||
|
||||
if ($outcome === null) {
|
||||
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
} elseif (
|
||||
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||
) {
|
||||
$outcome = OperationRunOutcome::Pending->value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($outcome === OperationRunOutcome::Failed->value
|
||||
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
|
||||
) {
|
||||
return new BadgeSpec(
|
||||
label: 'Reconciled failed',
|
||||
color: 'danger',
|
||||
icon: 'heroicon-m-arrow-path-rounded-square',
|
||||
iconColor: 'danger',
|
||||
);
|
||||
}
|
||||
|
||||
$state = $outcome;
|
||||
}
|
||||
|
||||
$state ??= BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||
|
||||
@ -8,12 +8,33 @@
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
|
||||
final class OperationRunStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = null;
|
||||
|
||||
if (is_array($value)) {
|
||||
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||
|
||||
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||
) {
|
||||
return new BadgeSpec(
|
||||
label: 'Likely stale',
|
||||
color: 'warning',
|
||||
icon: 'heroicon-m-exclamation-triangle',
|
||||
iconColor: 'warning',
|
||||
);
|
||||
}
|
||||
|
||||
$state = $status;
|
||||
}
|
||||
|
||||
$state ??= BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class OperatorExplanationEvaluationResultBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
||||
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
||||
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
||||
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class OperatorExplanationTrustworthinessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'trustworthy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-check-badge'),
|
||||
'limited_confidence' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'diagnostic_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-beaker'),
|
||||
'unusable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,7 @@ final class OperatorOutcomeTaxonomy
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Partial',
|
||||
'label' => 'Partially complete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
@ -136,7 +136,7 @@ final class OperatorOutcomeTaxonomy
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Stale',
|
||||
'label' => 'Refresh recommended',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
@ -183,7 +183,7 @@ final class OperatorOutcomeTaxonomy
|
||||
],
|
||||
'blocked' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Blocked',
|
||||
'label' => 'Publication blocked',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
@ -220,6 +220,129 @@ final class OperatorOutcomeTaxonomy
|
||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
||||
],
|
||||
],
|
||||
'operator_explanation_evaluation_result' => [
|
||||
'full_result' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Complete result',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Full result'],
|
||||
'notes' => 'The result can be read as complete for the intended operator decision.',
|
||||
],
|
||||
'incomplete_result' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Incomplete result',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partial result'],
|
||||
'notes' => 'A result exists, but missing or partial coverage limits what it means.',
|
||||
],
|
||||
'suppressed_result' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Suppressed result',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Suppressed'],
|
||||
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
|
||||
],
|
||||
'no_result' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'No issues detected',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['No result'],
|
||||
'notes' => 'The workflow produced no decision-relevant follow-up for the operator.',
|
||||
],
|
||||
'unavailable' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Result unavailable',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Unavailable'],
|
||||
'notes' => 'A usable result is not currently available for this surface.',
|
||||
],
|
||||
],
|
||||
'operator_explanation_trustworthiness' => [
|
||||
'trustworthy' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Trustworthy',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Decision grade'],
|
||||
'notes' => 'The operator can rely on this result for the intended task.',
|
||||
],
|
||||
'limited_confidence' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Limited confidence',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Use with caution'],
|
||||
'notes' => 'The result is still useful, but the operator should account for documented limitations.',
|
||||
],
|
||||
'diagnostic_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Diagnostic only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Diagnostics only'],
|
||||
'notes' => 'The result is suitable for diagnostics only, not for a final decision.',
|
||||
],
|
||||
'unusable' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Not usable yet',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Unusable'],
|
||||
'notes' => 'The operator should not rely on this result until the blocking issue is resolved.',
|
||||
],
|
||||
],
|
||||
'baseline_snapshot_lifecycle' => [
|
||||
'building' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Building',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['In progress'],
|
||||
'notes' => 'The snapshot row exists, but completion proof has not finished yet.',
|
||||
],
|
||||
'complete' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Complete',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Ready'],
|
||||
'notes' => 'The snapshot passed completion proof and is eligible for compare.',
|
||||
],
|
||||
'incomplete' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Incomplete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'The snapshot exists but did not finish cleanly and is not usable for compare.',
|
||||
],
|
||||
'superseded' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Superseded',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Historical'],
|
||||
'notes' => 'A newer complete snapshot is the effective current baseline truth.',
|
||||
],
|
||||
],
|
||||
'operation_run_status' => [
|
||||
'queued' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
|
||||
661
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
661
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
@ -0,0 +1,661 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BaselineCompareEvidenceGapDetails
|
||||
{
|
||||
public static function fromOperationRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun || ! is_array($run->context)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
return self::fromContext($run->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function fromContext(array $context): array
|
||||
{
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
return self::fromBaselineCompare($baselineCompare);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineCompare
|
||||
*/
|
||||
public static function fromBaselineCompare(array $baselineCompare): array
|
||||
{
|
||||
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
||||
|
||||
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
||||
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
||||
|
||||
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
|
||||
if (! array_key_exists($reasonCode, $byReason)) {
|
||||
$byReason[$reasonCode] = count($subjects);
|
||||
}
|
||||
}
|
||||
|
||||
$count = self::normalizeTotalCount(
|
||||
$evidenceGaps['count'] ?? null,
|
||||
$byReason,
|
||||
$normalizedSubjects['subjects'],
|
||||
);
|
||||
$detailState = self::detailState($count, $normalizedSubjects);
|
||||
$buckets = [];
|
||||
|
||||
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
|
||||
$rows = $detailState === 'structured_details_recorded'
|
||||
? array_map(
|
||||
static fn (array $subject): array => self::projectSubjectRow($subject),
|
||||
$normalizedSubjects['subjects'][$reasonCode] ?? [],
|
||||
)
|
||||
: [];
|
||||
$reasonCount = $byReason[$reasonCode] ?? count($rows);
|
||||
|
||||
if ($reasonCount <= 0 && $rows === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recordedCount = count($rows);
|
||||
$structuralCount = count(array_filter(
|
||||
$rows,
|
||||
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
|
||||
));
|
||||
$transientCount = count(array_filter(
|
||||
$rows,
|
||||
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
|
||||
));
|
||||
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
|
||||
|
||||
$searchText = trim(implode(' ', array_filter([
|
||||
Str::lower($reasonCode),
|
||||
Str::lower(self::reasonLabel($reasonCode)),
|
||||
...array_map(
|
||||
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
||||
$rows,
|
||||
),
|
||||
])));
|
||||
|
||||
$buckets[] = [
|
||||
'reason_code' => $reasonCode,
|
||||
'reason_label' => self::reasonLabel($reasonCode),
|
||||
'count' => $reasonCount,
|
||||
'recorded_count' => $recordedCount,
|
||||
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
||||
'structural_count' => $structuralCount,
|
||||
'operational_count' => $operationalCount,
|
||||
'transient_count' => $transientCount,
|
||||
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
|
||||
'search_text' => $searchText,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
$recordedSubjectsTotal = array_sum(array_map(
|
||||
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
||||
$buckets,
|
||||
));
|
||||
$structuralCount = array_sum(array_map(
|
||||
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
|
||||
$buckets,
|
||||
));
|
||||
$operationalCount = array_sum(array_map(
|
||||
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
|
||||
$buckets,
|
||||
));
|
||||
$transientCount = array_sum(array_map(
|
||||
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
|
||||
$buckets,
|
||||
));
|
||||
$legacyMode = $detailState === 'legacy_broad_reason';
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'count' => $count,
|
||||
'by_reason' => $byReason,
|
||||
'detail_state' => $detailState,
|
||||
'recorded_subjects_total' => $recordedSubjectsTotal,
|
||||
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
||||
'structural_count' => $structuralCount,
|
||||
'operational_count' => $operationalCount,
|
||||
'transient_count' => $transientCount,
|
||||
'legacy_mode' => $legacyMode,
|
||||
'requires_regeneration' => $legacyMode,
|
||||
],
|
||||
'buckets' => $buckets,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineCompare
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function diagnosticsPayload(array $baselineCompare): array
|
||||
{
|
||||
return array_filter([
|
||||
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
|
||||
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
||||
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
||||
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
||||
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
||||
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
||||
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
public static function reasonLabel(string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
return match ($reason) {
|
||||
'ambiguous_match' => 'Ambiguous inventory match',
|
||||
'policy_record_missing' => 'Policy record missing',
|
||||
'inventory_record_missing' => 'Inventory record missing',
|
||||
'foundation_not_policy_backed' => 'Foundation not policy-backed',
|
||||
'invalid_subject' => 'Invalid subject',
|
||||
'duplicate_subject' => 'Duplicate subject',
|
||||
'capture_failed' => 'Evidence capture failed',
|
||||
'retryable_capture_failure' => 'Retryable evidence capture failure',
|
||||
'budget_exhausted' => 'Capture budget exhausted',
|
||||
'throttled' => 'Graph throttled',
|
||||
'invalid_support_config' => 'Invalid support configuration',
|
||||
'missing_current' => 'Missing current evidence',
|
||||
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
|
||||
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
|
||||
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
|
||||
'rollout_disabled' => 'Rollout disabled',
|
||||
'policy_not_found' => 'Legacy policy not found',
|
||||
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
public static function subjectClassLabel(string $subjectClass): string
|
||||
{
|
||||
return match (trim($subjectClass)) {
|
||||
SubjectClass::PolicyBacked->value => 'Policy-backed',
|
||||
SubjectClass::InventoryBacked->value => 'Inventory-backed',
|
||||
SubjectClass::FoundationBacked->value => 'Foundation-backed',
|
||||
default => 'Derived',
|
||||
};
|
||||
}
|
||||
|
||||
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
|
||||
{
|
||||
return match (trim($resolutionOutcome)) {
|
||||
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
|
||||
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
|
||||
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
|
||||
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
|
||||
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
|
||||
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
|
||||
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
|
||||
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
|
||||
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
|
||||
ResolutionOutcome::Throttled->value => 'Graph throttled',
|
||||
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
|
||||
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
|
||||
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
|
||||
};
|
||||
}
|
||||
|
||||
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
|
||||
{
|
||||
return match (trim($operatorActionCategory)) {
|
||||
OperatorActionCategory::Retry->value => 'Retry',
|
||||
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
|
||||
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
|
||||
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
|
||||
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
|
||||
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
|
||||
default => 'No action',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $byReason
|
||||
* @return list<array{reason_code: string, reason_label: string, count: int}>
|
||||
*/
|
||||
public static function topReasons(array $byReason, int $limit = 5): array
|
||||
{
|
||||
$normalized = self::normalizeCounts($byReason);
|
||||
arsort($normalized);
|
||||
|
||||
return array_map(
|
||||
static fn (string $reason, int $count): array => [
|
||||
'reason_code' => $reason,
|
||||
'reason_label' => self::reasonLabel($reason),
|
||||
'count' => $count,
|
||||
],
|
||||
array_slice(array_keys($normalized), 0, $limit),
|
||||
array_slice(array_values($normalized), 0, $limit),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $buckets
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function tableRows(array $buckets): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
if (! is_array($bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
||||
|
||||
foreach ($bucketRows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
|
||||
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
||||
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
||||
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
|
||||
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
|
||||
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
|
||||
|
||||
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
|
||||
'reason_code' => $reasonCode,
|
||||
'reason_label' => self::reasonLabel($reasonCode),
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'subject_class' => $subjectClass,
|
||||
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
|
||||
'resolution_outcome' => $resolutionOutcome,
|
||||
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||
'operator_action_category' => $operatorActionCategory,
|
||||
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||
'structural' => (bool) ($row['structural'] ?? false),
|
||||
'retryable' => (bool) ($row['retryable'] ?? false),
|
||||
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function reasonFilterOptions(array $rows): array
|
||||
{
|
||||
return collect($rows)
|
||||
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['reason_code'] => (string) $row['reason_label'],
|
||||
])
|
||||
->sortBy(fn (string $label): string => Str::lower($label))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function policyTypeFilterOptions(array $rows): array
|
||||
{
|
||||
return collect($rows)
|
||||
->pluck('policy_type')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->mapWithKeys(fn (string $value): array => [$value => $value])
|
||||
->sortKeysUsing('strnatcasecmp')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function subjectClassFilterOptions(array $rows): array
|
||||
{
|
||||
return collect($rows)
|
||||
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
|
||||
])
|
||||
->sortBy(fn (string $label): string => Str::lower($label))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function actionCategoryFilterOptions(array $rows): array
|
||||
{
|
||||
return collect($rows)
|
||||
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
|
||||
])
|
||||
->sortBy(fn (string $label): string => Str::lower($label))
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function empty(): array
|
||||
{
|
||||
return [
|
||||
'summary' => [
|
||||
'count' => 0,
|
||||
'by_reason' => [],
|
||||
'detail_state' => 'no_gaps',
|
||||
'recorded_subjects_total' => 0,
|
||||
'missing_detail_count' => 0,
|
||||
'structural_count' => 0,
|
||||
'operational_count' => 0,
|
||||
'transient_count' => 0,
|
||||
'legacy_mode' => false,
|
||||
'requires_regeneration' => false,
|
||||
],
|
||||
'buckets' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function normalizeCounts(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($value as $reason => $count) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$intCount = (int) $count;
|
||||
|
||||
if ($intCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $intCount;
|
||||
}
|
||||
|
||||
arsort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* subjects: array<string, list<array<string, mixed>>>,
|
||||
* legacy_mode: bool
|
||||
* }
|
||||
*/
|
||||
private static function normalizeSubjects(mixed $value): array
|
||||
{
|
||||
if ($value === null) {
|
||||
return [
|
||||
'subjects' => [],
|
||||
'legacy_mode' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [
|
||||
'subjects' => [],
|
||||
'legacy_mode' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
return [
|
||||
'subjects' => [],
|
||||
'legacy_mode' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$subjects = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
$normalized = self::normalizeStructuredSubject($item);
|
||||
|
||||
if ($normalized === null) {
|
||||
return [
|
||||
'subjects' => [],
|
||||
'legacy_mode' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$subjects[$normalized['reason_code']][] = $normalized;
|
||||
}
|
||||
|
||||
foreach ($subjects as &$bucket) {
|
||||
usort($bucket, static function (array $left, array $right): int {
|
||||
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
|
||||
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
|
||||
});
|
||||
}
|
||||
unset($bucket);
|
||||
|
||||
ksort($subjects);
|
||||
|
||||
return [
|
||||
'subjects' => $subjects,
|
||||
'legacy_mode' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function normalizeStructuredSubject(mixed $value): ?array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$policyType = self::stringOrNull($value['policy_type'] ?? null);
|
||||
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
|
||||
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
|
||||
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
|
||||
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
|
||||
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
|
||||
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
|
||||
|
||||
if ($policyType === null
|
||||
|| $subjectKey === null
|
||||
|| $subjectClass === null
|
||||
|| $resolutionPath === null
|
||||
|| $resolutionOutcome === null
|
||||
|| $reasonCode === null
|
||||
|| $operatorActionCategory === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|
||||
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|
||||
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|
||||
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
|
||||
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
|
||||
|
||||
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
|
||||
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
|
||||
|
||||
return [
|
||||
'policy_type' => $policyType,
|
||||
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
|
||||
'subject_key' => $subjectKey,
|
||||
'subject_class' => $subjectClass,
|
||||
'resolution_path' => $resolutionPath,
|
||||
'resolution_outcome' => $resolutionOutcome,
|
||||
'reason_code' => $reasonCode,
|
||||
'operator_action_category' => $operatorActionCategory,
|
||||
'structural' => self::boolOrFalse($value['structural'] ?? null),
|
||||
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
|
||||
'source_model_expected' => $sourceModelExpected,
|
||||
'source_model_found' => $sourceModelFound,
|
||||
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $byReason
|
||||
* @param array<string, list<array<string, mixed>>> $subjects
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function orderedReasons(array $byReason, array $subjects): array
|
||||
{
|
||||
$reasons = array_keys($byReason);
|
||||
|
||||
foreach (array_keys($subjects) as $reason) {
|
||||
if (! in_array($reason, $reasons, true)) {
|
||||
$reasons[] = $reason;
|
||||
}
|
||||
}
|
||||
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $byReason
|
||||
* @param array<string, list<array<string, mixed>>> $subjects
|
||||
*/
|
||||
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
|
||||
{
|
||||
if (is_numeric($count)) {
|
||||
$intCount = (int) $count;
|
||||
|
||||
if ($intCount >= 0) {
|
||||
return $intCount;
|
||||
}
|
||||
}
|
||||
|
||||
$byReasonCount = array_sum($byReason);
|
||||
|
||||
if ($byReasonCount > 0) {
|
||||
return $byReasonCount;
|
||||
}
|
||||
|
||||
return array_sum(array_map(
|
||||
static fn (array $rows): int => count($rows),
|
||||
$subjects,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
|
||||
*/
|
||||
private static function detailState(int $count, array $subjects): string
|
||||
{
|
||||
if ($count <= 0) {
|
||||
return 'no_gaps';
|
||||
}
|
||||
|
||||
if ($subjects['legacy_mode']) {
|
||||
return 'legacy_broad_reason';
|
||||
}
|
||||
|
||||
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
|
||||
}
|
||||
|
||||
private static function bucketDetailState(string $detailState, int $recordedCount): string
|
||||
{
|
||||
if ($detailState === 'legacy_broad_reason') {
|
||||
return 'legacy_broad_reason';
|
||||
}
|
||||
|
||||
if ($recordedCount > 0) {
|
||||
return 'structured_details_recorded';
|
||||
}
|
||||
|
||||
return 'details_not_recorded';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function projectSubjectRow(array $subject): array
|
||||
{
|
||||
$reasonCode = (string) $subject['reason_code'];
|
||||
$subjectClass = (string) $subject['subject_class'];
|
||||
$resolutionOutcome = (string) $subject['resolution_outcome'];
|
||||
$operatorActionCategory = (string) $subject['operator_action_category'];
|
||||
|
||||
return array_merge($subject, [
|
||||
'reason_label' => self::reasonLabel($reasonCode),
|
||||
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||
'search_text' => Str::lower(trim(implode(' ', array_filter([
|
||||
$reasonCode,
|
||||
self::reasonLabel($reasonCode),
|
||||
(string) ($subject['policy_type'] ?? ''),
|
||||
(string) ($subject['subject_key'] ?? ''),
|
||||
$subjectClass,
|
||||
self::subjectClassLabel($subjectClass),
|
||||
(string) ($subject['resolution_path'] ?? ''),
|
||||
$resolutionOutcome,
|
||||
self::resolutionOutcomeLabel($resolutionOutcome),
|
||||
$operatorActionCategory,
|
||||
self::operatorActionCategoryLabel($operatorActionCategory),
|
||||
(string) ($subject['subject_external_id'] ?? ''),
|
||||
])))),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
private static function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
private static function boolOrFalse(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value) || is_string($value)) {
|
||||
return filter_var($value, FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
233
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
233
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
final class BaselineCompareExplanationRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperatorExplanationBuilder $builder,
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
) {}
|
||||
|
||||
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
||||
{
|
||||
$reason = $stats->reasonCode !== null
|
||||
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
||||
: null;
|
||||
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
||||
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$findingsCount = (int) ($stats->findingsCount ?? 0);
|
||||
$executionOutcome = match ($stats->state) {
|
||||
'comparing' => 'in_progress',
|
||||
'failed' => 'failed',
|
||||
default => $hasWarnings ? 'completed_with_follow_up' : 'completed',
|
||||
};
|
||||
$executionOutcomeLabel = match ($executionOutcome) {
|
||||
'in_progress' => 'In progress',
|
||||
'failed' => 'Execution failed',
|
||||
'completed_with_follow_up' => 'Completed with follow-up',
|
||||
default => 'Completed successfully',
|
||||
};
|
||||
$family = $reason?->absencePattern !== null
|
||||
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
||||
: null;
|
||||
$family ??= match (true) {
|
||||
$stats->state === 'comparing' => ExplanationFamily::InProgress,
|
||||
$stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite,
|
||||
$stats->state === 'no_tenant',
|
||||
$stats->state === 'no_assignment',
|
||||
$stats->state === 'no_snapshot',
|
||||
$stats->state === 'idle' => ExplanationFamily::Unavailable,
|
||||
$findingsCount === 0 && ! $hasWarnings => ExplanationFamily::NoIssuesDetected,
|
||||
$hasWarnings => ExplanationFamily::CompletedButLimited,
|
||||
default => ExplanationFamily::TrustworthyResult,
|
||||
};
|
||||
$trustworthiness = $reason?->trustImpact !== null
|
||||
? TrustworthinessLevel::tryFrom($reason->trustImpact)
|
||||
: null;
|
||||
$trustworthiness ??= match (true) {
|
||||
$family === ExplanationFamily::NoIssuesDetected,
|
||||
$family === ExplanationFamily::TrustworthyResult => TrustworthinessLevel::Trustworthy,
|
||||
$family === ExplanationFamily::CompletedButLimited => TrustworthinessLevel::LimitedConfidence,
|
||||
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
||||
default => TrustworthinessLevel::Unusable,
|
||||
};
|
||||
$evaluationResult = match ($family) {
|
||||
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||
ExplanationFamily::MissingInput,
|
||||
ExplanationFamily::BlockedPrerequisite,
|
||||
ExplanationFamily::Unavailable,
|
||||
ExplanationFamily::InProgress => 'unavailable',
|
||||
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
|
||||
? 'suppressed_result'
|
||||
: 'incomplete_result',
|
||||
};
|
||||
$headline = match ($family) {
|
||||
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
|
||||
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
||||
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
||||
? 'The comparison found drift, but the result needs caution.'
|
||||
: 'The comparison finished, but the current result is not an all-clear.',
|
||||
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
|
||||
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
|
||||
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
||||
ExplanationFamily::InProgress => 'The comparison is still running.',
|
||||
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
||||
};
|
||||
$coverageStatement = match (true) {
|
||||
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
||||
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
|
||||
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
|
||||
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
||||
$stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.',
|
||||
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
||||
default => 'Coverage matched the in-scope compare input for this run.',
|
||||
};
|
||||
$reliabilityStatement = match ($trustworthiness) {
|
||||
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
||||
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
|
||||
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
||||
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
|
||||
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
||||
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
||||
};
|
||||
$nextActionText = $reason?->firstNextStep()?->label ?? match ($family) {
|
||||
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
||||
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
||||
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
||||
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
|
||||
ExplanationFamily::InProgress => 'Wait for the compare to finish',
|
||||
ExplanationFamily::MissingInput,
|
||||
ExplanationFamily::BlockedPrerequisite,
|
||||
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
||||
? 'Run the baseline compare to generate a result'
|
||||
: 'Review the blocking baseline or scope prerequisite',
|
||||
};
|
||||
|
||||
return $this->builder->build(
|
||||
family: $family,
|
||||
headline: $headline,
|
||||
executionOutcome: $executionOutcome,
|
||||
executionOutcomeLabel: $executionOutcomeLabel,
|
||||
evaluationResult: $evaluationResult,
|
||||
trustworthinessLevel: $trustworthiness,
|
||||
reliabilityStatement: $reliabilityStatement,
|
||||
coverageStatement: $coverageStatement,
|
||||
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
||||
dominantCauseLabel: $reason?->operatorLabel,
|
||||
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
||||
nextActionCategory: $family === ExplanationFamily::NoIssuesDetected
|
||||
? 'none'
|
||||
: match ($family) {
|
||||
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
||||
ExplanationFamily::MissingInput,
|
||||
ExplanationFamily::BlockedPrerequisite,
|
||||
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
||||
default => 'review_evidence_gaps',
|
||||
},
|
||||
nextActionText: $nextActionText,
|
||||
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
||||
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
||||
diagnosticsSummary: 'Run evidence and low-level compare diagnostics remain available below the primary explanation.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, CountDescriptor>
|
||||
*/
|
||||
private function countDescriptors(
|
||||
BaselineCompareStats $stats,
|
||||
bool $hasCoverageWarnings,
|
||||
bool $hasEvidenceGaps,
|
||||
): array {
|
||||
$descriptors = [];
|
||||
|
||||
if ($stats->findingsCount !== null) {
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: 'Findings shown',
|
||||
value: (int) $stats->findingsCount,
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
qualifier: $hasCoverageWarnings || $hasEvidenceGaps ? 'not complete' : null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stats->uncoveredTypesCount !== null) {
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: 'Uncovered types',
|
||||
value: (int) $stats->uncoveredTypesCount,
|
||||
role: CountDescriptor::ROLE_COVERAGE,
|
||||
qualifier: (int) $stats->uncoveredTypesCount > 0 ? 'coverage gap' : null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stats->evidenceGapsCount !== null) {
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: 'Evidence gaps',
|
||||
value: (int) $stats->evidenceGapsCount,
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: (int) $stats->evidenceGapsCount > 0 ? 'review needed' : null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stats->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) {
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: 'Structural gaps',
|
||||
value: (int) $stats->evidenceGapStructuralCount,
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: 'product or support limit',
|
||||
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) {
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: 'Operational gaps',
|
||||
value: (int) $stats->evidenceGapOperationalCount,
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: 'local evidence missing',
|
||||
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) {
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: 'Transient gaps',
|
||||
value: (int) $stats->evidenceGapTransientCount,
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: 'retry may help',
|
||||
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stats->severityCounts !== []) {
|
||||
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
|
||||
$value = (int) ($stats->severityCounts[$key] ?? 0);
|
||||
|
||||
if ($value === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: $label,
|
||||
value: $value,
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptors;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
enum BaselineCompareReasonCode: string
|
||||
{
|
||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||
@ -22,4 +25,37 @@ public function message(): string
|
||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||
};
|
||||
}
|
||||
|
||||
public function explanationFamily(): ExplanationFamily
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::RolloutDisabled => ExplanationFamily::CompletedButLimited,
|
||||
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||
};
|
||||
}
|
||||
|
||||
public function trustworthinessLevel(): TrustworthinessLevel
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete => TrustworthinessLevel::LimitedConfidence,
|
||||
self::RolloutDisabled,
|
||||
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||
};
|
||||
}
|
||||
|
||||
public function absencePattern(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoDriftDetected => 'true_no_result',
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete => 'suppressed_output',
|
||||
self::RolloutDisabled => 'blocked_prerequisite',
|
||||
self::NoSubjectsInScope => 'missing_input',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,13 +5,17 @@
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class BaselineCompareStats
|
||||
@ -20,6 +24,32 @@ final class BaselineCompareStats
|
||||
* @param array<string, int> $severityCounts
|
||||
* @param list<string> $uncoveredTypes
|
||||
* @param array<string, int> $evidenceGapsTopReasons
|
||||
* @param array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* } $evidenceGapDetails
|
||||
* @param array<string, mixed> $baselineCompareDiagnostics
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $state,
|
||||
@ -28,6 +58,7 @@ private function __construct(
|
||||
public readonly ?int $profileId,
|
||||
public readonly ?int $snapshotId,
|
||||
public readonly ?int $duplicateNamePoliciesCount,
|
||||
public readonly ?int $duplicateNameSubjectsCount,
|
||||
public readonly ?int $operationRunId,
|
||||
public readonly ?int $findingsCount,
|
||||
public readonly array $severityCounts,
|
||||
@ -43,6 +74,12 @@ private function __construct(
|
||||
public readonly ?int $evidenceGapsCount = null,
|
||||
public readonly array $evidenceGapsTopReasons = [],
|
||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
||||
public readonly array $evidenceGapDetails = [],
|
||||
public readonly array $baselineCompareDiagnostics = [],
|
||||
public readonly ?int $evidenceGapStructuralCount = null,
|
||||
public readonly ?int $evidenceGapOperationalCount = null,
|
||||
public readonly ?int $evidenceGapTransientCount = null,
|
||||
public readonly ?bool $evidenceGapLegacyMode = null,
|
||||
) {}
|
||||
|
||||
public static function forTenant(?Tenant $tenant): self
|
||||
@ -73,7 +110,11 @@ public static function forTenant(?Tenant $tenant): self
|
||||
|
||||
$profileName = (string) $profile->name;
|
||||
$profileId = (int) $profile->getKey();
|
||||
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
@ -83,15 +124,27 @@ public static function forTenant(?Tenant $tenant): self
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
|
||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
|
||||
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
|
||||
$duplicateNameSubjectsCount = $duplicateNameStats['subject_count'];
|
||||
|
||||
if ($snapshotId === null) {
|
||||
return self::empty(
|
||||
'no_snapshot',
|
||||
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||
return new self(
|
||||
state: 'no_snapshot',
|
||||
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: null,
|
||||
failureReason: null,
|
||||
reasonCode: $snapshotReasonCode,
|
||||
reasonMessage: $snapshotReasonMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@ -105,6 +158,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
||||
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
||||
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
||||
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
||||
? (int) $evidenceGapSummary['structural_count']
|
||||
: null;
|
||||
$evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null)
|
||||
? (int) $evidenceGapSummary['operational_count']
|
||||
: null;
|
||||
$evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null)
|
||||
? (int) $evidenceGapSummary['transient_count']
|
||||
: null;
|
||||
$evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null)
|
||||
? (bool) $evidenceGapSummary['legacy_mode']
|
||||
: null;
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
@ -115,6 +183,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
@ -130,6 +199,12 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -147,6 +222,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
@ -162,6 +238,12 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -201,6 +283,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: $severityCounts,
|
||||
@ -216,6 +299,12 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -229,6 +318,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: 0,
|
||||
severityCounts: $severityCounts,
|
||||
@ -244,6 +334,12 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -254,6 +350,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: $severityCounts,
|
||||
@ -269,6 +366,12 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -291,6 +394,11 @@ public static function forWidget(?Tenant $tenant): self
|
||||
}
|
||||
|
||||
$profile = $assignment->baselineProfile;
|
||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$severityRows = Finding::query()
|
||||
@ -314,12 +422,13 @@ public static function forWidget(?Tenant $tenant): self
|
||||
->first();
|
||||
|
||||
return new self(
|
||||
state: $totalFindings > 0 ? 'ready' : 'idle',
|
||||
message: null,
|
||||
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
|
||||
message: $snapshotId === null ? $snapshotReasonMessage : null,
|
||||
profileName: (string) $profile->name,
|
||||
profileId: (int) $profile->getKey(),
|
||||
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: null,
|
||||
duplicateNameSubjectsCount: null,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: [
|
||||
@ -330,20 +439,28 @@ public static function forWidget(?Tenant $tenant): self
|
||||
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
||||
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
||||
failureReason: null,
|
||||
reasonCode: $snapshotReasonCode,
|
||||
reasonMessage: $snapshotReasonMessage,
|
||||
);
|
||||
}
|
||||
|
||||
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
|
||||
/**
|
||||
* @return array{policy_count: int, subject_count: int}
|
||||
*/
|
||||
private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array
|
||||
{
|
||||
$policyTypes = $effectiveScope->allTypes();
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return 0;
|
||||
return [
|
||||
'policy_count' => 0,
|
||||
'subject_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
||||
|
||||
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
|
||||
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array {
|
||||
/**
|
||||
* @var array<string, int> $countsByKey
|
||||
*/
|
||||
@ -376,14 +493,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
||||
});
|
||||
|
||||
$duplicatePolicies = 0;
|
||||
$duplicateSubjects = 0;
|
||||
|
||||
foreach ($countsByKey as $count) {
|
||||
if ($count > 1) {
|
||||
$duplicateSubjects++;
|
||||
$duplicatePolicies += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $duplicatePolicies;
|
||||
return [
|
||||
'policy_count' => $duplicatePolicies,
|
||||
'subject_count' => $duplicateSubjects,
|
||||
];
|
||||
};
|
||||
|
||||
if (app()->environment('testing')) {
|
||||
@ -397,7 +519,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
||||
$latestInventorySyncRunId ?? 'all',
|
||||
);
|
||||
|
||||
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
/** @var array{policy_count: int, subject_count: int} $stats */
|
||||
$stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
||||
@ -491,48 +616,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$details = self::evidenceGapDetailsForRun($run);
|
||||
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
|
||||
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
|
||||
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
|
||||
|
||||
return [$count, array_slice($byReason, 0, 6, true)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private static function evidenceGapDetailsForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return BaselineCompareEvidenceGapDetails::fromContext([]);
|
||||
}
|
||||
|
||||
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return [null, []];
|
||||
return [];
|
||||
}
|
||||
|
||||
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||
|
||||
if (! is_array($gaps)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$count = $gaps['count'] ?? null;
|
||||
$count = is_numeric($count) ? (int) $count : null;
|
||||
|
||||
$byReason = $gaps['by_reason'] ?? null;
|
||||
$byReason = is_array($byReason) ? $byReason : [];
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($byReason as $reason => $value) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$intValue = (int) $value;
|
||||
|
||||
if ($intValue <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $intValue;
|
||||
}
|
||||
|
||||
if ($count === null) {
|
||||
$count = array_sum($normalized);
|
||||
}
|
||||
|
||||
arsort($normalized);
|
||||
|
||||
return [$count, array_slice($normalized, 0, 6, true)];
|
||||
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -561,12 +705,38 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar
|
||||
];
|
||||
}
|
||||
|
||||
public function operatorExplanation(): OperatorExplanationPattern
|
||||
{
|
||||
/** @var BaselineCompareExplanationRegistry $registry */
|
||||
$registry = app(BaselineCompareExplanationRegistry::class);
|
||||
|
||||
return $registry->forStats($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }>
|
||||
*/
|
||||
public function explanationCountDescriptors(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
|
||||
$this->operatorExplanation()->countDescriptors,
|
||||
);
|
||||
}
|
||||
|
||||
private static function empty(
|
||||
string $state,
|
||||
?string $message,
|
||||
?string $profileName = null,
|
||||
?int $profileId = null,
|
||||
?int $duplicateNamePoliciesCount = null,
|
||||
?int $duplicateNameSubjectsCount = null,
|
||||
): self {
|
||||
return new self(
|
||||
state: $state,
|
||||
@ -575,6 +745,7 @@ private static function empty(
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
@ -583,4 +754,15 @@ private static function empty(
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
private static function missingSnapshotMessage(?string $reasonCode): ?string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,13 +18,125 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
||||
|
||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
||||
|
||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
||||
|
||||
public const string SNAPSHOT_SUPERSEDED = 'baseline.snapshot.superseded';
|
||||
|
||||
public const string SNAPSHOT_CAPTURE_FAILED = 'baseline.snapshot.capture_failed';
|
||||
|
||||
public const string SNAPSHOT_COMPLETION_PROOF_FAILED = 'baseline.snapshot.completion_proof_failed';
|
||||
|
||||
public const string SNAPSHOT_LEGACY_NO_PROOF = 'baseline.snapshot.legacy_no_proof';
|
||||
|
||||
public const string SNAPSHOT_LEGACY_CONTRADICTORY = 'baseline.snapshot.legacy_contradictory';
|
||||
|
||||
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
|
||||
|
||||
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
|
||||
|
||||
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
|
||||
|
||||
public const string COMPARE_NO_CONSUMABLE_SNAPSHOT = 'baseline.compare.no_consumable_snapshot';
|
||||
|
||||
public const string COMPARE_NO_ELIGIBLE_TARGET = 'baseline.compare.no_eligible_target';
|
||||
|
||||
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
||||
|
||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
||||
|
||||
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
|
||||
|
||||
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
|
||||
|
||||
public const string COMPARE_SNAPSHOT_SUPERSEDED = 'baseline.compare.snapshot_superseded';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::SNAPSHOT_CAPTURE_FAILED,
|
||||
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||
self::COMPARE_NO_ASSIGNMENT,
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isKnown(?string $reasonCode): bool
|
||||
{
|
||||
return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true);
|
||||
}
|
||||
|
||||
public static function trustImpact(?string $reasonCode): ?string
|
||||
{
|
||||
return match (trim((string) $reasonCode)) {
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'limited_confidence',
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||
self::COMPARE_NO_ASSIGNMENT,
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function absencePattern(?string $reasonCode): ?string
|
||||
{
|
||||
return match (trim((string) $reasonCode)) {
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE => 'missing_input',
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::COMPARE_NO_ASSIGNMENT,
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +118,17 @@ public function allTypes(): array
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
|
||||
{
|
||||
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
|
||||
|
||||
return $guardResult['allowed_types'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -134,17 +145,32 @@ public function toJsonb(): array
|
||||
*
|
||||
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
|
||||
*/
|
||||
public function toEffectiveScopeContext(): array
|
||||
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||
{
|
||||
$expanded = $this->expandDefaults();
|
||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||
|
||||
return [
|
||||
$context = [
|
||||
'policy_types' => $expanded->policyTypes,
|
||||
'foundation_types' => $expanded->foundationTypes,
|
||||
'all_types' => $allTypes,
|
||||
'foundations_included' => $expanded->foundationTypes !== [],
|
||||
];
|
||||
|
||||
if (! is_string($operation) || $operation === '') {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||
$guardResult = $guard->guardTypes($allTypes, $operation);
|
||||
|
||||
return array_merge($context, [
|
||||
'truthful_types' => $guardResult['allowed_types'],
|
||||
'limited_types' => $guardResult['limited_types'],
|
||||
'unsupported_types' => $guardResult['unsupported_types'],
|
||||
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||
'capabilities' => $guardResult['capabilities'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
42
app/Support/Baselines/BaselineSnapshotLifecycleState.php
Normal file
42
app/Support/Baselines/BaselineSnapshotLifecycleState.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum BaselineSnapshotLifecycleState: string
|
||||
{
|
||||
case Building = 'building';
|
||||
case Complete = 'complete';
|
||||
case Incomplete = 'incomplete';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Building => 'Building',
|
||||
self::Complete => 'Complete',
|
||||
self::Incomplete => 'Incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
public function isConsumable(): bool
|
||||
{
|
||||
return $this === self::Complete;
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return in_array($this, [self::Complete, self::Incomplete], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $state): string => $state->value,
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
app/Support/Baselines/BaselineSupportCapabilityGuard.php
Normal file
79
app/Support/Baselines/BaselineSupportCapabilityGuard.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class BaselineSupportCapabilityGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SubjectResolver $resolver,
|
||||
) {}
|
||||
|
||||
public function inspectType(string $policyType): SupportCapabilityRecord
|
||||
{
|
||||
return $this->resolver->capability($policyType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $policyTypes
|
||||
* @return array{
|
||||
* allowed_types: list<string>,
|
||||
* limited_types: list<string>,
|
||||
* unsupported_types: list<string>,
|
||||
* invalid_support_types: list<string>,
|
||||
* capabilities: array<string, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function guardTypes(array $policyTypes, string $operation): array
|
||||
{
|
||||
$allowedTypes = [];
|
||||
$limitedTypes = [];
|
||||
$unsupportedTypes = [];
|
||||
$invalidSupportTypes = [];
|
||||
$capabilities = [];
|
||||
|
||||
foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) {
|
||||
$record = $this->inspectType($policyType);
|
||||
$mode = $record->supportModeFor($operation);
|
||||
|
||||
$capabilities[$policyType] = array_merge(
|
||||
$record->toArray(),
|
||||
['support_mode' => $mode],
|
||||
);
|
||||
|
||||
if ($mode === 'invalid_support_config') {
|
||||
$invalidSupportTypes[] = $policyType;
|
||||
$unsupportedTypes[] = $policyType;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($record->allows($operation)) {
|
||||
$allowedTypes[] = $policyType;
|
||||
|
||||
if ($mode === 'limited') {
|
||||
$limitedTypes[] = $policyType;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$unsupportedTypes[] = $policyType;
|
||||
}
|
||||
|
||||
sort($allowedTypes, SORT_STRING);
|
||||
sort($limitedTypes, SORT_STRING);
|
||||
sort($unsupportedTypes, SORT_STRING);
|
||||
sort($invalidSupportTypes, SORT_STRING);
|
||||
ksort($capabilities);
|
||||
|
||||
return [
|
||||
'allowed_types' => $allowedTypes,
|
||||
'limited_types' => $limitedTypes,
|
||||
'unsupported_types' => $unsupportedTypes,
|
||||
'invalid_support_types' => $invalidSupportTypes,
|
||||
'capabilities' => $capabilities,
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Support/Baselines/OperatorActionCategory.php
Normal file
16
app/Support/Baselines/OperatorActionCategory.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum OperatorActionCategory: string
|
||||
{
|
||||
case None = 'none';
|
||||
case Retry = 'retry';
|
||||
case RunInventorySync = 'run_inventory_sync';
|
||||
case RunPolicySyncOrBackup = 'run_policy_sync_or_backup';
|
||||
case ReviewPermissions = 'review_permissions';
|
||||
case InspectSubjectMapping = 'inspect_subject_mapping';
|
||||
case ProductFollowUp = 'product_follow_up';
|
||||
}
|
||||
25
app/Support/Baselines/ResolutionOutcome.php
Normal file
25
app/Support/Baselines/ResolutionOutcome.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum ResolutionOutcome: string
|
||||
{
|
||||
case ResolvedPolicy = 'resolved_policy';
|
||||
case ResolvedInventory = 'resolved_inventory';
|
||||
case PolicyRecordMissing = 'policy_record_missing';
|
||||
case InventoryRecordMissing = 'inventory_record_missing';
|
||||
case FoundationInventoryOnly = 'foundation_inventory_only';
|
||||
case ResolutionTypeMismatch = 'resolution_type_mismatch';
|
||||
case UnresolvableSubject = 'unresolvable_subject';
|
||||
case InvalidSupportConfig = 'invalid_support_config';
|
||||
case PermissionOrScopeBlocked = 'permission_or_scope_blocked';
|
||||
case AmbiguousMatch = 'ambiguous_match';
|
||||
case InvalidSubject = 'invalid_subject';
|
||||
case DuplicateSubject = 'duplicate_subject';
|
||||
case RetryableCaptureFailure = 'retryable_capture_failure';
|
||||
case CaptureFailed = 'capture_failed';
|
||||
case Throttled = 'throttled';
|
||||
case BudgetExhausted = 'budget_exhausted';
|
||||
}
|
||||
36
app/Support/Baselines/ResolutionOutcomeRecord.php
Normal file
36
app/Support/Baselines/ResolutionOutcomeRecord.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class ResolutionOutcomeRecord
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $reasonCode
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelFound
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ResolutionOutcome $resolutionOutcome,
|
||||
public readonly string $reasonCode,
|
||||
public readonly OperatorActionCategory $operatorActionCategory,
|
||||
public readonly bool $structural,
|
||||
public readonly bool $retryable,
|
||||
public readonly ?string $sourceModelExpected = null,
|
||||
public readonly ?string $sourceModelFound = null,
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'resolution_outcome' => $this->resolutionOutcome->value,
|
||||
'reason_code' => $this->reasonCode,
|
||||
'operator_action_category' => $this->operatorActionCategory->value,
|
||||
'structural' => $this->structural,
|
||||
'retryable' => $this->retryable,
|
||||
'source_model_expected' => $this->sourceModelExpected,
|
||||
'source_model_found' => $this->sourceModelFound,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
app/Support/Baselines/ResolutionPath.php
Normal file
14
app/Support/Baselines/ResolutionPath.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum ResolutionPath: string
|
||||
{
|
||||
case Policy = 'policy';
|
||||
case Inventory = 'inventory';
|
||||
case FoundationPolicy = 'foundation_policy';
|
||||
case FoundationInventory = 'foundation_inventory';
|
||||
case Derived = 'derived';
|
||||
}
|
||||
13
app/Support/Baselines/SubjectClass.php
Normal file
13
app/Support/Baselines/SubjectClass.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum SubjectClass: string
|
||||
{
|
||||
case PolicyBacked = 'policy_backed';
|
||||
case InventoryBacked = 'inventory_backed';
|
||||
case FoundationBacked = 'foundation_backed';
|
||||
case Derived = 'derived';
|
||||
}
|
||||
47
app/Support/Baselines/SubjectDescriptor.php
Normal file
47
app/Support/Baselines/SubjectDescriptor.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class SubjectDescriptor
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $policyType
|
||||
* @param non-empty-string $subjectKey
|
||||
* @param 'supported'|'limited'|'excluded'|'invalid_support_config' $supportMode
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $policyType,
|
||||
public readonly ?string $subjectExternalId,
|
||||
public readonly string $subjectKey,
|
||||
public readonly SubjectClass $subjectClass,
|
||||
public readonly ResolutionPath $resolutionPath,
|
||||
public readonly string $supportMode,
|
||||
public readonly ?string $sourceModelExpected,
|
||||
) {}
|
||||
|
||||
public function expectsPolicy(): bool
|
||||
{
|
||||
return $this->sourceModelExpected === 'policy';
|
||||
}
|
||||
|
||||
public function expectsInventory(): bool
|
||||
{
|
||||
return $this->sourceModelExpected === 'inventory';
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'policy_type' => $this->policyType,
|
||||
'subject_external_id' => $this->subjectExternalId,
|
||||
'subject_key' => $this->subjectKey,
|
||||
'subject_class' => $this->subjectClass->value,
|
||||
'resolution_path' => $this->resolutionPath->value,
|
||||
'support_mode' => $this->supportMode,
|
||||
'source_model_expected' => $this->sourceModelExpected,
|
||||
];
|
||||
}
|
||||
}
|
||||
201
app/Support/Baselines/SubjectResolver.php
Normal file
201
app/Support/Baselines/SubjectResolver.php
Normal file
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
final class SubjectResolver
|
||||
{
|
||||
public function capability(string $policyType): SupportCapabilityRecord
|
||||
{
|
||||
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
|
||||
|
||||
return new SupportCapabilityRecord(
|
||||
policyType: $policyType,
|
||||
subjectClass: SubjectClass::from($contract['subject_class']),
|
||||
compareCapability: $contract['compare_capability'],
|
||||
captureCapability: $contract['capture_capability'],
|
||||
resolutionPath: ResolutionPath::from($contract['resolution_path']),
|
||||
configSupported: (bool) $contract['config_supported'],
|
||||
runtimeValid: (bool) $contract['runtime_valid'],
|
||||
sourceModelExpected: $contract['source_model_expected'],
|
||||
);
|
||||
}
|
||||
|
||||
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||
{
|
||||
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
||||
}
|
||||
|
||||
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||
{
|
||||
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
||||
}
|
||||
|
||||
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
|
||||
{
|
||||
$outcome = $descriptor->expectsPolicy()
|
||||
? ResolutionOutcome::ResolvedPolicy
|
||||
: ResolutionOutcome::ResolvedInventory;
|
||||
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: $outcome,
|
||||
reasonCode: $outcome->value,
|
||||
operatorActionCategory: OperatorActionCategory::None,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
$expectsPolicy = $descriptor->expectsPolicy();
|
||||
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
|
||||
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
|
||||
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
||||
reasonCode: 'foundation_not_policy_backed',
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: 'inventory',
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::InvalidSubject,
|
||||
reasonCode: 'invalid_subject',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
|
||||
reasonCode: 'duplicate_subject',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
|
||||
reasonCode: 'ambiguous_match',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
|
||||
reasonCode: 'invalid_support_config',
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $capability->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::Throttled,
|
||||
reasonCode: 'throttled',
|
||||
operatorActionCategory: OperatorActionCategory::Retry,
|
||||
structural: false,
|
||||
retryable: true,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
|
||||
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
|
||||
operatorActionCategory: OperatorActionCategory::Retry,
|
||||
structural: false,
|
||||
retryable: $retryable,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
|
||||
reasonCode: 'budget_exhausted',
|
||||
operatorActionCategory: OperatorActionCategory::Retry,
|
||||
structural: false,
|
||||
retryable: true,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||
{
|
||||
$capability = $this->capability($policyType);
|
||||
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
|
||||
|
||||
return new SubjectDescriptor(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
|
||||
subjectKey: $resolvedSubjectKey,
|
||||
subjectClass: $capability->subjectClass,
|
||||
resolutionPath: $capability->resolutionPath,
|
||||
supportMode: $capability->supportModeFor($operation),
|
||||
sourceModelExpected: $capability->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
|
||||
{
|
||||
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
|
||||
|
||||
if ($trimmedSubjectKey !== '') {
|
||||
return $trimmedSubjectKey;
|
||||
}
|
||||
|
||||
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
|
||||
|
||||
if (is_string($generated) && $generated !== '') {
|
||||
return $generated;
|
||||
}
|
||||
|
||||
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
|
||||
? trim($subjectExternalId)
|
||||
: 'unknown';
|
||||
|
||||
return trim($policyType).'|'.$fallbackExternalId;
|
||||
}
|
||||
}
|
||||
67
app/Support/Baselines/SupportCapabilityRecord.php
Normal file
67
app/Support/Baselines/SupportCapabilityRecord.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class SupportCapabilityRecord
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $policyType
|
||||
* @param 'supported'|'limited'|'unsupported' $compareCapability
|
||||
* @param 'supported'|'limited'|'unsupported' $captureCapability
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $policyType,
|
||||
public readonly SubjectClass $subjectClass,
|
||||
public readonly string $compareCapability,
|
||||
public readonly string $captureCapability,
|
||||
public readonly ResolutionPath $resolutionPath,
|
||||
public readonly bool $configSupported,
|
||||
public readonly bool $runtimeValid,
|
||||
public readonly ?string $sourceModelExpected = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return 'supported'|'limited'|'excluded'|'invalid_support_config'
|
||||
*/
|
||||
public function supportModeFor(string $operation): string
|
||||
{
|
||||
$capability = match ($operation) {
|
||||
'compare' => $this->compareCapability,
|
||||
'capture' => $this->captureCapability,
|
||||
default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'),
|
||||
};
|
||||
|
||||
if ($this->configSupported && ! $this->runtimeValid) {
|
||||
return 'invalid_support_config';
|
||||
}
|
||||
|
||||
return match ($capability) {
|
||||
'supported', 'limited' => $capability,
|
||||
default => 'excluded',
|
||||
};
|
||||
}
|
||||
|
||||
public function allows(string $operation): bool
|
||||
{
|
||||
return in_array($this->supportModeFor($operation), ['supported', 'limited'], true);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'policy_type' => $this->policyType,
|
||||
'subject_class' => $this->subjectClass->value,
|
||||
'compare_capability' => $this->compareCapability,
|
||||
'capture_capability' => $this->captureCapability,
|
||||
'resolution_path' => $this->resolutionPath->value,
|
||||
'config_supported' => $this->configSupported,
|
||||
'runtime_valid' => $this->runtimeValid,
|
||||
'source_model_expected' => $this->sourceModelExpected,
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Support/Filament/PanelThemeAsset.php
Normal file
27
app/Support/Filament/PanelThemeAsset.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Filament;
|
||||
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
|
||||
class PanelThemeAsset
|
||||
{
|
||||
public static function resolve(string $entry): string
|
||||
{
|
||||
$manifest = public_path('build/manifest.json');
|
||||
|
||||
if (! is_file($manifest)) {
|
||||
return Vite::asset($entry);
|
||||
}
|
||||
|
||||
/** @var array<string, array{file?: string}>|null $decoded */
|
||||
$decoded = json_decode((string) file_get_contents($manifest), true);
|
||||
$file = $decoded[$entry]['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
return Vite::asset($entry);
|
||||
}
|
||||
|
||||
return asset('build/'.$file);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use App\Support\Baselines\ResolutionPath;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
|
||||
class InventoryPolicyTypeMeta
|
||||
{
|
||||
/**
|
||||
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
|
||||
|
||||
return static::label($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* config_supported: bool,
|
||||
* runtime_valid: bool,
|
||||
* subject_class: string,
|
||||
* resolution_path: string,
|
||||
* compare_capability: string,
|
||||
* capture_capability: string,
|
||||
* source_model_expected: 'policy'|'inventory'|'derived'|null
|
||||
* }
|
||||
*/
|
||||
public static function baselineSupportContract(?string $type): array
|
||||
{
|
||||
$contract = static::defaultBaselineSupportContract($type);
|
||||
$resolution = static::baselineCompareMeta($type)['resolution'] ?? null;
|
||||
|
||||
if (is_array($resolution)) {
|
||||
$contract = array_replace($contract, array_filter([
|
||||
'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null,
|
||||
'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null,
|
||||
'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null,
|
||||
'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null,
|
||||
'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null,
|
||||
], static fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
$subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? ''));
|
||||
$resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? ''));
|
||||
$compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
|
||||
? (string) $contract['compare_capability']
|
||||
: 'unsupported';
|
||||
$captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
|
||||
? (string) $contract['capture_capability']
|
||||
: 'unsupported';
|
||||
$sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true)
|
||||
? (string) $contract['source_model_expected']
|
||||
: null;
|
||||
|
||||
$runtimeValid = $subjectClass instanceof SubjectClass
|
||||
&& $resolutionPath instanceof ResolutionPath
|
||||
&& static::pathMatchesSubjectClass($subjectClass, $resolutionPath)
|
||||
&& static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected);
|
||||
|
||||
if (! $runtimeValid) {
|
||||
$compareCapability = 'unsupported';
|
||||
$captureCapability = 'unsupported';
|
||||
}
|
||||
|
||||
return [
|
||||
'config_supported' => (bool) ($contract['config_supported'] ?? false),
|
||||
'runtime_valid' => $runtimeValid,
|
||||
'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value,
|
||||
'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value,
|
||||
'compare_capability' => $compareCapability,
|
||||
'capture_capability' => $captureCapability,
|
||||
'source_model_expected' => $sourceModelExpected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* config_supported: bool,
|
||||
* subject_class: string,
|
||||
* resolution_path: string,
|
||||
* compare_capability: string,
|
||||
* capture_capability: string,
|
||||
* source_model_expected: 'policy'|'inventory'|'derived'|null
|
||||
* }
|
||||
*/
|
||||
private static function defaultBaselineSupportContract(?string $type): array
|
||||
{
|
||||
if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) {
|
||||
return [
|
||||
'config_supported' => true,
|
||||
'subject_class' => SubjectClass::PolicyBacked->value,
|
||||
'resolution_path' => ResolutionPath::Policy->value,
|
||||
'compare_capability' => 'supported',
|
||||
'capture_capability' => 'supported',
|
||||
'source_model_expected' => 'policy',
|
||||
];
|
||||
}
|
||||
|
||||
if (static::isFoundation($type)) {
|
||||
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
|
||||
$identityStrategy = static::baselineCompareIdentityStrategy($type);
|
||||
$usesPolicyPath = $identityStrategy === 'external_id';
|
||||
|
||||
return [
|
||||
'config_supported' => $supported,
|
||||
'subject_class' => SubjectClass::FoundationBacked->value,
|
||||
'resolution_path' => $usesPolicyPath
|
||||
? ResolutionPath::FoundationPolicy->value
|
||||
: ResolutionPath::FoundationInventory->value,
|
||||
'compare_capability' => ! $supported
|
||||
? 'unsupported'
|
||||
: ($usesPolicyPath ? 'supported' : 'limited'),
|
||||
'capture_capability' => ! $supported
|
||||
? 'unsupported'
|
||||
: ($usesPolicyPath ? 'supported' : 'limited'),
|
||||
'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'config_supported' => false,
|
||||
'subject_class' => SubjectClass::Derived->value,
|
||||
'resolution_path' => ResolutionPath::Derived->value,
|
||||
'compare_capability' => 'unsupported',
|
||||
'capture_capability' => 'unsupported',
|
||||
'source_model_expected' => 'derived',
|
||||
];
|
||||
}
|
||||
|
||||
private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool
|
||||
{
|
||||
return match ($subjectClass) {
|
||||
SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy,
|
||||
SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory,
|
||||
SubjectClass::FoundationBacked => in_array($resolutionPath, [
|
||||
ResolutionPath::FoundationInventory,
|
||||
ResolutionPath::FoundationPolicy,
|
||||
], true),
|
||||
SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived,
|
||||
};
|
||||
}
|
||||
|
||||
private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool
|
||||
{
|
||||
return match ($resolutionPath) {
|
||||
ResolutionPath::Policy,
|
||||
ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy',
|
||||
ResolutionPath::Inventory,
|
||||
ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory',
|
||||
ResolutionPath::Derived => $sourceModelExpected === 'derived',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -378,7 +378,7 @@ private function resolveBaselineProfileRule(NavigationMatrixRule $rule, Baseline
|
||||
return match ($rule->relationKey) {
|
||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||
rule: $rule,
|
||||
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
|
||||
snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
|
||||
workspaceId: (int) $profile->workspace_id,
|
||||
),
|
||||
default => null,
|
||||
|
||||
@ -121,4 +121,12 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
|
||||
{
|
||||
return self::governanceArtifactFamily($operationType) !== null;
|
||||
}
|
||||
|
||||
public static function supportsOperatorExplanation(string $operationType): bool
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
return self::isGovernanceArtifactOperation($operationType)
|
||||
|| $operationType === 'baseline_compare';
|
||||
}
|
||||
}
|
||||
|
||||
83
app/Support/Operations/LifecycleReconciliationReason.php
Normal file
83
app/Support/Operations/LifecycleReconciliationReason.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum LifecycleReconciliationReason: string
|
||||
{
|
||||
case StaleQueued = 'run.stale_queued';
|
||||
case StaleRunning = 'run.stale_running';
|
||||
case InfrastructureTimeoutOrAbandonment = 'run.infrastructure_timeout_or_abandonment';
|
||||
case QueueFailureBridge = 'run.queue_failure_bridge';
|
||||
case AdapterOutOfSync = 'run.adapter_out_of_sync';
|
||||
|
||||
public function operatorLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::StaleQueued => 'Run never started',
|
||||
self::StaleRunning => 'Run stopped reporting progress',
|
||||
self::InfrastructureTimeoutOrAbandonment => 'Infrastructure ended the run',
|
||||
self::QueueFailureBridge => 'Queue failure was reconciled',
|
||||
self::AdapterOutOfSync => 'Lifecycle was reconciled from related records',
|
||||
};
|
||||
}
|
||||
|
||||
public function shortExplanation(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::StaleQueued => 'The run stayed queued past its lifecycle window and was marked failed.',
|
||||
self::StaleRunning => 'The run stayed active past its lifecycle window and was marked failed.',
|
||||
self::InfrastructureTimeoutOrAbandonment => 'Queue infrastructure ended the job before normal completion could update the run.',
|
||||
self::QueueFailureBridge => 'The platform bridged a queue failure back to the owning run and marked it failed.',
|
||||
self::AdapterOutOfSync => 'A related restore record reached terminal truth before the operation run was updated.',
|
||||
};
|
||||
}
|
||||
|
||||
public function actionability(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AdapterOutOfSync => 'non_actionable',
|
||||
default => 'retryable_transient',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
public function nextSteps(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::AdapterOutOfSync => [
|
||||
NextStepOption::instruction('Review the related restore record before deciding whether to run the workflow again.'),
|
||||
],
|
||||
default => [
|
||||
NextStepOption::instruction('Review worker health and logs before retrying this operation.'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public function defaultMessage(): string
|
||||
{
|
||||
return $this->shortExplanation();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->value,
|
||||
operatorLabel: $this->operatorLabel(),
|
||||
shortExplanation: $this->shortExplanation(),
|
||||
actionability: $this->actionability(),
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $this->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
152
app/Support/Operations/OperationLifecyclePolicy.php
Normal file
152
app/Support/Operations/OperationLifecyclePolicy.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class OperationLifecyclePolicy
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* job_class?: class-string,
|
||||
* queued_stale_after_seconds?: int,
|
||||
* running_stale_after_seconds?: int,
|
||||
* expected_max_runtime_seconds?: int,
|
||||
* direct_failed_bridge?: bool,
|
||||
* scheduled_reconciliation?: bool
|
||||
* }>
|
||||
*/
|
||||
public function coveredTypes(): array
|
||||
{
|
||||
$coveredTypes = config('tenantpilot.operations.lifecycle.covered_types', []);
|
||||
|
||||
return is_array($coveredTypes) ? $coveredTypes : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* job_class?: class-string,
|
||||
* queued_stale_after_seconds:int,
|
||||
* running_stale_after_seconds:int,
|
||||
* expected_max_runtime_seconds:?int,
|
||||
* direct_failed_bridge:bool,
|
||||
* scheduled_reconciliation:bool
|
||||
* }|null
|
||||
*/
|
||||
public function definition(string $operationType): ?array
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
if ($operationType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$definition = $this->coveredTypes()[$operationType] ?? null;
|
||||
|
||||
if (! is_array($definition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'job_class' => is_string($definition['job_class'] ?? null) ? $definition['job_class'] : null,
|
||||
'queued_stale_after_seconds' => max(1, (int) ($definition['queued_stale_after_seconds'] ?? 300)),
|
||||
'running_stale_after_seconds' => max(1, (int) ($definition['running_stale_after_seconds'] ?? 900)),
|
||||
'expected_max_runtime_seconds' => is_numeric($definition['expected_max_runtime_seconds'] ?? null)
|
||||
? max(1, (int) $definition['expected_max_runtime_seconds'])
|
||||
: null,
|
||||
'direct_failed_bridge' => (bool) ($definition['direct_failed_bridge'] ?? false),
|
||||
'scheduled_reconciliation' => (bool) ($definition['scheduled_reconciliation'] ?? true),
|
||||
];
|
||||
}
|
||||
|
||||
public function supports(string $operationType): bool
|
||||
{
|
||||
return $this->definition($operationType) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function coveredTypeNames(): array
|
||||
{
|
||||
return array_values(array_keys($this->coveredTypes()));
|
||||
}
|
||||
|
||||
public function queuedStaleAfterSeconds(string $operationType): int
|
||||
{
|
||||
return (int) ($this->definition($operationType)['queued_stale_after_seconds'] ?? 300);
|
||||
}
|
||||
|
||||
public function runningStaleAfterSeconds(string $operationType): int
|
||||
{
|
||||
return (int) ($this->definition($operationType)['running_stale_after_seconds'] ?? 900);
|
||||
}
|
||||
|
||||
public function expectedMaxRuntimeSeconds(string $operationType): ?int
|
||||
{
|
||||
$expectedMaxRuntimeSeconds = $this->definition($operationType)['expected_max_runtime_seconds'] ?? null;
|
||||
|
||||
return is_int($expectedMaxRuntimeSeconds) ? $expectedMaxRuntimeSeconds : null;
|
||||
}
|
||||
|
||||
public function requiresDirectFailedBridge(string $operationType): bool
|
||||
{
|
||||
return (bool) ($this->definition($operationType)['direct_failed_bridge'] ?? false);
|
||||
}
|
||||
|
||||
public function supportsScheduledReconciliation(string $operationType): bool
|
||||
{
|
||||
return (bool) ($this->definition($operationType)['scheduled_reconciliation'] ?? false);
|
||||
}
|
||||
|
||||
public function reconciliationBatchLimit(): int
|
||||
{
|
||||
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.batch_limit', 100));
|
||||
}
|
||||
|
||||
public function reconciliationScheduleMinutes(): int
|
||||
{
|
||||
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.schedule_minutes', 5));
|
||||
}
|
||||
|
||||
public function retryAfterSafetyMarginSeconds(): int
|
||||
{
|
||||
return max(1, (int) config('queue.lifecycle_invariants.retry_after_safety_margin', 30));
|
||||
}
|
||||
|
||||
public function queueConnection(string $operationType): ?string
|
||||
{
|
||||
$jobClass = $this->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$connection = Arr::get(get_class_vars($jobClass), 'connection');
|
||||
|
||||
return is_string($connection) && trim($connection) !== '' ? trim($connection) : config('queue.default');
|
||||
}
|
||||
|
||||
public function queueRetryAfterSeconds(?string $connection = null): ?int
|
||||
{
|
||||
$connection = is_string($connection) && trim($connection) !== '' ? trim($connection) : (string) config('queue.default', 'database');
|
||||
$retryAfter = config("queue.connections.{$connection}.retry_after");
|
||||
|
||||
if (is_numeric($retryAfter)) {
|
||||
return max(1, (int) $retryAfter);
|
||||
}
|
||||
|
||||
$databaseRetryAfter = config('queue.connections.database.retry_after');
|
||||
|
||||
return is_numeric($databaseRetryAfter) ? max(1, (int) $databaseRetryAfter) : null;
|
||||
}
|
||||
|
||||
public function jobClass(string $operationType): ?string
|
||||
{
|
||||
$jobClass = $this->definition($operationType)['job_class'] ?? null;
|
||||
|
||||
return is_string($jobClass) && $jobClass !== '' ? $jobClass : null;
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,16 @@
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final class OperationRunCapabilityResolver
|
||||
{
|
||||
public function requiredCapabilityForRun(OperationRun $run): ?string
|
||||
{
|
||||
return $this->requiredCapabilityForType((string) $run->type);
|
||||
}
|
||||
|
||||
public function requiredCapabilityForType(string $operationType): ?string
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
69
app/Support/Operations/OperationRunFreshnessState.php
Normal file
69
app/Support/Operations/OperationRunFreshnessState.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
enum OperationRunFreshnessState: string
|
||||
{
|
||||
case FreshActive = 'fresh_active';
|
||||
case LikelyStale = 'likely_stale';
|
||||
case ReconciledFailed = 'reconciled_failed';
|
||||
case TerminalNormal = 'terminal_normal';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
public static function forRun(OperationRun $run, ?OperationLifecyclePolicy $policy = null): self
|
||||
{
|
||||
$policy ??= app(OperationLifecyclePolicy::class);
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||
return $run->isLifecycleReconciled() ? self::ReconciledFailed : self::TerminalNormal;
|
||||
}
|
||||
|
||||
if (! $policy->supports((string) $run->type)) {
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
||||
if ($run->started_at !== null || $run->created_at === null) {
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
return $run->created_at->lte(now()->subSeconds($policy->queuedStaleAfterSeconds((string) $run->type)))
|
||||
? self::LikelyStale
|
||||
: self::FreshActive;
|
||||
}
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Running->value) {
|
||||
$startedAt = $run->started_at ?? $run->created_at;
|
||||
|
||||
if ($startedAt === null) {
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
return $startedAt->lte(now()->subSeconds($policy->runningStaleAfterSeconds((string) $run->type)))
|
||||
? self::LikelyStale
|
||||
: self::FreshActive;
|
||||
}
|
||||
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
public function isFreshActive(): bool
|
||||
{
|
||||
return $this === self::FreshActive;
|
||||
}
|
||||
|
||||
public function isLikelyStale(): bool
|
||||
{
|
||||
return $this === self::LikelyStale;
|
||||
}
|
||||
|
||||
public function isReconciledFailed(): bool
|
||||
{
|
||||
return $this === self::ReconciledFailed;
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,11 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
final class OperationUxPresenter
|
||||
@ -98,10 +101,32 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
||||
$operatorExplanationGuidance = self::operatorExplanationGuidance($run);
|
||||
$nextStepLabel = self::firstNextStepLabel($run);
|
||||
$freshnessState = self::freshnessState($run);
|
||||
|
||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
||||
return $reasonGuidance;
|
||||
if ($freshnessState->isLikelyStale()) {
|
||||
return 'This run is past its lifecycle window. Review worker health and logs before retrying from the start surface.';
|
||||
}
|
||||
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return $operatorExplanationGuidance
|
||||
?? $reasonGuidance
|
||||
?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
|
||||
}
|
||||
|
||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true)) {
|
||||
if ($operatorExplanationGuidance !== null) {
|
||||
return $operatorExplanationGuidance;
|
||||
}
|
||||
|
||||
if ($reasonGuidance !== null) {
|
||||
return $reasonGuidance;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) {
|
||||
return $operatorExplanationGuidance;
|
||||
}
|
||||
|
||||
return match ($uxStatus) {
|
||||
@ -124,15 +149,44 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
||||
|
||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||
{
|
||||
$operatorExplanation = self::governanceOperatorExplanation($run);
|
||||
|
||||
if (is_string($operatorExplanation?->dominantCauseExplanation) && trim($operatorExplanation->dominantCauseExplanation) !== '') {
|
||||
return trim($operatorExplanation->dominantCauseExplanation);
|
||||
}
|
||||
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
$sanitizedFailureMessage = self::sanitizeFailureMessage($failureMessage);
|
||||
|
||||
if ($sanitizedFailureMessage !== null) {
|
||||
return $sanitizedFailureMessage;
|
||||
}
|
||||
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
return $reasonEnvelope->shortExplanation;
|
||||
}
|
||||
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
if (self::freshnessState($run)->isLikelyStale()) {
|
||||
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
|
||||
}
|
||||
|
||||
return self::sanitizeFailureMessage($failureMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function freshnessState(OperationRun $run): OperationRunFreshnessState
|
||||
{
|
||||
return $run->freshnessState();
|
||||
}
|
||||
|
||||
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||
{
|
||||
return match (self::freshnessState($run)) {
|
||||
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,6 +196,15 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
$freshnessState = self::freshnessState($run);
|
||||
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return [
|
||||
'titleSuffix' => 'was automatically reconciled',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||
'status' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
return match ($uxStatus) {
|
||||
'succeeded' => [
|
||||
@ -223,4 +286,32 @@ private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonT
|
||||
{
|
||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
||||
}
|
||||
|
||||
private static function operatorExplanationGuidance(OperationRun $run): ?string
|
||||
{
|
||||
$operatorExplanation = self::governanceOperatorExplanation($run);
|
||||
|
||||
if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim($operatorExplanation->nextActionText);
|
||||
|
||||
if (str_ends_with($text, '.')) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return $text === 'No action needed'
|
||||
? 'No action needed.'
|
||||
: 'Next step: '.$text.'.';
|
||||
}
|
||||
|
||||
private static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||
{
|
||||
if (! $run->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class RunDurationInsights
|
||||
@ -118,6 +119,10 @@ public static function expectedHuman(OperationRun $run): ?string
|
||||
|
||||
public static function stuckGuidance(OperationRun $run): ?string
|
||||
{
|
||||
if ($run->freshnessState() === OperationRunFreshnessState::LikelyStale) {
|
||||
return 'Past the lifecycle window. Review worker health and logs before retrying.';
|
||||
}
|
||||
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
|
||||
if (! in_array($uxStatus, ['queued', 'running'], true)) {
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class RunFailureSanitizer
|
||||
@ -130,7 +132,15 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
|
||||
ExecutionDenialReasonCode::cases(),
|
||||
);
|
||||
|
||||
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
||||
$lifecycleReasonCodes = array_map(
|
||||
static fn (LifecycleReconciliationReason $reasonCode): string => $reasonCode->value,
|
||||
LifecycleReconciliationReason::cases(),
|
||||
);
|
||||
|
||||
return ProviderReasonCodes::isKnown($candidate)
|
||||
|| BaselineReasonCodes::isKnown($candidate)
|
||||
|| in_array($candidate, $executionDenialReasonCodes, true)
|
||||
|| in_array($candidate, $lifecycleReasonCodes, true);
|
||||
}
|
||||
|
||||
public static function sanitizeMessage(string $message): string
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class FallbackReasonTranslator implements TranslatesReasonCode
|
||||
@ -43,6 +44,8 @@ public function translate(string $reasonCode, string $surface = 'detail', array
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $actionability === 'non_actionable',
|
||||
diagnosticCodeLabel: $normalizedCode,
|
||||
trustImpact: $this->trustImpactFor($actionability),
|
||||
absencePattern: $this->absencePatternFor($normalizedCode, $actionability),
|
||||
);
|
||||
}
|
||||
|
||||
@ -109,4 +112,36 @@ private function fallbackNextStepsFor(string $actionability): array
|
||||
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
|
||||
};
|
||||
}
|
||||
|
||||
private function trustImpactFor(string $actionability): string
|
||||
{
|
||||
return match ($actionability) {
|
||||
'non_actionable' => TrustworthinessLevel::Trustworthy->value,
|
||||
'retryable_transient' => TrustworthinessLevel::LimitedConfidence->value,
|
||||
default => TrustworthinessLevel::Unusable->value,
|
||||
};
|
||||
}
|
||||
|
||||
private function absencePatternFor(string $reasonCode, string $actionability): ?string
|
||||
{
|
||||
$normalizedCode = strtolower($reasonCode);
|
||||
|
||||
if (str_contains($normalizedCode, 'suppressed')) {
|
||||
return 'suppressed_output';
|
||||
}
|
||||
|
||||
if (str_contains($normalizedCode, 'missing') || str_contains($normalizedCode, 'stale')) {
|
||||
return 'missing_input';
|
||||
}
|
||||
|
||||
if ($actionability === 'prerequisite_missing') {
|
||||
return 'blocked_prerequisite';
|
||||
}
|
||||
|
||||
if ($actionability === 'non_actionable') {
|
||||
return 'true_no_result';
|
||||
}
|
||||
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
@ -24,14 +25,16 @@ public function __construct(
|
||||
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
|
||||
$storedTranslation = $this->storedOperationRunTranslation($context);
|
||||
|
||||
if ($storedTranslation !== null) {
|
||||
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
||||
|
||||
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
||||
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
|
||||
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
|
||||
$nextSteps = $this->operationRunNextSteps($context);
|
||||
|
||||
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
|
||||
return $storedEnvelope->withNextSteps($nextSteps);
|
||||
}
|
||||
|
||||
return $storedEnvelope;
|
||||
@ -39,7 +42,8 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
||||
}
|
||||
|
||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code');
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($context, 'baseline_compare.reason_code');
|
||||
|
||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
||||
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
||||
@ -67,11 +71,33 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
||||
$legacyNextSteps = $this->operationRunNextSteps($context);
|
||||
|
||||
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function storedOperationRunTranslation(array $context): ?array
|
||||
{
|
||||
$storedTranslation = $context['reason_translation'] ?? data_get($context, 'baseline_compare.reason_translation');
|
||||
|
||||
return is_array($storedTranslation) ? $storedTranslation : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
private function operationRunNextSteps(array $context): array
|
||||
{
|
||||
$nextSteps = $context['next_steps'] ?? data_get($context, 'baseline_compare.next_steps');
|
||||
|
||||
return is_array($nextSteps) ? NextStepOption::collect($nextSteps) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
@ -91,6 +117,7 @@ private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
|
||||
|
||||
return ProviderReasonCodes::isKnown($reasonCode)
|
||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||
}
|
||||
@ -167,6 +194,26 @@ public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
||||
return $envelope?->shortExplanation;
|
||||
}
|
||||
|
||||
public function dominantCauseLabel(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->operatorLabel;
|
||||
}
|
||||
|
||||
public function dominantCauseExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->shortExplanation;
|
||||
}
|
||||
|
||||
public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->trustImpact;
|
||||
}
|
||||
|
||||
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->absencePattern;
|
||||
}
|
||||
|
||||
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->guidanceText();
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ReasonResolutionEnvelope
|
||||
@ -19,6 +20,8 @@ public function __construct(
|
||||
public array $nextSteps = [],
|
||||
public bool $showNoActionNeeded = false,
|
||||
public ?string $diagnosticCodeLabel = null,
|
||||
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
|
||||
public ?string $absencePattern = null,
|
||||
) {
|
||||
if (trim($this->internalCode) === '') {
|
||||
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
||||
@ -41,6 +44,24 @@ public function __construct(
|
||||
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
|
||||
}
|
||||
|
||||
if (! in_array($this->trustImpact, array_map(
|
||||
static fn (TrustworthinessLevel $level): string => $level->value,
|
||||
TrustworthinessLevel::cases(),
|
||||
), true)) {
|
||||
throw new InvalidArgumentException('Unsupported reason trust impact: '.$this->trustImpact);
|
||||
}
|
||||
|
||||
if ($this->absencePattern !== null && ! in_array($this->absencePattern, [
|
||||
'none',
|
||||
'true_no_result',
|
||||
'missing_input',
|
||||
'blocked_prerequisite',
|
||||
'suppressed_output',
|
||||
'unavailable',
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported reason absence pattern: '.$this->absencePattern);
|
||||
}
|
||||
|
||||
foreach ($this->nextSteps as $nextStep) {
|
||||
if (! $nextStep instanceof NextStepOption) {
|
||||
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
||||
@ -70,6 +91,12 @@ public static function fromArray(array $data): ?self
|
||||
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
||||
? trim((string) $data['diagnostic_code_label'])
|
||||
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
|
||||
$trustImpact = is_string($data['trust_impact'] ?? null)
|
||||
? trim((string) $data['trust_impact'])
|
||||
: (is_string($data['trustImpact'] ?? null) ? trim((string) $data['trustImpact']) : TrustworthinessLevel::LimitedConfidence->value);
|
||||
$absencePattern = is_string($data['absence_pattern'] ?? null)
|
||||
? trim((string) $data['absence_pattern'])
|
||||
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
|
||||
|
||||
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
|
||||
return null;
|
||||
@ -83,6 +110,8 @@ public static function fromArray(array $data): ?self
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $showNoActionNeeded,
|
||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
||||
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||
absencePattern: $absencePattern !== '' ? $absencePattern : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,6 +128,8 @@ public function withNextSteps(array $nextSteps): self
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $this->showNoActionNeeded,
|
||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
||||
trustImpact: $this->trustImpact,
|
||||
absencePattern: $this->absencePattern,
|
||||
);
|
||||
}
|
||||
|
||||
@ -179,6 +210,8 @@ public function toLegacyNextSteps(): array
|
||||
* }>,
|
||||
* show_no_action_needed: bool,
|
||||
* diagnostic_code_label: string
|
||||
* trust_impact: string,
|
||||
* absence_pattern: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -194,6 +227,8 @@ public function toArray(): array
|
||||
),
|
||||
'show_no_action_needed' => $this->showNoActionNeeded,
|
||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
||||
'trust_impact' => $this->trustImpact,
|
||||
'absence_pattern' => $this->absencePattern,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,11 +4,15 @@
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
final class ReasonTranslator
|
||||
{
|
||||
@ -43,8 +47,13 @@ public function translate(
|
||||
return match (true) {
|
||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
|
||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::RBAC_ARTIFACT,
|
||||
@ -74,4 +83,184 @@ private function fallbackTranslate(
|
||||
|
||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
||||
}
|
||||
|
||||
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope
|
||||
{
|
||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
|
||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
|
||||
'Source tenant unavailable',
|
||||
'The selected tenant is not available in this workspace for baseline capture.',
|
||||
'prerequisite_missing',
|
||||
'Select a source tenant from the same workspace before capturing again.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => [
|
||||
'Baseline profile inactive',
|
||||
'Only active baseline profiles can be captured or compared.',
|
||||
'prerequisite_missing',
|
||||
'Activate the baseline profile before retrying this action.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => [
|
||||
'Full-content rollout disabled',
|
||||
'This workflow is disabled by rollout configuration in the current environment.',
|
||||
'prerequisite_missing',
|
||||
'Enable the rollout before retrying full-content baseline work.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_BUILDING,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
|
||||
'Baseline still building',
|
||||
'The selected baseline snapshot is still building and cannot be trusted for compare yet.',
|
||||
'prerequisite_missing',
|
||||
'Wait for capture to finish or use the current complete snapshot instead.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => [
|
||||
'Baseline snapshot incomplete',
|
||||
'The snapshot did not finish cleanly, so TenantPilot will not use it for compare.',
|
||||
'prerequisite_missing',
|
||||
'Capture a new baseline and wait for it to complete before comparing.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_SUPERSEDED,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => [
|
||||
'Snapshot superseded',
|
||||
'A newer complete baseline snapshot is current, so this historical snapshot is not compare input anymore.',
|
||||
'prerequisite_missing',
|
||||
'Use the current complete snapshot for compare instead of this historical copy.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED => [
|
||||
'Baseline capture failed',
|
||||
'Snapshot capture stopped after the row was created, so the artifact remains unusable.',
|
||||
'retryable_transient',
|
||||
'Review the run details, then retry the capture once the failure is addressed.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED => [
|
||||
'Completion proof failed',
|
||||
'TenantPilot could not prove that every expected snapshot item was persisted successfully.',
|
||||
'prerequisite_missing',
|
||||
'Capture the baseline again so a complete snapshot can be finalized.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF => [
|
||||
'Legacy completion unproven',
|
||||
'This older snapshot has no reliable completion proof, so it is blocked from compare.',
|
||||
'prerequisite_missing',
|
||||
'Recapture the baseline to create a complete snapshot with explicit lifecycle proof.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY => [
|
||||
'Legacy completion contradictory',
|
||||
'Stored counts or producer-run evidence disagree, so TenantPilot treats this snapshot as incomplete.',
|
||||
'prerequisite_missing',
|
||||
'Recapture the baseline to replace this ambiguous historical snapshot.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_NO_ASSIGNMENT => [
|
||||
'No baseline assigned',
|
||||
'This tenant has no assigned baseline profile yet.',
|
||||
'prerequisite_missing',
|
||||
'Assign a baseline profile to the tenant before starting compare.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => [
|
||||
'Assigned baseline inactive',
|
||||
'The assigned baseline profile is not active, so compare cannot start.',
|
||||
'prerequisite_missing',
|
||||
'Activate the assigned baseline profile or assign a different active profile.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => [
|
||||
'Current baseline unavailable',
|
||||
'No complete baseline snapshot is currently available for compare.',
|
||||
'prerequisite_missing',
|
||||
'Capture a baseline and wait for it to complete before comparing.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET => [
|
||||
'No eligible compare target',
|
||||
'No assigned tenant with compare access is currently available for this baseline profile.',
|
||||
'prerequisite_missing',
|
||||
'Assign this baseline to a tenant you can compare, or use an account with access to an assigned tenant.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => [
|
||||
'Selected snapshot unavailable',
|
||||
'The requested baseline snapshot could not be found for this profile.',
|
||||
'prerequisite_missing',
|
||||
'Refresh the page and select a valid snapshot for this baseline profile.',
|
||||
],
|
||||
default => [
|
||||
'Baseline workflow blocked',
|
||||
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
|
||||
'prerequisite_missing',
|
||||
'Review the recorded baseline state before retrying.',
|
||||
],
|
||||
};
|
||||
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: $operatorLabel,
|
||||
shortExplanation: $shortExplanation,
|
||||
actionability: $actionability,
|
||||
nextSteps: [
|
||||
NextStepOption::instruction($nextStep),
|
||||
],
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
trustImpact: BaselineReasonCodes::trustImpact($reasonCode) ?? TrustworthinessLevel::Unusable->value,
|
||||
absencePattern: BaselineReasonCodes::absencePattern($reasonCode),
|
||||
);
|
||||
}
|
||||
|
||||
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
|
||||
{
|
||||
$enum = BaselineCompareReasonCode::tryFrom($reasonCode);
|
||||
|
||||
if (! $enum instanceof BaselineCompareReasonCode) {
|
||||
return $this->fallbackReasonTranslator->translate($reasonCode) ?? new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: 'Baseline compare needs review',
|
||||
shortExplanation: 'TenantPilot recorded a baseline-compare state that needs operator review.',
|
||||
actionability: 'permanent_configuration',
|
||||
);
|
||||
}
|
||||
|
||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
|
||||
BaselineCompareReasonCode::NoDriftDetected => [
|
||||
'No drift detected',
|
||||
'The comparison completed for the in-scope subjects without recording drift findings.',
|
||||
'non_actionable',
|
||||
'No action needed unless you expected findings.',
|
||||
],
|
||||
BaselineCompareReasonCode::CoverageUnproven => [
|
||||
'Coverage proof missing',
|
||||
'The comparison finished, but missing coverage proof means some findings may have been suppressed for safety.',
|
||||
'prerequisite_missing',
|
||||
'Run inventory sync and compare again before treating this as complete.',
|
||||
],
|
||||
BaselineCompareReasonCode::EvidenceCaptureIncomplete => [
|
||||
'Evidence capture incomplete',
|
||||
'The comparison finished, but incomplete evidence capture limits how much confidence you should place in the visible result.',
|
||||
'prerequisite_missing',
|
||||
'Resume or rerun evidence capture before relying on this compare result.',
|
||||
],
|
||||
BaselineCompareReasonCode::RolloutDisabled => [
|
||||
'Compare rollout disabled',
|
||||
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
|
||||
'prerequisite_missing',
|
||||
'Enable the rollout or use the supported compare mode before retrying.',
|
||||
],
|
||||
BaselineCompareReasonCode::NoSubjectsInScope => [
|
||||
'Nothing was eligible to compare',
|
||||
'No in-scope subjects were available for evaluation, so the compare could not produce a normal result.',
|
||||
'prerequisite_missing',
|
||||
'Review scope selection and baseline inputs before comparing again.',
|
||||
],
|
||||
};
|
||||
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: $operatorLabel,
|
||||
shortExplanation: $shortExplanation,
|
||||
actionability: $actionability,
|
||||
nextSteps: [
|
||||
NextStepOption::instruction($nextStep),
|
||||
],
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
trustImpact: $enum->trustworthinessLevel()->value,
|
||||
absencePattern: $enum->absencePattern(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
final readonly class ArtifactTruthCause
|
||||
{
|
||||
@ -18,6 +19,8 @@ public function __construct(
|
||||
public ?string $operatorLabel,
|
||||
public ?string $shortExplanation,
|
||||
public ?string $diagnosticCode,
|
||||
public string $trustImpact,
|
||||
public ?string $absencePattern,
|
||||
public array $nextSteps = [],
|
||||
) {}
|
||||
|
||||
@ -35,6 +38,8 @@ public static function fromReasonResolutionEnvelope(
|
||||
operatorLabel: $reason->operatorLabel,
|
||||
shortExplanation: $reason->shortExplanation,
|
||||
diagnosticCode: $reason->diagnosticCode(),
|
||||
trustImpact: $reason->trustImpact,
|
||||
absencePattern: $reason->absencePattern,
|
||||
nextSteps: array_values(array_map(
|
||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||
$reason->nextSteps,
|
||||
@ -42,6 +47,23 @@ public static function fromReasonResolutionEnvelope(
|
||||
);
|
||||
}
|
||||
|
||||
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
|
||||
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
|
||||
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
|
||||
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing',
|
||||
nextSteps: array_map(
|
||||
static fn (string $label): NextStepOption => NextStepOption::instruction($label),
|
||||
$this->nextSteps,
|
||||
),
|
||||
diagnosticCodeLabel: $this->diagnosticCode,
|
||||
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||
absencePattern: $this->absencePattern,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* reasonCode: ?string,
|
||||
@ -49,6 +71,8 @@ public static function fromReasonResolutionEnvelope(
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* trustImpact: string,
|
||||
* absencePattern: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
*/
|
||||
@ -60,6 +84,8 @@ public function toArray(): array
|
||||
'operatorLabel' => $this->operatorLabel,
|
||||
'shortExplanation' => $this->shortExplanation,
|
||||
'diagnosticCode' => $this->diagnosticCode,
|
||||
'trustImpact' => $this->trustImpact,
|
||||
'absencePattern' => $this->absencePattern,
|
||||
'nextSteps' => $this->nextSteps,
|
||||
];
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
|
||||
final readonly class ArtifactTruthEnvelope
|
||||
{
|
||||
@ -32,6 +33,7 @@ public function __construct(
|
||||
public ?string $relatedArtifactUrl,
|
||||
public array $dimensions = [],
|
||||
public ?ArtifactTruthCause $reason = null,
|
||||
public ?OperatorExplanationPattern $operatorExplanation = null,
|
||||
) {}
|
||||
|
||||
public function primaryDimension(): ?ArtifactTruthDimension
|
||||
@ -99,8 +101,11 @@ public function nextStepText(): string
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* trustImpact: string,
|
||||
* absencePattern: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
* },
|
||||
* operatorExplanation: ?array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -132,6 +137,7 @@ public function toArray(): array
|
||||
),
|
||||
)),
|
||||
'reason' => $this->reason?->toArray(),
|
||||
'operatorExplanation' => $this->operatorExplanation?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -20,17 +21,22 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class ArtifactTruthPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
||||
) {}
|
||||
|
||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||
@ -52,38 +58,49 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
||||
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
||||
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
||||
&& $snapshot->baselineProfile !== null;
|
||||
$effectiveSnapshot = $snapshot->baselineProfile !== null
|
||||
? $this->snapshotTruthResolver->resolveEffectiveSnapshot($snapshot->baselineProfile)
|
||||
: null;
|
||||
$isHistorical = $this->snapshotTruthResolver->isHistoricallySuperseded($snapshot, $effectiveSnapshot);
|
||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
||||
$severeGapReasons = array_filter(
|
||||
$gapReasons,
|
||||
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
$reasonCode = $this->firstReasonCode($severeGapReasons);
|
||||
$reasonCode = $this->snapshotTruthResolver->artifactReasonCode($snapshot, $effectiveSnapshot)
|
||||
?? $this->firstReasonCode($severeGapReasons);
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
$artifactExistence = match (true) {
|
||||
$isHistorical => 'historical_only',
|
||||
! $hasItems => 'created_but_not_usable',
|
||||
$snapshot->isBuilding(), $snapshot->isIncomplete() => 'created_but_not_usable',
|
||||
! $snapshot->isConsumable() => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match ($fidelity) {
|
||||
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
||||
FidelityState::Partial => 'partial',
|
||||
FidelityState::ReferenceOnly => 'reference_only',
|
||||
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
||||
FidelityState::Full => $snapshot->isIncomplete()
|
||||
? ($hasItems ? 'partial' : 'missing_input')
|
||||
: ($severeGapReasons === [] ? 'trusted' : 'partial'),
|
||||
FidelityState::Partial => $snapshot->isBuilding() ? 'missing_input' : 'partial',
|
||||
FidelityState::ReferenceOnly => $snapshot->isBuilding() ? 'missing_input' : 'reference_only',
|
||||
FidelityState::Unsupported => $snapshot->isBuilding() ? 'missing_input' : ($hasItems ? 'unsupported' : 'trusted'),
|
||||
};
|
||||
|
||||
if (! $hasItems && $reasonCode !== null) {
|
||||
if (($snapshot->isBuilding() || $snapshot->isIncomplete()) && $reasonCode !== null) {
|
||||
$contentState = 'missing_input';
|
||||
}
|
||||
|
||||
$freshnessState = $isHistorical ? 'stale' : 'current';
|
||||
$freshnessState = match (true) {
|
||||
$snapshot->isBuilding() => 'unknown',
|
||||
$isHistorical => 'stale',
|
||||
default => 'current',
|
||||
};
|
||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$snapshot->isBuilding() => 'optional',
|
||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||
$freshnessState === 'stale' => 'optional',
|
||||
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
||||
@ -94,26 +111,30 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
$reason?->shortExplanation ?? 'This snapshot remains readable for history, but a newer complete snapshot is the current baseline truth.',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label,
|
||||
],
|
||||
$artifactExistence === 'created_but_not_usable' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'created_but_not_usable',
|
||||
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||
],
|
||||
$contentState !== 'trusted' => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
$contentState,
|
||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
$supportState === 'limited_support'
|
||||
? 'Support limited'
|
||||
: BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
'trusted',
|
||||
'Structured capture content is available for this baseline snapshot.',
|
||||
null,
|
||||
$hasItems
|
||||
? 'Structured capture content is available for this baseline snapshot.'
|
||||
: 'This empty baseline snapshot completed successfully and can still be used for compare.',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||
],
|
||||
};
|
||||
|
||||
@ -147,6 +168,19 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
||||
relatedRunId: null,
|
||||
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||
includePublicationDimension: false,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Captured items',
|
||||
value: (int) ($summary['total_items'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Evidence gaps',
|
||||
value: (int) (Arr::get($summary, 'gaps.count', 0)),
|
||||
role: CountDescriptor::ROLE_COVERAGE,
|
||||
qualifier: (int) (Arr::get($summary, 'gaps.count', 0)) > 0 ? 'review needed' : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -270,6 +304,25 @@ public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEn
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
includePublicationDimension: false,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Evidence dimensions',
|
||||
value: (int) ($summary['dimension_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Missing dimensions',
|
||||
value: $missingDimensions,
|
||||
role: CountDescriptor::ROLE_COVERAGE,
|
||||
qualifier: $missingDimensions > 0 ? 'partial' : null,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Stale dimensions',
|
||||
value: $staleDimensions,
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: $staleDimensions > 0 ? 'refresh recommended' : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -399,6 +452,24 @@ public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Findings',
|
||||
value: (int) ($summary['finding_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Sections',
|
||||
value: (int) ($summary['section_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EXECUTION,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Publish blockers',
|
||||
value: count($publishBlockers),
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: $publishBlockers !== [] ? 'resolve before publish' : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -519,6 +590,24 @@ public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Findings',
|
||||
value: (int) ($summary['finding_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Reports',
|
||||
value: (int) ($summary['report_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EXECUTION,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Operations',
|
||||
value: (int) ($summary['operation_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EXECUTION,
|
||||
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -560,6 +649,10 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
||||
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
||||
countDescriptors: array_merge(
|
||||
$artifactEnvelope->operatorExplanation?->countDescriptors ?? [],
|
||||
$this->runCountDescriptors($run),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -601,18 +694,16 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
},
|
||||
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
$actionability === 'required'
|
||||
nextActionLabel: $reason?->firstNextStep()?->label
|
||||
?? ($actionability === 'required'
|
||||
? 'Inspect the blocked run details before retrying'
|
||||
: 'Wait for the artifact-producing run to finish',
|
||||
),
|
||||
: 'Wait for the artifact-producing run to finish'),
|
||||
nextActionUrl: null,
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: null,
|
||||
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
||||
countDescriptors: $this->runCountDescriptors($run),
|
||||
);
|
||||
}
|
||||
|
||||
@ -698,6 +789,7 @@ private function makeEnvelope(
|
||||
?int $relatedRunId,
|
||||
?string $relatedArtifactUrl,
|
||||
bool $includePublicationDimension,
|
||||
array $countDescriptors = [],
|
||||
): ArtifactTruthEnvelope {
|
||||
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
||||
$dimensions = [
|
||||
@ -731,7 +823,7 @@ classification: 'diagnostic',
|
||||
);
|
||||
}
|
||||
|
||||
return new ArtifactTruthEnvelope(
|
||||
$draftEnvelope = new ArtifactTruthEnvelope(
|
||||
artifactFamily: $artifactFamily,
|
||||
artifactKey: $artifactKey,
|
||||
workspaceId: $workspaceId,
|
||||
@ -753,6 +845,30 @@ classification: 'diagnostic',
|
||||
dimensions: array_values($dimensions),
|
||||
reason: $reason,
|
||||
);
|
||||
|
||||
return new ArtifactTruthEnvelope(
|
||||
artifactFamily: $draftEnvelope->artifactFamily,
|
||||
artifactKey: $draftEnvelope->artifactKey,
|
||||
workspaceId: $draftEnvelope->workspaceId,
|
||||
tenantId: $draftEnvelope->tenantId,
|
||||
executionOutcome: $draftEnvelope->executionOutcome,
|
||||
artifactExistence: $draftEnvelope->artifactExistence,
|
||||
contentState: $draftEnvelope->contentState,
|
||||
freshnessState: $draftEnvelope->freshnessState,
|
||||
publicationReadiness: $draftEnvelope->publicationReadiness,
|
||||
supportState: $draftEnvelope->supportState,
|
||||
actionability: $draftEnvelope->actionability,
|
||||
primaryLabel: $draftEnvelope->primaryLabel,
|
||||
primaryExplanation: $draftEnvelope->primaryExplanation,
|
||||
diagnosticLabel: $draftEnvelope->diagnosticLabel,
|
||||
nextActionLabel: $draftEnvelope->nextActionLabel,
|
||||
nextActionUrl: $draftEnvelope->nextActionUrl,
|
||||
relatedRunId: $draftEnvelope->relatedRunId,
|
||||
relatedArtifactUrl: $draftEnvelope->relatedArtifactUrl,
|
||||
dimensions: $draftEnvelope->dimensions,
|
||||
reason: $draftEnvelope->reason,
|
||||
operatorExplanation: $this->operatorExplanationBuilder->fromArtifactTruthEnvelope($draftEnvelope, $countDescriptors),
|
||||
);
|
||||
}
|
||||
|
||||
private function dimension(
|
||||
@ -770,4 +886,31 @@ classification: $classification,
|
||||
badgeState: $state,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, CountDescriptor>
|
||||
*/
|
||||
private function runCountDescriptors(OperationRun $run): array
|
||||
{
|
||||
$descriptors = [];
|
||||
|
||||
foreach (SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []) as $key => $value) {
|
||||
$role = match (true) {
|
||||
in_array($key, ['total', 'processed'], true) => CountDescriptor::ROLE_EXECUTION,
|
||||
str_contains($key, 'failed') || str_contains($key, 'warning') || str_contains($key, 'blocked') => CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
default => CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
};
|
||||
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: SummaryCountsNormalizer::label($key),
|
||||
value: (int) $value,
|
||||
role: $role,
|
||||
visibilityTier: in_array($key, ['total', 'processed'], true)
|
||||
? CountDescriptor::VISIBILITY_PRIMARY
|
||||
: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
return $descriptors;
|
||||
}
|
||||
}
|
||||
|
||||
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class CountDescriptor
|
||||
{
|
||||
public const string ROLE_EXECUTION = 'execution';
|
||||
|
||||
public const string ROLE_EVALUATION_OUTPUT = 'evaluation_output';
|
||||
|
||||
public const string ROLE_COVERAGE = 'coverage';
|
||||
|
||||
public const string ROLE_RELIABILITY_SIGNAL = 'reliability_signal';
|
||||
|
||||
public const string VISIBILITY_PRIMARY = 'primary';
|
||||
|
||||
public const string VISIBILITY_DIAGNOSTIC = 'diagnostic';
|
||||
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public int $value,
|
||||
public string $role,
|
||||
public ?string $qualifier = null,
|
||||
public string $visibilityTier = self::VISIBILITY_PRIMARY,
|
||||
) {
|
||||
if (trim($this->label) === '') {
|
||||
throw new InvalidArgumentException('Count descriptors require a label.');
|
||||
}
|
||||
|
||||
if (! in_array($this->role, [
|
||||
self::ROLE_EXECUTION,
|
||||
self::ROLE_EVALUATION_OUTPUT,
|
||||
self::ROLE_COVERAGE,
|
||||
self::ROLE_RELIABILITY_SIGNAL,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported count descriptor role: '.$this->role);
|
||||
}
|
||||
|
||||
if (! in_array($this->visibilityTier, [self::VISIBILITY_PRIMARY, self::VISIBILITY_DIAGNOSTIC], true)) {
|
||||
throw new InvalidArgumentException('Unsupported count descriptor visibility tier: '.$this->visibilityTier);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'label' => $this->label,
|
||||
'value' => $this->value,
|
||||
'role' => $this->role,
|
||||
'qualifier' => $this->qualifier,
|
||||
'visibilityTier' => $this->visibilityTier,
|
||||
];
|
||||
}
|
||||
}
|
||||
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
enum ExplanationFamily: string
|
||||
{
|
||||
case TrustworthyResult = 'trustworthy_result';
|
||||
case NoIssuesDetected = 'no_issues_detected';
|
||||
case CompletedButLimited = 'completed_but_limited';
|
||||
case SuppressedOutput = 'suppressed_output';
|
||||
case MissingInput = 'missing_input';
|
||||
case BlockedPrerequisite = 'blocked_prerequisite';
|
||||
case Unavailable = 'unavailable';
|
||||
case InProgress = 'in_progress';
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
|
||||
final class OperatorExplanationBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
public function build(
|
||||
ExplanationFamily $family,
|
||||
string $headline,
|
||||
string $executionOutcome,
|
||||
string $executionOutcomeLabel,
|
||||
string $evaluationResult,
|
||||
TrustworthinessLevel $trustworthinessLevel,
|
||||
string $reliabilityStatement,
|
||||
?string $coverageStatement,
|
||||
?string $dominantCauseCode,
|
||||
?string $dominantCauseLabel,
|
||||
?string $dominantCauseExplanation,
|
||||
string $nextActionCategory,
|
||||
string $nextActionText,
|
||||
array $countDescriptors = [],
|
||||
bool $diagnosticsAvailable = false,
|
||||
?string $diagnosticsSummary = null,
|
||||
): OperatorExplanationPattern {
|
||||
return new OperatorExplanationPattern(
|
||||
family: $family,
|
||||
headline: $headline,
|
||||
executionOutcome: $executionOutcome,
|
||||
executionOutcomeLabel: $executionOutcomeLabel,
|
||||
evaluationResult: $evaluationResult,
|
||||
trustworthinessLevel: $trustworthinessLevel,
|
||||
reliabilityStatement: $reliabilityStatement,
|
||||
coverageStatement: $coverageStatement,
|
||||
dominantCauseCode: $dominantCauseCode,
|
||||
dominantCauseLabel: $dominantCauseLabel,
|
||||
dominantCauseExplanation: $dominantCauseExplanation,
|
||||
nextActionCategory: $nextActionCategory,
|
||||
nextActionText: $nextActionText,
|
||||
countDescriptors: $countDescriptors,
|
||||
diagnosticsAvailable: $diagnosticsAvailable,
|
||||
diagnosticsSummary: $diagnosticsSummary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
public function fromArtifactTruthEnvelope(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
array $countDescriptors = [],
|
||||
): OperatorExplanationPattern {
|
||||
$reason = $truth->reason?->toReasonResolutionEnvelope();
|
||||
$family = $this->familyForTruth($truth, $reason);
|
||||
$trustworthiness = $this->trustworthinessForTruth($truth, $reason);
|
||||
$evaluationResult = $this->evaluationResultForTruth($truth, $family);
|
||||
$executionOutcome = $this->executionOutcomeKey($truth->executionOutcome);
|
||||
$executionOutcomeLabel = $this->executionOutcomeLabel($truth->executionOutcome);
|
||||
$dominantCauseCode = $reason?->internalCode;
|
||||
$dominantCauseLabel = $reason?->operatorLabel ?? $truth->primaryLabel;
|
||||
$dominantCauseExplanation = $reason?->shortExplanation ?? $truth->primaryExplanation;
|
||||
$headline = $this->headlineForTruth($truth, $family, $trustworthiness);
|
||||
$reliabilityStatement = $this->reliabilityStatementForTruth($truth, $trustworthiness);
|
||||
$coverageStatement = $this->coverageStatementForTruth($truth, $reason);
|
||||
$nextActionText = $truth->nextStepText();
|
||||
$nextActionCategory = $this->nextActionCategory($truth->actionability, $reason);
|
||||
$diagnosticsAvailable = $truth->reason !== null
|
||||
|| $truth->diagnosticLabel !== null
|
||||
|| $countDescriptors !== [];
|
||||
|
||||
return $this->build(
|
||||
family: $family,
|
||||
headline: $headline,
|
||||
executionOutcome: $executionOutcome,
|
||||
executionOutcomeLabel: $executionOutcomeLabel,
|
||||
evaluationResult: $evaluationResult,
|
||||
trustworthinessLevel: $trustworthiness,
|
||||
reliabilityStatement: $reliabilityStatement,
|
||||
coverageStatement: $coverageStatement,
|
||||
dominantCauseCode: $dominantCauseCode,
|
||||
dominantCauseLabel: $dominantCauseLabel,
|
||||
dominantCauseExplanation: $dominantCauseExplanation,
|
||||
nextActionCategory: $nextActionCategory,
|
||||
nextActionText: $nextActionText,
|
||||
countDescriptors: $countDescriptors,
|
||||
diagnosticsAvailable: $diagnosticsAvailable,
|
||||
diagnosticsSummary: $diagnosticsAvailable
|
||||
? 'Technical truth detail remains available below the primary explanation.'
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function familyForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): ExplanationFamily {
|
||||
return match (true) {
|
||||
$reason?->absencePattern === 'suppressed_output' => ExplanationFamily::SuppressedOutput,
|
||||
$reason?->absencePattern === 'blocked_prerequisite' => ExplanationFamily::BlockedPrerequisite,
|
||||
$truth->executionOutcome === 'pending' || $truth->artifactExistence === 'not_created' && $truth->actionability !== 'required' => ExplanationFamily::InProgress,
|
||||
$truth->executionOutcome === 'failed' || $truth->executionOutcome === 'blocked' => ExplanationFamily::BlockedPrerequisite,
|
||||
$truth->artifactExistence === 'created_but_not_usable' || $truth->contentState === 'missing_input' => ExplanationFamily::MissingInput,
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' && $truth->primaryLabel === 'Trustworthy artifact' => ExplanationFamily::TrustworthyResult,
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => ExplanationFamily::NoIssuesDetected,
|
||||
$truth->artifactExistence === 'historical_only' => ExplanationFamily::Unavailable,
|
||||
default => ExplanationFamily::CompletedButLimited,
|
||||
};
|
||||
}
|
||||
|
||||
private function trustworthinessForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): TrustworthinessLevel {
|
||||
if ($reason?->trustImpact !== null) {
|
||||
return TrustworthinessLevel::tryFrom($reason->trustImpact) ?? TrustworthinessLevel::LimitedConfidence;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$truth->artifactExistence === 'created_but_not_usable',
|
||||
$truth->contentState === 'missing_input',
|
||||
$truth->executionOutcome === 'failed',
|
||||
$truth->executionOutcome === 'blocked' => TrustworthinessLevel::Unusable,
|
||||
$truth->supportState === 'limited_support',
|
||||
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => TrustworthinessLevel::DiagnosticOnly,
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => TrustworthinessLevel::Trustworthy,
|
||||
default => TrustworthinessLevel::LimitedConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
private function evaluationResultForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
ExplanationFamily $family,
|
||||
): string {
|
||||
return match ($family) {
|
||||
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||
ExplanationFamily::MissingInput,
|
||||
ExplanationFamily::BlockedPrerequisite,
|
||||
ExplanationFamily::Unavailable => 'unavailable',
|
||||
ExplanationFamily::InProgress => 'unavailable',
|
||||
ExplanationFamily::CompletedButLimited => 'incomplete_result',
|
||||
};
|
||||
}
|
||||
|
||||
private function executionOutcomeKey(?string $executionOutcome): string
|
||||
{
|
||||
$normalized = BadgeCatalog::normalizeState($executionOutcome);
|
||||
|
||||
return match ($normalized) {
|
||||
'queued', 'running', 'pending' => 'in_progress',
|
||||
'partially_succeeded' => 'completed_with_follow_up',
|
||||
'blocked' => 'blocked',
|
||||
'failed' => 'failed',
|
||||
default => 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
private function executionOutcomeLabel(?string $executionOutcome): string
|
||||
{
|
||||
if (! is_string($executionOutcome) || trim($executionOutcome) === '') {
|
||||
return 'Completed';
|
||||
}
|
||||
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $executionOutcome);
|
||||
|
||||
return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim($executionOutcome)));
|
||||
}
|
||||
|
||||
private function headlineForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
ExplanationFamily $family,
|
||||
TrustworthinessLevel $trustworthiness,
|
||||
): string {
|
||||
return match ($family) {
|
||||
ExplanationFamily::TrustworthyResult => 'The result is ready to use.',
|
||||
ExplanationFamily::NoIssuesDetected => 'No follow-up was detected from this result.',
|
||||
ExplanationFamily::SuppressedOutput => 'The run completed, but normal output was intentionally suppressed.',
|
||||
ExplanationFamily::MissingInput => 'The result exists, but missing inputs keep it from being decision-grade.',
|
||||
ExplanationFamily::BlockedPrerequisite => 'The workflow did not produce a usable result because a prerequisite blocked it.',
|
||||
ExplanationFamily::InProgress => 'The result is still being prepared.',
|
||||
ExplanationFamily::Unavailable => 'A result is not currently available for this surface.',
|
||||
ExplanationFamily::CompletedButLimited => match ($trustworthiness) {
|
||||
TrustworthinessLevel::DiagnosticOnly => 'The result is available for diagnostics, not for a final decision.',
|
||||
TrustworthinessLevel::LimitedConfidence => 'The result is available, but it should be read with caution.',
|
||||
TrustworthinessLevel::Unusable => 'The result is not reliable enough to use as-is.',
|
||||
default => 'The result completed with operator follow-up.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private function reliabilityStatementForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
TrustworthinessLevel $trustworthiness,
|
||||
): string {
|
||||
return match ($trustworthiness) {
|
||||
TrustworthinessLevel::Trustworthy => 'Trustworthiness is high for the intended operator task.',
|
||||
TrustworthinessLevel::LimitedConfidence => $truth->primaryExplanation
|
||||
?? 'Trustworthiness is limited because coverage, freshness, or publication readiness still need review.',
|
||||
TrustworthinessLevel::DiagnosticOnly => 'This output is suitable for diagnostics only and should not be treated as the final answer.',
|
||||
TrustworthinessLevel::Unusable => 'This output is not reliable enough to support the intended operator action yet.',
|
||||
};
|
||||
}
|
||||
|
||||
private function coverageStatementForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): ?string {
|
||||
return match (true) {
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' => 'Coverage and artifact quality are sufficient for the default reading path.',
|
||||
$truth->freshnessState === 'stale' => 'The artifact exists, but freshness limits how confidently it should be used.',
|
||||
$truth->contentState === 'partial' => 'Coverage is incomplete, so the visible output should be treated as partial.',
|
||||
$truth->contentState === 'missing_input' => $reason?->shortExplanation ?? 'Required inputs were missing or unusable when this result was assembled.',
|
||||
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => 'Only reduced-fidelity support is available for this result.',
|
||||
$truth->publicationReadiness === 'blocked' => 'The artifact exists, but it is still blocked from the intended downstream use.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function nextActionCategory(
|
||||
string $actionability,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): string {
|
||||
if ($reason?->actionability === 'retryable_transient') {
|
||||
return 'retry_later';
|
||||
}
|
||||
|
||||
return match ($actionability) {
|
||||
'none' => 'none',
|
||||
'optional' => 'review_evidence_gaps',
|
||||
default => $reason?->actionability === 'prerequisite_missing'
|
||||
? 'fix_prerequisite'
|
||||
: 'manual_validate',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class OperatorExplanationPattern
|
||||
{
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
public function __construct(
|
||||
public ExplanationFamily $family,
|
||||
public string $headline,
|
||||
public string $executionOutcome,
|
||||
public string $executionOutcomeLabel,
|
||||
public string $evaluationResult,
|
||||
public TrustworthinessLevel $trustworthinessLevel,
|
||||
public string $reliabilityStatement,
|
||||
public ?string $coverageStatement,
|
||||
public ?string $dominantCauseCode,
|
||||
public ?string $dominantCauseLabel,
|
||||
public ?string $dominantCauseExplanation,
|
||||
public string $nextActionCategory,
|
||||
public string $nextActionText,
|
||||
public array $countDescriptors = [],
|
||||
public bool $diagnosticsAvailable = false,
|
||||
public ?string $diagnosticsSummary = null,
|
||||
) {
|
||||
if (trim($this->headline) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require a headline.');
|
||||
}
|
||||
|
||||
if (trim($this->executionOutcome) === '' || trim($this->executionOutcomeLabel) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require an execution outcome and label.');
|
||||
}
|
||||
|
||||
if (trim($this->evaluationResult) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require an evaluation result state.');
|
||||
}
|
||||
|
||||
if (trim($this->reliabilityStatement) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require a reliability statement.');
|
||||
}
|
||||
|
||||
if (trim($this->nextActionCategory) === '' || trim($this->nextActionText) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require a next action category and text.');
|
||||
}
|
||||
|
||||
foreach ($this->countDescriptors as $descriptor) {
|
||||
if (! $descriptor instanceof CountDescriptor) {
|
||||
throw new InvalidArgumentException('Operator explanation count descriptors must contain CountDescriptor instances.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function evaluationResultLabel(): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $this->evaluationResult)->label;
|
||||
}
|
||||
|
||||
public function trustworthinessLabel(): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $this->trustworthinessLevel)->label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* family: string,
|
||||
* headline: string,
|
||||
* executionOutcome: string,
|
||||
* executionOutcomeLabel: string,
|
||||
* evaluationResult: string,
|
||||
* evaluationResultLabel: string,
|
||||
* trustworthinessLevel: string,
|
||||
* reliabilityLevel: string,
|
||||
* trustworthinessLabel: string,
|
||||
* reliabilityStatement: string,
|
||||
* coverageStatement: ?string,
|
||||
* dominantCause: array{
|
||||
* code: ?string,
|
||||
* label: ?string,
|
||||
* explanation: ?string
|
||||
* },
|
||||
* nextAction: array{
|
||||
* category: string,
|
||||
* text: string
|
||||
* },
|
||||
* countDescriptors: array<int, array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }>,
|
||||
* diagnosticsAvailable: bool,
|
||||
* diagnosticsSummary: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'family' => $this->family->value,
|
||||
'headline' => $this->headline,
|
||||
'executionOutcome' => $this->executionOutcome,
|
||||
'executionOutcomeLabel' => $this->executionOutcomeLabel,
|
||||
'evaluationResult' => $this->evaluationResult,
|
||||
'evaluationResultLabel' => $this->evaluationResultLabel(),
|
||||
'trustworthinessLevel' => $this->trustworthinessLevel->value,
|
||||
'reliabilityLevel' => $this->trustworthinessLevel->value,
|
||||
'trustworthinessLabel' => $this->trustworthinessLabel(),
|
||||
'reliabilityStatement' => $this->reliabilityStatement,
|
||||
'coverageStatement' => $this->coverageStatement,
|
||||
'dominantCause' => [
|
||||
'code' => $this->dominantCauseCode,
|
||||
'label' => $this->dominantCauseLabel,
|
||||
'explanation' => $this->dominantCauseExplanation,
|
||||
],
|
||||
'nextAction' => [
|
||||
'category' => $this->nextActionCategory,
|
||||
'text' => $this->nextActionText,
|
||||
],
|
||||
'countDescriptors' => array_map(
|
||||
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
|
||||
$this->countDescriptors,
|
||||
),
|
||||
'diagnosticsAvailable' => $this->diagnosticsAvailable,
|
||||
'diagnosticsSummary' => $this->diagnosticsSummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
enum TrustworthinessLevel: string
|
||||
{
|
||||
case Trustworthy = 'trustworthy';
|
||||
case LimitedConfidence = 'limited_confidence';
|
||||
case DiagnosticOnly = 'diagnostic_only';
|
||||
case Unusable = 'unusable';
|
||||
}
|
||||
@ -35,6 +35,7 @@ public static function firstSlice(): array
|
||||
'evidence_snapshots',
|
||||
'inventory_items',
|
||||
'entra_groups',
|
||||
'tenant_reviews',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 600),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 600),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
@ -68,7 +68,7 @@
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 600),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
@ -126,4 +126,8 @@
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
'lifecycle_invariants' => [
|
||||
'retry_after_safety_margin' => (int) env('QUEUE_RETRY_AFTER_SAFETY_MARGIN', 30),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -13,6 +13,113 @@
|
||||
],
|
||||
],
|
||||
|
||||
'operations' => [
|
||||
'lifecycle' => [
|
||||
'reconciliation' => [
|
||||
'batch_limit' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_BATCH_LIMIT', 100),
|
||||
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
|
||||
],
|
||||
'covered_types' => [
|
||||
'baseline_capture' => [
|
||||
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
|
||||
'queued_stale_after_seconds' => 600,
|
||||
'running_stale_after_seconds' => 1800,
|
||||
'expected_max_runtime_seconds' => 300,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'baseline_compare' => [
|
||||
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
|
||||
'queued_stale_after_seconds' => 600,
|
||||
'running_stale_after_seconds' => 1800,
|
||||
'expected_max_runtime_seconds' => 300,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'inventory_sync' => [
|
||||
'job_class' => \App\Jobs\RunInventorySyncJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1200,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'policy.sync' => [
|
||||
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 180,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'policy.sync_one' => [
|
||||
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 180,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'entra_group_sync' => [
|
||||
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'directory_role_definitions.sync' => [
|
||||
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'backup_schedule_run' => [
|
||||
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1200,
|
||||
'expected_max_runtime_seconds' => 300,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'restore.execute' => [
|
||||
'job_class' => \App\Jobs\ExecuteRestoreRunJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1500,
|
||||
'expected_max_runtime_seconds' => 420,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review_pack.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review.compose' => [
|
||||
'job_class' => \App\Jobs\ComposeTenantReviewJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.evidence.snapshot.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateEvidenceSnapshotJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||
|
||||
'supported_policy_types' => [
|
||||
@ -305,6 +412,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'compare_capability' => 'limited',
|
||||
'capture_capability' => 'limited',
|
||||
'source_model_expected' => 'inventory',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -319,6 +433,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'compare_capability' => 'limited',
|
||||
'capture_capability' => 'limited',
|
||||
'source_model_expected' => 'inventory',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -333,6 +454,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'external_id',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_policy',
|
||||
'compare_capability' => 'supported',
|
||||
'capture_capability' => 'supported',
|
||||
'source_model_expected' => 'policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -347,6 +475,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => false,
|
||||
'identity_strategy' => 'external_id',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_policy',
|
||||
'compare_capability' => 'unsupported',
|
||||
'capture_capability' => 'unsupported',
|
||||
'source_model_expected' => 'policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -361,6 +496,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'compare_capability' => 'limited',
|
||||
'capture_capability' => 'limited',
|
||||
'source_model_expected' => 'inventory',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -24,7 +25,50 @@ public function definition(): array
|
||||
'baseline_profile_id' => BaselineProfile::factory(),
|
||||
'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
|
||||
'captured_at' => now(),
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => now(),
|
||||
'failed_at' => null,
|
||||
'summary_jsonb' => ['total_items' => 0],
|
||||
'completion_meta_jsonb' => [
|
||||
'expected_items' => 0,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => null,
|
||||
'was_empty_capture' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function building(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => now(),
|
||||
'failed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function incomplete(?string $reasonCode = null): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => now(),
|
||||
'completion_meta_jsonb' => [
|
||||
'expected_items' => 0,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => null,
|
||||
'was_empty_capture' => true,
|
||||
'finalization_reason_code' => $reasonCode,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_snapshots')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$needsLifecycleState = ! Schema::hasColumn('baseline_snapshots', 'lifecycle_state');
|
||||
$needsCompletedAt = ! Schema::hasColumn('baseline_snapshots', 'completed_at');
|
||||
$needsFailedAt = ! Schema::hasColumn('baseline_snapshots', 'failed_at');
|
||||
$needsCompletionMeta = ! Schema::hasColumn('baseline_snapshots', 'completion_meta_jsonb');
|
||||
|
||||
if ($needsLifecycleState || $needsCompletedAt || $needsFailedAt || $needsCompletionMeta) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table) use ($needsLifecycleState, $needsCompletedAt, $needsFailedAt, $needsCompletionMeta): void {
|
||||
if ($needsLifecycleState) {
|
||||
$table->string('lifecycle_state')->nullable()->after('captured_at');
|
||||
}
|
||||
|
||||
if ($needsCompletedAt) {
|
||||
$table->timestampTz('completed_at')->nullable()->after('lifecycle_state');
|
||||
}
|
||||
|
||||
if ($needsFailedAt) {
|
||||
$table->timestampTz('failed_at')->nullable()->after('completed_at');
|
||||
}
|
||||
|
||||
if ($needsCompletionMeta) {
|
||||
$table->jsonb('completion_meta_jsonb')->nullable()->after('summary_jsonb');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DB::table('baseline_snapshots')
|
||||
->orderBy('id')
|
||||
->chunkById(200, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$summary = $this->decodeJson($row->summary_jsonb);
|
||||
$persistedItems = (int) DB::table('baseline_snapshot_items')
|
||||
->where('baseline_snapshot_id', (int) $row->id)
|
||||
->count();
|
||||
|
||||
$classification = $this->classifyLegacySnapshot($row, $summary, $persistedItems);
|
||||
|
||||
DB::table('baseline_snapshots')
|
||||
->where('id', (int) $row->id)
|
||||
->update([
|
||||
'lifecycle_state' => $classification['lifecycle_state'],
|
||||
'completed_at' => $classification['completed_at'],
|
||||
'failed_at' => $classification['failed_at'],
|
||||
'completion_meta_jsonb' => json_encode($classification['completion_meta'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
DB::table('baseline_snapshots')
|
||||
->whereNull('lifecycle_state')
|
||||
->update([
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
]);
|
||||
|
||||
if ($needsLifecycleState) {
|
||||
DB::statement(sprintf(
|
||||
"UPDATE baseline_snapshots SET lifecycle_state = '%s' WHERE lifecycle_state IS NULL",
|
||||
BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
));
|
||||
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->string('lifecycle_state')->default(BaselineSnapshotLifecycleState::Building->value)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->index('lifecycle_state', 'baseline_snapshots_lifecycle_state_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->index(
|
||||
['workspace_id', 'baseline_profile_id', 'lifecycle_state', 'completed_at'],
|
||||
'baseline_snapshots_profile_lifecycle_completed_idx',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_snapshots')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->dropIndex('baseline_snapshots_profile_lifecycle_completed_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->dropIndex('baseline_snapshots_lifecycle_state_index');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
foreach (['completion_meta_jsonb', 'failed_at', 'completed_at', 'lifecycle_state'] as $column) {
|
||||
if (Schema::hasColumn('baseline_snapshots', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @return array{
|
||||
* lifecycle_state: string,
|
||||
* completed_at: mixed,
|
||||
* failed_at: mixed,
|
||||
* completion_meta: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
private function classifyLegacySnapshot(object $row, array $summary, int $persistedItems): array
|
||||
{
|
||||
$expectedItems = $this->normalizeInteger(data_get($summary, 'total_items'));
|
||||
$producerRun = $this->resolveProducerRunForSnapshot((int) $row->id, (int) $row->workspace_id, (int) $row->baseline_profile_id);
|
||||
$producerRunContext = $producerRun !== null ? $this->decodeJson($producerRun->context ?? null) : [];
|
||||
$producerExpectedItems = $this->normalizeInteger(data_get($producerRunContext, 'result.items_captured'))
|
||||
?? $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
|
||||
$producerSubjectsTotal = $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
|
||||
$producerSucceeded = $producerRun !== null
|
||||
&& in_array((string) ($producerRun->outcome ?? ''), [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
], true);
|
||||
|
||||
$completionMeta = [
|
||||
'expected_items' => $expectedItems ?? $producerExpectedItems,
|
||||
'persisted_items' => $persistedItems,
|
||||
'producer_run_id' => $producerRun?->id !== null ? (int) $producerRun->id : null,
|
||||
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
|
||||
];
|
||||
|
||||
if ($expectedItems !== null && $expectedItems === $persistedItems) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_count_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_producer_run_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($producerSucceeded && $persistedItems === 0 && in_array(0, array_filter([
|
||||
$expectedItems,
|
||||
$producerExpectedItems,
|
||||
$producerSubjectsTotal,
|
||||
], static fn (?int $value): bool => $value !== null), true)) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$reasonCode = $expectedItems !== null
|
||||
|| $producerExpectedItems !== null
|
||||
|| $producerRun !== null
|
||||
? BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY
|
||||
: BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF;
|
||||
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => $row->updated_at ?? $row->captured_at ?? $row->created_at,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => $reasonCode,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveProducerRunForSnapshot(int $snapshotId, int $workspaceId, int $profileId): ?object
|
||||
{
|
||||
$runs = DB::table('operation_runs')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('type', OperationRunType::BaselineCapture->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->limit(500)
|
||||
->get(['id', 'outcome', 'completed_at', 'context']);
|
||||
|
||||
foreach ($runs as $run) {
|
||||
$context = $this->decodeJson($run->context ?? null);
|
||||
$resultSnapshotId = $this->normalizeInteger(data_get($context, 'result.snapshot_id'));
|
||||
|
||||
if ($resultSnapshotId === $snapshotId) {
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($runs as $run) {
|
||||
$context = $this->decodeJson($run->context ?? null);
|
||||
$runProfileId = $this->normalizeInteger(data_get($context, 'baseline_profile_id'));
|
||||
|
||||
if ($runProfileId === $profileId) {
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJson(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeInteger(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && ctype_digit(trim($value))) {
|
||||
return (int) trim($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hasIndex(string $indexName): bool
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
return match ($driver) {
|
||||
'pgsql' => DB::table('pg_indexes')
|
||||
->where('schemaname', 'public')
|
||||
->where('indexname', $indexName)
|
||||
->exists(),
|
||||
'sqlite' => collect(DB::select("PRAGMA index_list('baseline_snapshots')"))
|
||||
->contains(fn (object $index): bool => ($index->name ?? null) === $indexName),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user