Compare commits
1 Commits
dev
...
158-artifa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336d40e844 |
11
.github/agents/copilot-instructions.md
vendored
11
.github/agents/copilot-instructions.md
vendored
@ -100,11 +100,6 @@ ## 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)
|
- 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)
|
- 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)
|
- 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)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -124,8 +119,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
- 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
|
||||||
- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||||
- 160-operation-lifecycle-guarantees: Added 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
|
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -19,10 +18,8 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||||
|
|
||||||
public function handle(
|
public function handle(OperationRunService $operationRunService): int
|
||||||
OperationRunService $operationRunService,
|
{
|
||||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
|
||||||
): int {
|
|
||||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
@ -99,9 +96,31 @@ public function handle(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($change !== null) {
|
|
||||||
$reconciled++;
|
$reconciled++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
<?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,7 +13,6 @@
|
|||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -87,21 +86,9 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $evidenceGapsTopReasons = 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 */
|
/** @var array<string, int>|null */
|
||||||
public ?array $rbacRoleDefinitionSummary = null;
|
public ?array $rbacRoleDefinitionSummary = null;
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
|
||||||
public ?array $operatorExplanation = null;
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -152,17 +139,7 @@ public function refreshStats(): void
|
|||||||
|
|
||||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
$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->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -175,32 +152,26 @@ public function refreshStats(): void
|
|||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
|
||||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||||
? (int) $evidenceGapSummary['count']
|
|
||||||
: (int) ($this->evidenceGapsCount ?? 0);
|
|
||||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
&& 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;
|
$evidenceGapsSummary = null;
|
||||||
$evidenceGapsTooltip = null;
|
$evidenceGapsTooltip = null;
|
||||||
|
|
||||||
if ($hasEvidenceGaps) {
|
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||||
$parts = array_map(
|
$parts = [];
|
||||||
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
|
||||||
BaselineCompareEvidenceGapDetails::topReasons(
|
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||||
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||||
5,
|
continue;
|
||||||
),
|
}
|
||||||
);
|
|
||||||
|
$parts[] = $reason.' ('.((int) $count).')';
|
||||||
|
}
|
||||||
|
|
||||||
if ($parts !== []) {
|
if ($parts !== []) {
|
||||||
$evidenceGapsSummary = implode(', ', $parts);
|
$evidenceGapsSummary = implode(', ', $parts);
|
||||||
@ -236,9 +207,6 @@ protected function getViewData(): array
|
|||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
'hasWarnings' => $hasWarnings,
|
'hasWarnings' => $hasWarnings,
|
||||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||||
'evidenceGapDetailState' => $evidenceGapDetailState,
|
|
||||||
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
|
||||||
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
|
||||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||||
'findingsColorClass' => $findingsColorClass,
|
'findingsColorClass' => $findingsColorClass,
|
||||||
@ -339,22 +307,9 @@ private function compareNowAction(): Action
|
|||||||
$result = $service->startCompare($tenant, $user);
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
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()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
->body($message)
|
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,6 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -83,16 +82,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
if ($requestedEventId !== null) {
|
if ($this->selectedAuditLogId !== null) {
|
||||||
$this->resolveAuditLog($requestedEventId);
|
$this->selectedAuditLog();
|
||||||
$this->selectedAuditLogId = $requestedEventId;
|
|
||||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,10 +98,31 @@ public function mount(): void
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
scopeActionName: 'operate_hub_scope_audit_log',
|
||||||
returnActionName: 'operate_hub_return_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
|
public function table(Table $table): Table
|
||||||
@ -177,19 +195,9 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->before(function (AuditLogModel $record): void {
|
->action(function (AuditLogModel $record): void {
|
||||||
$this->selectedAuditLogId = (int) $record->getKey();
|
$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([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No audit events match this view')
|
->emptyStateHeading('No audit events match this view')
|
||||||
@ -201,11 +209,48 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
|
$this->selectedAuditLogId = null;
|
||||||
$this->resetTable();
|
$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>
|
* @return array<int, Tenant>
|
||||||
*/
|
*/
|
||||||
@ -278,54 +323,6 @@ private function auditBaseQuery(): Builder
|
|||||||
->latestFirst();
|
->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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -166,68 +165,6 @@ 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
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@ -250,26 +187,4 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
default => $query,
|
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,8 +24,6 @@
|
|||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -172,18 +170,11 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$operatorExplanation = $this->governanceOperatorExplanation();
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||||
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
||||||
? 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::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||||
]);
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
@ -192,40 +183,6 @@ 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
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
*/
|
*/
|
||||||
@ -460,13 +417,4 @@ private function relatedLinksTenant(): ?Tenant
|
|||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
)->allowed ? $tenant : null;
|
)->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)
|
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
||||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('completeness_state')
|
TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
@ -154,7 +154,7 @@ public function table(Table $table): Table
|
|||||||
)->iconColor),
|
)->iconColor),
|
||||||
TextColumn::make('artifact_next_step')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
|||||||
@ -6,28 +6,19 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
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\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -297,32 +288,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->placeholder('None'),
|
->placeholder('None'),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->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')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
->label('Created by')
|
->label('Created by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
|
TextEntry::make('activeSnapshot.captured_at')
|
||||||
|
->label('Last snapshot')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('No snapshot yet'),
|
||||||
TextEntry::make('created_at')
|
TextEntry::make('created_at')
|
||||||
->dateTime(),
|
->dateTime(),
|
||||||
TextEntry::make('updated_at')
|
TextEntry::make('updated_at')
|
||||||
@ -381,27 +355,10 @@ public static function table(Table $table): Table
|
|||||||
TextColumn::make('tenant_assignments_count')
|
TextColumn::make('tenant_assignments_count')
|
||||||
->label('Assigned tenants')
|
->label('Assigned tenants')
|
||||||
->counts('tenantAssignments'),
|
->counts('tenantAssignments'),
|
||||||
TextColumn::make('current_snapshot_truth')
|
TextColumn::make('activeSnapshot.captured_at')
|
||||||
->label('Current snapshot')
|
->label('Last snapshot')
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
->dateTime()
|
||||||
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
->placeholder('No snapshot'),
|
||||||
->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')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -588,167 +545,4 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
|||||||
|
|
||||||
return $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
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
? '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 effective current baseline snapshot.';
|
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
||||||
|
|
||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
|||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -256,11 +256,7 @@ private function compareNowAction(): Action
|
|||||||
$message = match ($reasonCode) {
|
$message = match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
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_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has 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),
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -399,12 +395,4 @@ private function hasManageCapability(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function profileHasConsumableSnapshot(): bool
|
|
||||||
{
|
|
||||||
/** @var BaselineProfile $profile */
|
|
||||||
$profile = $this->getRecord();
|
|
||||||
|
|
||||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,10 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
@ -179,23 +177,7 @@ public static function table(Table $table): Table
|
|||||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
->description(static fn (BaselineSnapshot $record): ?string => 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(),
|
->wrap(),
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
@ -203,8 +185,15 @@ public static function table(Table $table): Table
|
|||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('artifact_next_step')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||||
->wrap(),
|
->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)
|
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
@ -214,10 +203,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Baseline')
|
->label('Baseline')
|
||||||
->options(static::baselineProfileOptions())
|
->options(static::baselineProfileOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('lifecycle_state')
|
SelectFilter::make('snapshot_state')
|
||||||
->label('Lifecycle')
|
->label('State')
|
||||||
->options(static::lifecycleOptions())
|
->options(static::snapshotStateOptions())
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@ -278,9 +267,9 @@ private static function baselineProfileOptions(): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function lifecycleOptions(): array
|
private static function snapshotStateOptions(): array
|
||||||
{
|
{
|
||||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveWorkspace(): ?Workspace
|
public static function resolveWorkspace(): ?Workspace
|
||||||
@ -354,18 +343,24 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
|||||||
return self::gapsCount($snapshot) > 0;
|
return self::gapsCount($snapshot) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||||
{
|
{
|
||||||
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
return self::gapSpec($snapshot)->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||||
{
|
{
|
||||||
if (! is_string($value) || trim($value) === '') {
|
if (! is_string($value) || trim($value) === '') {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->where('lifecycle_state', trim($value));
|
$gapCountExpression = self::gapCountExpression($query);
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||||
|
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapCountExpression(Builder $query): string
|
private static function gapCountExpression(Builder $query): string
|
||||||
@ -389,51 +384,4 @@ private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruth
|
|||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
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,8 +35,6 @@ public function mount(int|string $record): void
|
|||||||
$snapshot = $this->getRecord();
|
$snapshot = $this->getRecord();
|
||||||
|
|
||||||
if ($snapshot instanceof BaselineSnapshot) {
|
if ($snapshot instanceof BaselineSnapshot) {
|
||||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
|
||||||
|
|
||||||
$relatedContext = app(RelatedNavigationResolver::class)
|
$relatedContext = app(RelatedNavigationResolver::class)
|
||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
@ -129,11 +128,10 @@ public static function table(Table $table): Table
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
@ -156,10 +154,10 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -255,25 +253,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
{
|
{
|
||||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
|
|
||||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||||
$targetScope = static::targetScopeDisplay($record);
|
$targetScope = static::targetScopeDisplay($record);
|
||||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: 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')
|
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||||
@ -308,8 +294,8 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
kind: 'current_status',
|
kind: 'current_status',
|
||||||
title: 'Artifact truth',
|
title: 'Artifact truth',
|
||||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
||||||
visible: $artifactTruth !== null,
|
visible: $record->isGovernanceArtifactOperation(),
|
||||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
@ -329,15 +315,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
items: array_values(array_filter([
|
items: array_values(array_filter([
|
||||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
$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)),
|
$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
|
$referencedTenantLifecycle !== null
|
||||||
? $factory->keyFact(
|
? $factory->keyFact(
|
||||||
'Tenant lifecycle',
|
'Tenant lifecycle',
|
||||||
@ -356,26 +333,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$referencedTenantLifecycle?->contextNote !== null
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
: null,
|
: 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
|
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||||
: null,
|
: null,
|
||||||
@ -442,25 +399,9 @@ 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') {
|
if ((string) $record->type === 'baseline_compare') {
|
||||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
$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 !== []) {
|
if ($baselineCompareFacts !== []) {
|
||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
@ -473,25 +414,6 @@ 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 !== []) {
|
if ($baselineCompareEvidence !== []) {
|
||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
@ -616,8 +538,6 @@ private static function baselineCompareFacts(
|
|||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
): array {
|
): array {
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
|
||||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
|
||||||
$facts = [];
|
$facts = [];
|
||||||
|
|
||||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||||
@ -649,17 +569,6 @@ private static function baselineCompareFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
|
||||||
$facts[] = $factory->keyFact(
|
|
||||||
'Evidence gap detail',
|
|
||||||
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
|
||||||
'details_recorded' => 'Recorded subjects available',
|
|
||||||
'details_not_recorded' => 'Detailed rows were not recorded',
|
|
||||||
default => 'No evidence gaps recorded',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($uncoveredTypes !== []) {
|
if ($uncoveredTypes !== []) {
|
||||||
sort($uncoveredTypes, SORT_STRING);
|
sort($uncoveredTypes, SORT_STRING);
|
||||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||||
@ -800,82 +709,6 @@ private static function contextPayload(OperationRun $record): array
|
|||||||
return $context;
|
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
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
{
|
{
|
||||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
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)
|
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
@ -295,7 +295,7 @@ public static function table(Table $table): Table
|
|||||||
->boolean(),
|
->boolean(),
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
@ -563,7 +563,6 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
|
||||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
|
|||||||
@ -22,8 +22,6 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'state' => 'no_assignment',
|
|
||||||
'message' => null,
|
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
'findingsCount' => 0,
|
'findingsCount' => 0,
|
||||||
'highCount' => 0,
|
'highCount' => 0,
|
||||||
@ -45,8 +43,6 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'state' => $stats->state,
|
|
||||||
'message' => $stats->message,
|
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
'findingsCount' => $stats->findingsCount ?? 0,
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||||
|
|||||||
@ -44,10 +44,8 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
'shouldShow' => $hasWarnings && $runUrl !== null,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'state' => $stats->state,
|
|
||||||
'message' => $stats->message,
|
|
||||||
'coverageStatus' => $coverageStatus,
|
'coverageStatus' => $coverageStatus,
|
||||||
'fidelity' => $stats->fidelity,
|
'fidelity' => $stats->fidelity,
|
||||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -12,18 +11,11 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public int $bulkRunId = 0;
|
public int $bulkRunId = 0;
|
||||||
|
|
||||||
@ -76,6 +68,32 @@ 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
|
private function resolveOperationRun(): OperationRun
|
||||||
{
|
{
|
||||||
if ($this->operationRun instanceof OperationRun) {
|
if ($this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -16,15 +15,7 @@
|
|||||||
|
|
||||||
class BulkTenantSyncJob implements ShouldQueue
|
class BulkTenantSyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 180;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -22,9 +21,7 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -36,19 +33,10 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
@ -72,13 +60,13 @@ public function handle(
|
|||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
|
||||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||||
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
|
||||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||||
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
@ -220,17 +208,16 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$snapshotResult = $this->captureSnapshotArtifact(
|
$snapshot = $this->findOrCreateSnapshot(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
$items,
|
$items,
|
||||||
$snapshotSummary,
|
$snapshotSummary,
|
||||||
);
|
);
|
||||||
|
|
||||||
$snapshot = $snapshotResult['snapshot'];
|
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
|
||||||
|
|
||||||
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
if ($profile->status === BaselineProfileStatus::Active) {
|
||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +258,6 @@ public function handle(
|
|||||||
'snapshot_identity_hash' => $identityHash,
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'items_captured' => $snapshotItems['items_count'],
|
'items_captured' => $snapshotItems['items_count'],
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -522,151 +508,29 @@ 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,
|
BaselineProfile $profile,
|
||||||
string $identityHash,
|
string $identityHash,
|
||||||
array $snapshotItems,
|
array $snapshotItems,
|
||||||
array $summaryJsonb,
|
array $summaryJsonb,
|
||||||
): array {
|
): BaselineSnapshot {
|
||||||
$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()
|
$existing = BaselineSnapshot::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->where('baseline_profile_id', $profile->getKey())
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
->where('snapshot_identity_hash', $identityHash)
|
->where('snapshot_identity_hash', $identityHash)
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $existing instanceof BaselineSnapshot ? $existing : null;
|
if ($existing instanceof BaselineSnapshot) {
|
||||||
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$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,
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
|
||||||
'summary_jsonb' => $summaryJsonb,
|
'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) {
|
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||||
$rows = array_map(
|
$rows = array_map(
|
||||||
@ -685,56 +549,9 @@ private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapsho
|
|||||||
);
|
);
|
||||||
|
|
||||||
BaselineSnapshotItem::insert($rows);
|
BaselineSnapshotItem::insert($rows);
|
||||||
$persistedItems += count($rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $persistedItems;
|
return $snapshot;
|
||||||
}
|
|
||||||
|
|
||||||
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,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -19,7 +18,6 @@
|
|||||||
use App\Services\Baselines\BaselineAutoCloseService;
|
use App\Services\Baselines\BaselineAutoCloseService;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||||
@ -39,7 +37,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
@ -47,7 +44,6 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -58,15 +54,7 @@
|
|||||||
|
|
||||||
class CompareBaselineToTenantJob implements ShouldQueue
|
class CompareBaselineToTenantJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
@ -96,7 +84,6 @@ public function handle(
|
|||||||
?SettingsResolver $settingsResolver = null,
|
?SettingsResolver $settingsResolver = null,
|
||||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
|
||||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
@ -105,7 +92,6 @@ public function handle(
|
|||||||
$settingsResolver ??= app(SettingsResolver::class);
|
$settingsResolver ??= app(SettingsResolver::class);
|
||||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
|
||||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
@ -292,52 +278,12 @@ public function handle(
|
|||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey($snapshotId)
|
->whereKey($snapshotId)
|
||||||
->first();
|
->first(['id', 'captured_at']);
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
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
|
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
||||||
? CarbonImmutable::instance($snapshot->captured_at)
|
? CarbonImmutable::instance($snapshot->captured_at)
|
||||||
: null;
|
: null;
|
||||||
@ -388,7 +334,6 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$phaseResult = [];
|
$phaseResult = [];
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
$phaseGapSubjects = [];
|
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
@ -417,7 +362,6 @@ public function handle(
|
|||||||
|
|
||||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
$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;
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,12 +441,6 @@ public function handle(
|
|||||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||||
$gapsCount = array_sum($gapsByReason);
|
$gapsCount = array_sum($gapsByReason);
|
||||||
|
|
||||||
$gapSubjects = $this->collectGapSubjects(
|
|
||||||
ambiguousKeys: $ambiguousKeys,
|
|
||||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
|
||||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
'total' => count($driftResults),
|
'total' => count($driftResults),
|
||||||
'processed' => count($driftResults),
|
'processed' => count($driftResults),
|
||||||
@ -580,7 +518,6 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
...$gapsByReason,
|
...$gapsByReason,
|
||||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
'coverage' => [
|
'coverage' => [
|
||||||
@ -608,10 +545,6 @@ public function handle(
|
|||||||
'findings_resolved' => $resolvedCount,
|
'findings_resolved' => $resolvedCount,
|
||||||
'severity_breakdown' => $severityBreakdown,
|
'severity_breakdown' => $severityBreakdown,
|
||||||
];
|
];
|
||||||
$updatedContext = $this->withCompareReasonTranslation(
|
|
||||||
$updatedContext,
|
|
||||||
$reasonCode?->value,
|
|
||||||
);
|
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
$this->auditCompleted(
|
$this->auditCompleted(
|
||||||
@ -857,7 +790,6 @@ private function completeWithCoverageWarning(
|
|||||||
'findings_resolved' => 0,
|
'findings_resolved' => 0,
|
||||||
'severity_breakdown' => [],
|
'severity_breakdown' => [],
|
||||||
];
|
];
|
||||||
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -964,34 +896,6 @@ 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".
|
* Load current inventory items keyed by "policy_type|subject_key".
|
||||||
*
|
*
|
||||||
@ -1100,17 +1004,6 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
|||||||
return $run instanceof OperationRun ? $run : null;
|
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.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare baseline items vs current inventory and produce drift results.
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
*
|
*
|
||||||
@ -1143,7 +1036,6 @@ private function computeDrift(
|
|||||||
): array {
|
): array {
|
||||||
$drift = [];
|
$drift = [];
|
||||||
$evidenceGaps = [];
|
$evidenceGaps = [];
|
||||||
$evidenceGapSubjects = [];
|
|
||||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||||
|
|
||||||
@ -1185,7 +1077,6 @@ private function computeDrift(
|
|||||||
if (! is_array($currentItem)) {
|
if (! is_array($currentItem)) {
|
||||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1250,7 +1141,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_current'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1267,14 +1157,12 @@ private function computeDrift(
|
|||||||
if ($isRbacRoleDefinition) {
|
if ($isRbacRoleDefinition) {
|
||||||
if ($baselinePolicyVersionId === null) {
|
if ($baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentPolicyVersionId === null) {
|
if ($currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1288,7 +1176,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($roleDefinitionDiff === null) {
|
if ($roleDefinitionDiff === null) {
|
||||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1369,7 +1256,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_current'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1385,7 +1271,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1445,7 +1330,6 @@ private function computeDrift(
|
|||||||
return [
|
return [
|
||||||
'drift' => $drift,
|
'drift' => $drift,
|
||||||
'evidence_gaps' => $evidenceGaps,
|
'evidence_gaps' => $evidenceGaps,
|
||||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
|
||||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1957,44 +1841,6 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private const GAP_SUBJECTS_LIMIT = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $ambiguousKeys
|
|
||||||
* @param array<string, list<string>> $phaseGapSubjects
|
|
||||||
* @param array<string, list<string>> $driftGapSubjects
|
|
||||||
* @return array<string, list<string>>
|
|
||||||
*/
|
|
||||||
private function collectGapSubjects(array $ambiguousKeys, array $phaseGapSubjects, array $driftGapSubjects): array
|
|
||||||
{
|
|
||||||
$subjects = [];
|
|
||||||
|
|
||||||
if ($ambiguousKeys !== []) {
|
|
||||||
$subjects['ambiguous_match'] = array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([$phaseGapSubjects, $driftGapSubjects] as $subjectMap) {
|
|
||||||
foreach ($subjectMap as $reason => $keys) {
|
|
||||||
if (! is_string($reason) || ! is_array($keys) || $keys === []) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$subjects[$reason] = array_slice(
|
|
||||||
array_values(array_unique([
|
|
||||||
...($subjects[$reason] ?? []),
|
|
||||||
...array_values(array_filter($keys, 'is_string')),
|
|
||||||
])),
|
|
||||||
0,
|
|
||||||
self::GAP_SUBJECTS_LIMIT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($subjects);
|
|
||||||
|
|
||||||
return $subjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -18,13 +17,8 @@
|
|||||||
|
|
||||||
class ComposeTenantReviewJob implements ShouldQueue
|
class ComposeTenantReviewJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantReviewId,
|
public int $tenantReviewId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
<?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,10 +20,6 @@ class EntraGroupSyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -25,10 +25,6 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 420;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -19,10 +19,6 @@ class GenerateEvidenceSnapshotJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $snapshotId,
|
public int $snapshotId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -28,10 +28,6 @@ class GenerateReviewPackJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $reviewPackId,
|
public int $reviewPackId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -40,10 +40,6 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility-only legacy field.
|
* Compatibility-only legacy field.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -25,15 +24,7 @@
|
|||||||
|
|
||||||
class RunInventorySyncJob implements ShouldQueue
|
class RunInventorySyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -22,15 +21,7 @@
|
|||||||
|
|
||||||
class SyncPoliciesJob implements ShouldQueue
|
class SyncPoliciesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 180;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,6 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,191 +0,0 @@
|
|||||||
<?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{
|
|
||||||
* __id: string,
|
|
||||||
* reason_code: string,
|
|
||||||
* reason_label: string,
|
|
||||||
* policy_type: string,
|
|
||||||
* subject_key: string,
|
|
||||||
* search_text: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
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)),
|
|
||||||
])
|
|
||||||
->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_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;
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
->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,7 +7,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -122,37 +121,6 @@ public function snapshots(): HasMany
|
|||||||
return $this->hasMany(BaselineSnapshot::class);
|
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
|
public function tenantAssignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BaselineTenantAssignment::class);
|
return $this->hasMany(BaselineTenantAssignment::class);
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class BaselineSnapshot extends Model
|
class BaselineSnapshot extends Model
|
||||||
{
|
{
|
||||||
@ -19,20 +13,10 @@ class BaselineSnapshot extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
/**
|
protected $casts = [
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
|
||||||
'summary_jsonb' => 'array',
|
'summary_jsonb' => 'array',
|
||||||
'completion_meta_jsonb' => 'array',
|
|
||||||
'captured_at' => 'datetime',
|
'captured_at' => 'datetime',
|
||||||
'completed_at' => 'datetime',
|
|
||||||
'failed_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
@ -48,100 +32,4 @@ public function items(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(BaselineSnapshotItem::class);
|
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,7 +3,6 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -135,11 +134,6 @@ public function isGovernanceArtifactOperation(): bool
|
|||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function supportsOperatorExplanation(): bool
|
|
||||||
{
|
|
||||||
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function governanceArtifactFamily(): ?string
|
public function governanceArtifactFamily(): ?string
|
||||||
{
|
{
|
||||||
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
||||||
@ -165,32 +159,4 @@ public function relatedArtifactId(): ?int
|
|||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function reconciliation(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
$reconciliation = $context['reconciliation'] ?? null;
|
|
||||||
|
|
||||||
return is_array($reconciliation) ? $reconciliation : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isLifecycleReconciled(): bool
|
|
||||||
{
|
|
||||||
return $this->reconciliation() !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lifecycleReconciliationReasonCode(): ?string
|
|
||||||
{
|
|
||||||
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function freshnessState(): OperationRunFreshnessState
|
|
||||||
{
|
|
||||||
return OperationRunFreshnessState::forRun($this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||||
->requiredCapabilityForRun($run);
|
->requiredCapabilityForType((string) $run->type);
|
||||||
|
|
||||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -28,7 +28,6 @@
|
|||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
@ -203,7 +202,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Filament\System\Pages\Dashboard;
|
use App\Filament\System\Pages\Dashboard;
|
||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -61,6 +60,6 @@ public function panel(Panel $panel): Panel
|
|||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
])
|
])
|
||||||
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
->viteTheme('resources/css/filament/system/theme.css');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
@ -113,7 +112,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -152,23 +151,25 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
|||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$runs->updateRunWithReconciliation(
|
$runs->updateRun(
|
||||||
run: $run,
|
$run,
|
||||||
status: $opStatus,
|
status: $opStatus,
|
||||||
outcome: $opOutcome,
|
outcome: $opOutcome,
|
||||||
summaryCounts: $summaryCounts,
|
summaryCounts: $summaryCounts,
|
||||||
failures: $failures,
|
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();
|
$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) {
|
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||||
$run->started_at = $restoreRun->started_at;
|
$run->started_at = $restoreRun->started_at;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,18 +18,16 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
|
|
||||||
final class BaselineCompareService
|
final class BaselineCompareService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||||
*/
|
*/
|
||||||
public function startCompare(
|
public function startCompare(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -42,45 +40,38 @@ public function startCompare(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||||
}
|
}
|
||||||
|
|
||||||
$precondition = $this->validatePreconditions($profile);
|
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
||||||
|
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
return $this->failedStart($precondition);
|
return ['ok' => false, 'reason_code' => $precondition];
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectedSnapshot = null;
|
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
||||||
|
|
||||||
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
if ($snapshotId > 0) {
|
||||||
$selectedSnapshot = BaselineSnapshot::query()
|
$snapshot = BaselineSnapshot::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey((int) $baselineSnapshotId)
|
->whereKey($snapshotId)
|
||||||
->first();
|
->first(['id']);
|
||||||
|
|
||||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
return ['ok' => false, 'reason_code' => 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(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
);
|
);
|
||||||
@ -122,7 +113,7 @@ public function startCompare(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
@ -132,20 +123,10 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
|||||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,6 @@ public function __construct(
|
|||||||
* @return array{
|
* @return array{
|
||||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||||
* gaps: array<string, int>,
|
* gaps: array<string, int>,
|
||||||
* gap_subjects: array<string, list<string>>,
|
|
||||||
* resume_token: ?string,
|
* resume_token: ?string,
|
||||||
* captured_versions: array<string, array{
|
* captured_versions: array<string, array{
|
||||||
* policy_type: string,
|
* policy_type: string,
|
||||||
@ -77,8 +76,6 @@ public function capture(
|
|||||||
|
|
||||||
/** @var array<string, int> $gaps */
|
/** @var array<string, int> $gaps */
|
||||||
$gaps = [];
|
$gaps = [];
|
||||||
/** @var array<string, list<string>> $gapSubjects */
|
|
||||||
$gapSubjects = [];
|
|
||||||
$capturedVersions = [];
|
$capturedVersions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -93,7 +90,6 @@ public function capture(
|
|||||||
|
|
||||||
if ($policyType === '' || $externalId === '') {
|
if ($policyType === '' || $externalId === '') {
|
||||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||||
$gapSubjects['invalid_subject'][] = ($policyType !== '' ? $policyType : 'unknown').'|'.($externalId !== '' ? $externalId : 'unknown');
|
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -103,7 +99,6 @@ public function capture(
|
|||||||
|
|
||||||
if (isset($seen[$subjectKey])) {
|
if (isset($seen[$subjectKey])) {
|
||||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||||
$gapSubjects['duplicate_subject'][] = $subjectKey;
|
|
||||||
$stats['skipped']++;
|
$stats['skipped']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -119,7 +114,6 @@ public function capture(
|
|||||||
|
|
||||||
if (! $policy instanceof Policy) {
|
if (! $policy instanceof Policy) {
|
||||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
||||||
$gapSubjects['policy_not_found'][] = $subjectKey;
|
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -185,11 +179,9 @@ public function capture(
|
|||||||
|
|
||||||
if ($isThrottled) {
|
if ($isThrottled) {
|
||||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||||
$gapSubjects['throttled'][] = $subjectKey;
|
|
||||||
$stats['throttled']++;
|
$stats['throttled']++;
|
||||||
} else {
|
} else {
|
||||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||||
$gapSubjects['capture_failed'][] = $subjectKey;
|
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,27 +202,14 @@ public function capture(
|
|||||||
$remainingCount = max(0, count($subjects) - $processed);
|
$remainingCount = max(0, count($subjects) - $processed);
|
||||||
if ($remainingCount > 0) {
|
if ($remainingCount > 0) {
|
||||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
$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'] ?? ''));
|
|
||||||
|
|
||||||
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$gapSubjects['budget_exhausted'][] = $remainingPolicyType.'|'.$remainingExternalId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ksort($gaps);
|
ksort($gaps);
|
||||||
ksort($gapSubjects);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
'gap_subjects' => $gapSubjects,
|
|
||||||
'resume_token' => $resumeTokenOut,
|
'resume_token' => $resumeTokenOut,
|
||||||
'captured_versions' => $capturedVersions,
|
'captured_versions' => $capturedVersions,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
<?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,7 +14,6 @@
|
|||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@ -100,21 +99,13 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
{
|
{
|
||||||
$rendered = $this->present($snapshot);
|
$rendered = $this->present($snapshot);
|
||||||
$factory = new EnterpriseDetailSectionFactory;
|
$factory = new EnterpriseDetailSectionFactory;
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
|
||||||
|
|
||||||
$truthBadge = $factory->statusBadge(
|
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
||||||
$truth->primaryBadgeSpec()->label,
|
$stateBadge = $factory->statusBadge(
|
||||||
$truth->primaryBadgeSpec()->color,
|
$stateSpec->label,
|
||||||
$truth->primaryBadgeSpec()->icon,
|
$stateSpec->color,
|
||||||
$truth->primaryBadgeSpec()->iconColor,
|
$stateSpec->icon,
|
||||||
);
|
$stateSpec->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);
|
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||||
@ -129,27 +120,20 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||||
$rendered->summaryRows,
|
$rendered->summaryRows,
|
||||||
));
|
));
|
||||||
$currentTruth = $this->currentTruthPresentation($truth);
|
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||||
$currentTruthBadge = $factory->statusBadge(
|
|
||||||
$currentTruth['label'],
|
|
||||||
$currentTruth['color'],
|
|
||||||
$currentTruth['icon'],
|
|
||||||
$currentTruth['iconColor'],
|
|
||||||
);
|
|
||||||
$operatorExplanation = $truth->operatorExplanation;
|
|
||||||
|
|
||||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||||
->header(new SummaryHeaderData(
|
->header(new SummaryHeaderData(
|
||||||
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
||||||
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
||||||
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
|
statusBadges: [$stateBadge, $fidelityBadge],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
|
||||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||||
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||||
$factory->keyFact('Captured items', $capturedItemCount),
|
$factory->keyFact('Captured items', $capturedItemCount),
|
||||||
],
|
],
|
||||||
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
@ -191,30 +175,11 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
->addSupportingCard(
|
->addSupportingCard(
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
title: 'Snapshot truth',
|
title: 'Snapshot status',
|
||||||
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: [
|
items: [
|
||||||
|
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
||||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||||
$factory->keyFact('Captured items', $capturedItemCount),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
@ -222,8 +187,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
title: 'Capture timing',
|
title: 'Capture timing',
|
||||||
items: [
|
items: [
|
||||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
$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),
|
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -375,33 +338,6 @@ 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
|
private function typeLabel(string $policyType): string
|
||||||
{
|
{
|
||||||
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||||
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
||||||
use App\Support\OpsUx\BulkRunContext;
|
use App\Support\OpsUx\BulkRunContext;
|
||||||
@ -63,45 +62,15 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
|||||||
|
|
||||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||||
{
|
{
|
||||||
return $this->forceFailNonTerminalRun(
|
return $this->updateRun(
|
||||||
$run,
|
$run,
|
||||||
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
message: $message,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
source: 'scheduled_reconciler',
|
failures: [
|
||||||
evidence: [
|
[
|
||||||
'status' => OperationRunStatus::Queued->value,
|
'code' => 'run.stale_queued',
|
||||||
'created_at' => $run->created_at?->toIso8601String(),
|
'message' => $message,
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -752,136 +721,6 @@ 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.
|
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
||||||
*
|
*
|
||||||
@ -1194,49 +1033,16 @@ private function isDirectlyTranslatableReason(string $reasonCode): bool
|
|||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
return ProviderReasonCodes::isKnown($reasonCode)
|
||||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
|
||||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
|| 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
|
private function writeTerminalAudit(OperationRun $run): void
|
||||||
{
|
{
|
||||||
$tenant = $run->tenant;
|
$tenant = $run->tenant;
|
||||||
$workspace = $run->workspace;
|
$workspace = $run->workspace;
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
||||||
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||||
|
|
||||||
$action = match ($run->outcome) {
|
$action = match ($run->outcome) {
|
||||||
@ -1266,7 +1072,6 @@ private function writeTerminalAudit(OperationRun $run): void
|
|||||||
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
||||||
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
||||||
'blocked_by' => $context['blocked_by'] ?? null,
|
'blocked_by' => $context['blocked_by'] ?? null,
|
||||||
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
<?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,9 +20,6 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::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::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||||
|
|||||||
@ -11,9 +11,6 @@ enum BadgeDomain: string
|
|||||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
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 BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||||
case OperationRunStatus = 'operation_run_status';
|
case OperationRunStatus = 'operation_run_status';
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?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,46 +8,12 @@
|
|||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
|
|
||||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
public function spec(mixed $value): BadgeSpec
|
public function spec(mixed $value): BadgeSpec
|
||||||
{
|
{
|
||||||
$state = null;
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
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) {
|
return match ($state) {
|
||||||
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||||
|
|||||||
@ -8,33 +8,12 @@
|
|||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
|
|
||||||
final class OperationRunStatusBadge implements BadgeMapper
|
final class OperationRunStatusBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
public function spec(mixed $value): BadgeSpec
|
public function spec(mixed $value): BadgeSpec
|
||||||
{
|
{
|
||||||
$state = null;
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
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) {
|
return match ($state) {
|
||||||
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?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' => [
|
'partial' => [
|
||||||
'axis' => 'data_coverage',
|
'axis' => 'data_coverage',
|
||||||
'label' => 'Partially complete',
|
'label' => 'Partial',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'required',
|
'next_action_policy' => 'required',
|
||||||
@ -136,7 +136,7 @@ final class OperatorOutcomeTaxonomy
|
|||||||
],
|
],
|
||||||
'stale' => [
|
'stale' => [
|
||||||
'axis' => 'data_freshness',
|
'axis' => 'data_freshness',
|
||||||
'label' => 'Refresh recommended',
|
'label' => 'Stale',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'optional',
|
'next_action_policy' => 'optional',
|
||||||
@ -183,7 +183,7 @@ final class OperatorOutcomeTaxonomy
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'axis' => 'publication_readiness',
|
'axis' => 'publication_readiness',
|
||||||
'label' => 'Publication blocked',
|
'label' => 'Blocked',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'required',
|
'next_action_policy' => 'required',
|
||||||
@ -220,129 +220,6 @@ final class OperatorOutcomeTaxonomy
|
|||||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
'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' => [
|
'operation_run_status' => [
|
||||||
'queued' => [
|
'queued' => [
|
||||||
'axis' => 'execution_lifecycle',
|
'axis' => 'execution_lifecycle',
|
||||||
|
|||||||
@ -1,557 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Baselines;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
final class BaselineCompareEvidenceGapDetails
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @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
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
* @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
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
* @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
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public static function fromBaselineCompare(array $baselineCompare): array
|
|
||||||
{
|
|
||||||
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
|
||||||
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
|
||||||
|
|
||||||
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
|
||||||
$subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
|
||||||
|
|
||||||
foreach ($subjects as $reason => $keys) {
|
|
||||||
if (! array_key_exists($reason, $byReason)) {
|
|
||||||
$byReason[$reason] = count($keys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects);
|
|
||||||
$detailState = self::detailState($count, $subjects);
|
|
||||||
|
|
||||||
$buckets = [];
|
|
||||||
|
|
||||||
foreach (self::orderedReasons($byReason, $subjects) as $reason) {
|
|
||||||
$rows = self::rowsForReason($reason, $subjects[$reason] ?? []);
|
|
||||||
$reasonCount = $byReason[$reason] ?? count($rows);
|
|
||||||
|
|
||||||
if ($reasonCount <= 0 && $rows === []) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$recordedCount = count($rows);
|
|
||||||
$searchText = trim(implode(' ', array_filter([
|
|
||||||
Str::lower($reason),
|
|
||||||
Str::lower(self::reasonLabel($reason)),
|
|
||||||
...array_map(
|
|
||||||
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
|
||||||
$rows,
|
|
||||||
),
|
|
||||||
])));
|
|
||||||
|
|
||||||
$buckets[] = [
|
|
||||||
'reason_code' => $reason,
|
|
||||||
'reason_label' => self::reasonLabel($reason),
|
|
||||||
'count' => $reasonCount,
|
|
||||||
'recorded_count' => $recordedCount,
|
|
||||||
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
|
||||||
'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded',
|
|
||||||
'search_text' => $searchText,
|
|
||||||
'rows' => $rows,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$recordedSubjectsTotal = array_sum(array_map(
|
|
||||||
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
|
||||||
$buckets,
|
|
||||||
));
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => [
|
|
||||||
'count' => $count,
|
|
||||||
'by_reason' => $byReason,
|
|
||||||
'detail_state' => $detailState,
|
|
||||||
'recorded_subjects_total' => $recordedSubjectsTotal,
|
|
||||||
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
|
||||||
],
|
|
||||||
'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_not_found' => 'Policy not found',
|
|
||||||
'missing_current' => 'Missing current evidence',
|
|
||||||
'invalid_subject' => 'Invalid subject',
|
|
||||||
'duplicate_subject' => 'Duplicate subject',
|
|
||||||
'capture_failed' => 'Evidence capture failed',
|
|
||||||
'budget_exhausted' => 'Capture budget exhausted',
|
|
||||||
'throttled' => 'Graph throttled',
|
|
||||||
'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',
|
|
||||||
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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{
|
|
||||||
* __id: string,
|
|
||||||
* reason_code: string,
|
|
||||||
* reason_label: string,
|
|
||||||
* policy_type: string,
|
|
||||||
* subject_key: string,
|
|
||||||
* search_text: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
$reasonLabel = self::stringOrNull($row['reason_label'] ?? null);
|
|
||||||
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
|
||||||
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
|
||||||
|
|
||||||
if ($reasonCode === null || $reasonLabel === null || $policyType === null || $subjectKey === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows[] = [
|
|
||||||
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])),
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'reason_label' => $reasonLabel,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
'search_text' => Str::lower(implode(' ', [
|
|
||||||
$reasonCode,
|
|
||||||
$reasonLabel,
|
|
||||||
$policyType,
|
|
||||||
$subjectKey,
|
|
||||||
])),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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 empty(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary' => [
|
|
||||||
'count' => 0,
|
|
||||||
'by_reason' => [],
|
|
||||||
'detail_state' => 'no_gaps',
|
|
||||||
'recorded_subjects_total' => 0,
|
|
||||||
'missing_detail_count' => 0,
|
|
||||||
],
|
|
||||||
'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<string, list<string>>
|
|
||||||
*/
|
|
||||||
private static function normalizeSubjects(mixed $value): array
|
|
||||||
{
|
|
||||||
if (! is_array($value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = [];
|
|
||||||
|
|
||||||
foreach ($value as $reason => $keys) {
|
|
||||||
if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = array_values(array_unique(array_filter(array_map(
|
|
||||||
static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null,
|
|
||||||
$keys,
|
|
||||||
))));
|
|
||||||
|
|
||||||
if ($items === []) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized[trim($reason)] = $items;
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($normalized);
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, int> $byReason
|
|
||||||
* @param array<string, list<string>> $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<string>> $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 $keys): int => count($keys),
|
|
||||||
$subjects,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, list<string>> $subjects
|
|
||||||
*/
|
|
||||||
private static function detailState(int $count, array $subjects): string
|
|
||||||
{
|
|
||||||
if ($count <= 0) {
|
|
||||||
return 'no_gaps';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $subjects !== [] ? 'details_recorded' : 'details_not_recorded';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $subjects
|
|
||||||
* @return list<array{
|
|
||||||
* reason_code: string,
|
|
||||||
* reason_label: string,
|
|
||||||
* policy_type: string,
|
|
||||||
* subject_key: string,
|
|
||||||
* search_text: string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private static function rowsForReason(string $reason, array $subjects): array
|
|
||||||
{
|
|
||||||
$rows = [];
|
|
||||||
|
|
||||||
foreach ($subjects as $subject) {
|
|
||||||
[$policyType, $subjectKey] = self::splitSubject($subject);
|
|
||||||
|
|
||||||
if ($policyType === null || $subjectKey === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows[] = [
|
|
||||||
'reason_code' => $reason,
|
|
||||||
'reason_label' => self::reasonLabel($reason),
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
'search_text' => Str::lower(implode(' ', [
|
|
||||||
$reason,
|
|
||||||
self::reasonLabel($reason),
|
|
||||||
$policyType,
|
|
||||||
$subjectKey,
|
|
||||||
])),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: ?string, 1: ?string}
|
|
||||||
*/
|
|
||||||
private static function splitSubject(string $subject): array
|
|
||||||
{
|
|
||||||
$parts = explode('|', $subject, 2);
|
|
||||||
|
|
||||||
if (count($parts) !== 2) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyType = trim($parts[0]);
|
|
||||||
$subjectKey = trim($parts[1]);
|
|
||||||
|
|
||||||
if ($policyType === '' || $subjectKey === '') {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$policyType, $subjectKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function stringOrNull(mixed $value): ?string
|
|
||||||
{
|
|
||||||
if (! is_string($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function intOrNull(mixed $value): ?int
|
|
||||||
{
|
|
||||||
return is_numeric($value) ? (int) $value : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
<?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->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,9 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
|
||||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
||||||
|
|
||||||
enum BaselineCompareReasonCode: string
|
enum BaselineCompareReasonCode: string
|
||||||
{
|
{
|
||||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||||
@ -25,37 +22,4 @@ public function message(): string
|
|||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
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,17 +5,13 @@
|
|||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
final class BaselineCompareStats
|
final class BaselineCompareStats
|
||||||
@ -24,32 +20,6 @@ final class BaselineCompareStats
|
|||||||
* @param array<string, int> $severityCounts
|
* @param array<string, int> $severityCounts
|
||||||
* @param list<string> $uncoveredTypes
|
* @param list<string> $uncoveredTypes
|
||||||
* @param array<string, int> $evidenceGapsTopReasons
|
* @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(
|
private function __construct(
|
||||||
public readonly string $state,
|
public readonly string $state,
|
||||||
@ -73,8 +43,6 @@ private function __construct(
|
|||||||
public readonly ?int $evidenceGapsCount = null,
|
public readonly ?int $evidenceGapsCount = null,
|
||||||
public readonly array $evidenceGapsTopReasons = [],
|
public readonly array $evidenceGapsTopReasons = [],
|
||||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
public readonly ?array $rbacRoleDefinitionSummary = null,
|
||||||
public readonly array $evidenceGapDetails = [],
|
|
||||||
public readonly array $baselineCompareDiagnostics = [],
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function forTenant(?Tenant $tenant): self
|
public static function forTenant(?Tenant $tenant): self
|
||||||
@ -105,11 +73,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
|
|
||||||
$profileName = (string) $profile->name;
|
$profileName = (string) $profile->name;
|
||||||
$profileId = (int) $profile->getKey();
|
$profileId = (int) $profile->getKey();
|
||||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||||
$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(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
@ -122,21 +86,12 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||||
|
|
||||||
if ($snapshotId === null) {
|
if ($snapshotId === null) {
|
||||||
return new self(
|
return self::empty(
|
||||||
state: 'no_snapshot',
|
'no_snapshot',
|
||||||
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||||
profileName: $profileName,
|
profileName: $profileName,
|
||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: null,
|
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
operationRunId: null,
|
|
||||||
findingsCount: null,
|
|
||||||
severityCounts: [],
|
|
||||||
lastComparedHuman: null,
|
|
||||||
lastComparedIso: null,
|
|
||||||
failureReason: null,
|
|
||||||
reasonCode: $snapshotReasonCode,
|
|
||||||
reasonMessage: $snapshotReasonMessage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,8 +105,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||||
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
|
||||||
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
|
||||||
|
|
||||||
// Active run (queued/running)
|
// Active run (queued/running)
|
||||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||||
@ -177,8 +130,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
evidenceGapDetails: $evidenceGapDetails,
|
|
||||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,8 +162,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
evidenceGapDetails: $evidenceGapDetails,
|
|
||||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,8 +216,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
evidenceGapDetails: $evidenceGapDetails,
|
|
||||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,8 +244,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
evidenceGapDetails: $evidenceGapDetails,
|
|
||||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,8 +269,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||||
evidenceGapDetails: $evidenceGapDetails,
|
|
||||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,11 +291,6 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
}
|
}
|
||||||
|
|
||||||
$profile = $assignment->baselineProfile;
|
$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();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$severityRows = Finding::query()
|
$severityRows = Finding::query()
|
||||||
@ -376,11 +314,11 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
|
state: $totalFindings > 0 ? 'ready' : 'idle',
|
||||||
message: $snapshotId === null ? $snapshotReasonMessage : null,
|
message: null,
|
||||||
profileName: (string) $profile->name,
|
profileName: (string) $profile->name,
|
||||||
profileId: (int) $profile->getKey(),
|
profileId: (int) $profile->getKey(),
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||||
duplicateNamePoliciesCount: null,
|
duplicateNamePoliciesCount: null,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
@ -392,8 +330,6 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
||||||
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
reasonCode: $snapshotReasonCode,
|
|
||||||
reasonMessage: $snapshotReasonMessage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -555,67 +491,48 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
|||||||
return [null, []];
|
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 : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||||
|
|
||||||
if (! is_array($baselineCompare)) {
|
if (! is_array($baselineCompare)) {
|
||||||
return [];
|
return [null, []];
|
||||||
}
|
}
|
||||||
|
|
||||||
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
|
$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)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -644,31 +561,6 @@ 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(
|
private static function empty(
|
||||||
string $state,
|
string $state,
|
||||||
?string $message,
|
?string $message,
|
||||||
@ -691,15 +583,4 @@ private static function empty(
|
|||||||
failureReason: null,
|
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,125 +18,13 @@ final class BaselineReasonCodes
|
|||||||
|
|
||||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
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_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
|
||||||
|
|
||||||
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
|
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_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_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
||||||
|
|
||||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -378,7 +378,7 @@ private function resolveBaselineProfileRule(NavigationMatrixRule $rule, Baseline
|
|||||||
return match ($rule->relationKey) {
|
return match ($rule->relationKey) {
|
||||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||||
rule: $rule,
|
rule: $rule,
|
||||||
snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
|
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
|
||||||
workspaceId: (int) $profile->workspace_id,
|
workspaceId: (int) $profile->workspace_id,
|
||||||
),
|
),
|
||||||
default => null,
|
default => null,
|
||||||
|
|||||||
@ -121,12 +121,4 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
|
|||||||
{
|
{
|
||||||
return self::governanceArtifactFamily($operationType) !== null;
|
return self::governanceArtifactFamily($operationType) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function supportsOperatorExplanation(string $operationType): bool
|
|
||||||
{
|
|
||||||
$operationType = trim($operationType);
|
|
||||||
|
|
||||||
return self::isGovernanceArtifactOperation($operationType)
|
|
||||||
|| $operationType === 'baseline_compare';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
<?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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
<?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,16 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Support\Operations;
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
|
||||||
final class OperationRunCapabilityResolver
|
final class OperationRunCapabilityResolver
|
||||||
{
|
{
|
||||||
public function requiredCapabilityForRun(OperationRun $run): ?string
|
|
||||||
{
|
|
||||||
return $this->requiredCapabilityForType((string) $run->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function requiredCapabilityForType(string $operationType): ?string
|
public function requiredCapabilityForType(string $operationType): ?string
|
||||||
{
|
{
|
||||||
$operationType = trim($operationType);
|
$operationType = trim($operationType);
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
<?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,11 +7,8 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
|
||||||
final class OperationUxPresenter
|
final class OperationUxPresenter
|
||||||
@ -101,33 +98,11 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
|||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
$reasonEnvelope = self::reasonEnvelope($run);
|
||||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
||||||
$operatorExplanationGuidance = self::operatorExplanationGuidance($run);
|
|
||||||
$nextStepLabel = self::firstNextStepLabel($run);
|
$nextStepLabel = self::firstNextStepLabel($run);
|
||||||
$freshnessState = self::freshnessState($run);
|
|
||||||
|
|
||||||
if ($freshnessState->isLikelyStale()) {
|
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
||||||
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;
|
return $reasonGuidance;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) {
|
|
||||||
return $operatorExplanationGuidance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($uxStatus) {
|
return match ($uxStatus) {
|
||||||
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
||||||
@ -149,44 +124,15 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
|||||||
|
|
||||||
public static function surfaceFailureDetail(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);
|
$reasonEnvelope = self::reasonEnvelope($run);
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
if ($reasonEnvelope !== null) {
|
||||||
return $reasonEnvelope->shortExplanation;
|
return $reasonEnvelope->shortExplanation;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::freshnessState($run)->isLikelyStale()) {
|
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||||
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return self::sanitizeFailureMessage($failureMessage);
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -196,15 +142,6 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
{
|
{
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
$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) {
|
return match ($uxStatus) {
|
||||||
'succeeded' => [
|
'succeeded' => [
|
||||||
@ -286,32 +223,4 @@ private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonT
|
|||||||
{
|
{
|
||||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
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,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
final class RunDurationInsights
|
final class RunDurationInsights
|
||||||
@ -119,10 +118,6 @@ public static function expectedHuman(OperationRun $run): ?string
|
|||||||
|
|
||||||
public static function stuckGuidance(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);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
|
|
||||||
if (! in_array($uxStatus, ['queued', 'running'], true)) {
|
if (! in_array($uxStatus, ['queued', 'running'], true)) {
|
||||||
|
|||||||
@ -3,9 +3,7 @@
|
|||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
use App\Services\Intune\SecretClassificationService;
|
use App\Services\Intune\SecretClassificationService;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
final class RunFailureSanitizer
|
final class RunFailureSanitizer
|
||||||
@ -132,15 +130,7 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
|
|||||||
ExecutionDenialReasonCode::cases(),
|
ExecutionDenialReasonCode::cases(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$lifecycleReasonCodes = array_map(
|
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
||||||
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
|
public static function sanitizeMessage(string $message): string
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Support\ReasonTranslation;
|
namespace App\Support\ReasonTranslation;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
||||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
final class FallbackReasonTranslator implements TranslatesReasonCode
|
final class FallbackReasonTranslator implements TranslatesReasonCode
|
||||||
@ -44,8 +43,6 @@ public function translate(string $reasonCode, string $surface = 'detail', array
|
|||||||
nextSteps: $nextSteps,
|
nextSteps: $nextSteps,
|
||||||
showNoActionNeeded: $actionability === 'non_actionable',
|
showNoActionNeeded: $actionability === 'non_actionable',
|
||||||
diagnosticCodeLabel: $normalizedCode,
|
diagnosticCodeLabel: $normalizedCode,
|
||||||
trustImpact: $this->trustImpactFor($actionability),
|
|
||||||
absencePattern: $this->absencePatternFor($normalizedCode, $actionability),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,36 +109,4 @@ private function fallbackNextStepsFor(string $actionability): array
|
|||||||
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
|
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,7 +8,6 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
use App\Support\Providers\ProviderReasonTranslator;
|
||||||
use App\Support\RbacReason;
|
use App\Support\RbacReason;
|
||||||
@ -25,16 +24,14 @@ public function __construct(
|
|||||||
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$storedTranslation = $this->storedOperationRunTranslation($context);
|
$storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
|
||||||
|
|
||||||
if ($storedTranslation !== null) {
|
if ($storedTranslation !== null) {
|
||||||
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
||||||
|
|
||||||
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
||||||
$nextSteps = $this->operationRunNextSteps($context);
|
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
|
||||||
|
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
|
||||||
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
|
|
||||||
return $storedEnvelope->withNextSteps($nextSteps);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $storedEnvelope;
|
return $storedEnvelope;
|
||||||
@ -42,8 +39,7 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
|||||||
}
|
}
|
||||||
|
|
||||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
$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) !== '') {
|
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
||||||
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
||||||
@ -71,33 +67,11 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
|||||||
return $envelope;
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
$legacyNextSteps = $this->operationRunNextSteps($context);
|
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
||||||
|
|
||||||
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
|
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
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
@ -117,7 +91,6 @@ private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
|
|||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
return ProviderReasonCodes::isKnown($reasonCode)
|
||||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
|
||||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||||
}
|
}
|
||||||
@ -194,26 +167,6 @@ public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
|||||||
return $envelope?->shortExplanation;
|
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
|
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
|
||||||
{
|
{
|
||||||
return $envelope?->guidanceText();
|
return $envelope?->guidanceText();
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
namespace App\Support\ReasonTranslation;
|
||||||
|
|
||||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
final readonly class ReasonResolutionEnvelope
|
final readonly class ReasonResolutionEnvelope
|
||||||
@ -20,8 +19,6 @@ public function __construct(
|
|||||||
public array $nextSteps = [],
|
public array $nextSteps = [],
|
||||||
public bool $showNoActionNeeded = false,
|
public bool $showNoActionNeeded = false,
|
||||||
public ?string $diagnosticCodeLabel = null,
|
public ?string $diagnosticCodeLabel = null,
|
||||||
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
|
|
||||||
public ?string $absencePattern = null,
|
|
||||||
) {
|
) {
|
||||||
if (trim($this->internalCode) === '') {
|
if (trim($this->internalCode) === '') {
|
||||||
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
||||||
@ -44,24 +41,6 @@ public function __construct(
|
|||||||
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
|
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) {
|
foreach ($this->nextSteps as $nextStep) {
|
||||||
if (! $nextStep instanceof NextStepOption) {
|
if (! $nextStep instanceof NextStepOption) {
|
||||||
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
||||||
@ -91,12 +70,6 @@ public static function fromArray(array $data): ?self
|
|||||||
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
||||||
? trim((string) $data['diagnostic_code_label'])
|
? trim((string) $data['diagnostic_code_label'])
|
||||||
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
|
: (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 === '') {
|
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
|
||||||
return null;
|
return null;
|
||||||
@ -110,8 +83,6 @@ public static function fromArray(array $data): ?self
|
|||||||
nextSteps: $nextSteps,
|
nextSteps: $nextSteps,
|
||||||
showNoActionNeeded: $showNoActionNeeded,
|
showNoActionNeeded: $showNoActionNeeded,
|
||||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
||||||
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
|
||||||
absencePattern: $absencePattern !== '' ? $absencePattern : null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,8 +99,6 @@ public function withNextSteps(array $nextSteps): self
|
|||||||
nextSteps: $nextSteps,
|
nextSteps: $nextSteps,
|
||||||
showNoActionNeeded: $this->showNoActionNeeded,
|
showNoActionNeeded: $this->showNoActionNeeded,
|
||||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
||||||
trustImpact: $this->trustImpact,
|
|
||||||
absencePattern: $this->absencePattern,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,8 +179,6 @@ public function toLegacyNextSteps(): array
|
|||||||
* }>,
|
* }>,
|
||||||
* show_no_action_needed: bool,
|
* show_no_action_needed: bool,
|
||||||
* diagnostic_code_label: string
|
* diagnostic_code_label: string
|
||||||
* trust_impact: string,
|
|
||||||
* absence_pattern: ?string
|
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@ -227,8 +194,6 @@ public function toArray(): array
|
|||||||
),
|
),
|
||||||
'show_no_action_needed' => $this->showNoActionNeeded,
|
'show_no_action_needed' => $this->showNoActionNeeded,
|
||||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
'diagnostic_code_label' => $this->diagnosticCode(),
|
||||||
'trust_impact' => $this->trustImpact,
|
|
||||||
'absence_pattern' => $this->absencePattern,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
namespace App\Support\ReasonTranslation;
|
||||||
|
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
use App\Support\Providers\ProviderReasonTranslator;
|
||||||
use App\Support\RbacReason;
|
use App\Support\RbacReason;
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
||||||
|
|
||||||
final class ReasonTranslator
|
final class ReasonTranslator
|
||||||
{
|
{
|
||||||
@ -47,13 +43,8 @@ public function translate(
|
|||||||
return match (true) {
|
return match (true) {
|
||||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
||||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
$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 === self::EXECUTION_DENIAL_ARTIFACT,
|
||||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
$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 === self::TENANT_OPERABILITY_ARTIFACT,
|
||||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||||
$artifactKey === self::RBAC_ARTIFACT,
|
$artifactKey === self::RBAC_ARTIFACT,
|
||||||
@ -83,184 +74,4 @@ private function fallbackTranslate(
|
|||||||
|
|
||||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
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,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
||||||
|
|
||||||
final readonly class ArtifactTruthCause
|
final readonly class ArtifactTruthCause
|
||||||
{
|
{
|
||||||
@ -19,8 +18,6 @@ public function __construct(
|
|||||||
public ?string $operatorLabel,
|
public ?string $operatorLabel,
|
||||||
public ?string $shortExplanation,
|
public ?string $shortExplanation,
|
||||||
public ?string $diagnosticCode,
|
public ?string $diagnosticCode,
|
||||||
public string $trustImpact,
|
|
||||||
public ?string $absencePattern,
|
|
||||||
public array $nextSteps = [],
|
public array $nextSteps = [],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -38,8 +35,6 @@ public static function fromReasonResolutionEnvelope(
|
|||||||
operatorLabel: $reason->operatorLabel,
|
operatorLabel: $reason->operatorLabel,
|
||||||
shortExplanation: $reason->shortExplanation,
|
shortExplanation: $reason->shortExplanation,
|
||||||
diagnosticCode: $reason->diagnosticCode(),
|
diagnosticCode: $reason->diagnosticCode(),
|
||||||
trustImpact: $reason->trustImpact,
|
|
||||||
absencePattern: $reason->absencePattern,
|
|
||||||
nextSteps: array_values(array_map(
|
nextSteps: array_values(array_map(
|
||||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||||
$reason->nextSteps,
|
$reason->nextSteps,
|
||||||
@ -47,23 +42,6 @@ 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{
|
* @return array{
|
||||||
* reasonCode: ?string,
|
* reasonCode: ?string,
|
||||||
@ -71,8 +49,6 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
|||||||
* operatorLabel: ?string,
|
* operatorLabel: ?string,
|
||||||
* shortExplanation: ?string,
|
* shortExplanation: ?string,
|
||||||
* diagnosticCode: ?string,
|
* diagnosticCode: ?string,
|
||||||
* trustImpact: string,
|
|
||||||
* absencePattern: ?string,
|
|
||||||
* nextSteps: array<int, string>
|
* nextSteps: array<int, string>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@ -84,8 +60,6 @@ public function toArray(): array
|
|||||||
'operatorLabel' => $this->operatorLabel,
|
'operatorLabel' => $this->operatorLabel,
|
||||||
'shortExplanation' => $this->shortExplanation,
|
'shortExplanation' => $this->shortExplanation,
|
||||||
'diagnosticCode' => $this->diagnosticCode,
|
'diagnosticCode' => $this->diagnosticCode,
|
||||||
'trustImpact' => $this->trustImpact,
|
|
||||||
'absencePattern' => $this->absencePattern,
|
|
||||||
'nextSteps' => $this->nextSteps,
|
'nextSteps' => $this->nextSteps,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
||||||
|
|
||||||
final readonly class ArtifactTruthEnvelope
|
final readonly class ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
@ -33,7 +32,6 @@ public function __construct(
|
|||||||
public ?string $relatedArtifactUrl,
|
public ?string $relatedArtifactUrl,
|
||||||
public array $dimensions = [],
|
public array $dimensions = [],
|
||||||
public ?ArtifactTruthCause $reason = null,
|
public ?ArtifactTruthCause $reason = null,
|
||||||
public ?OperatorExplanationPattern $operatorExplanation = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function primaryDimension(): ?ArtifactTruthDimension
|
public function primaryDimension(): ?ArtifactTruthDimension
|
||||||
@ -101,11 +99,8 @@ public function nextStepText(): string
|
|||||||
* operatorLabel: ?string,
|
* operatorLabel: ?string,
|
||||||
* shortExplanation: ?string,
|
* shortExplanation: ?string,
|
||||||
* diagnosticCode: ?string,
|
* diagnosticCode: ?string,
|
||||||
* trustImpact: string,
|
|
||||||
* absencePattern: ?string,
|
|
||||||
* nextSteps: array<int, string>
|
* nextSteps: array<int, string>
|
||||||
* },
|
* }
|
||||||
* operatorExplanation: ?array<string, mixed>
|
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@ -137,7 +132,6 @@ public function toArray(): array
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
'reason' => $this->reason?->toArray(),
|
'reason' => $this->reason?->toArray(),
|
||||||
'operatorExplanation' => $this->operatorExplanation?->toArray(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -21,22 +20,17 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
final class ArtifactTruthPresenter
|
final class ArtifactTruthPresenter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ReasonPresenter $reasonPresenter,
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
|
||||||
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||||
@ -58,49 +52,38 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
|||||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||||
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
||||||
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
||||||
$effectiveSnapshot = $snapshot->baselineProfile !== null
|
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
||||||
? $this->snapshotTruthResolver->resolveEffectiveSnapshot($snapshot->baselineProfile)
|
&& $snapshot->baselineProfile !== null;
|
||||||
: null;
|
|
||||||
$isHistorical = $this->snapshotTruthResolver->isHistoricallySuperseded($snapshot, $effectiveSnapshot);
|
|
||||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
||||||
$severeGapReasons = array_filter(
|
$severeGapReasons = array_filter(
|
||||||
$gapReasons,
|
$gapReasons,
|
||||||
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
||||||
ARRAY_FILTER_USE_BOTH,
|
ARRAY_FILTER_USE_BOTH,
|
||||||
);
|
);
|
||||||
$reasonCode = $this->snapshotTruthResolver->artifactReasonCode($snapshot, $effectiveSnapshot)
|
$reasonCode = $this->firstReasonCode($severeGapReasons);
|
||||||
?? $this->firstReasonCode($severeGapReasons);
|
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
|
||||||
$artifactExistence = match (true) {
|
$artifactExistence = match (true) {
|
||||||
$isHistorical => 'historical_only',
|
$isHistorical => 'historical_only',
|
||||||
$snapshot->isBuilding(), $snapshot->isIncomplete() => 'created_but_not_usable',
|
! $hasItems => 'created_but_not_usable',
|
||||||
! $snapshot->isConsumable() => 'created_but_not_usable',
|
|
||||||
default => 'created',
|
default => 'created',
|
||||||
};
|
};
|
||||||
|
|
||||||
$contentState = match ($fidelity) {
|
$contentState = match ($fidelity) {
|
||||||
FidelityState::Full => $snapshot->isIncomplete()
|
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
||||||
? ($hasItems ? 'partial' : 'missing_input')
|
FidelityState::Partial => 'partial',
|
||||||
: ($severeGapReasons === [] ? 'trusted' : 'partial'),
|
FidelityState::ReferenceOnly => 'reference_only',
|
||||||
FidelityState::Partial => $snapshot->isBuilding() ? 'missing_input' : 'partial',
|
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
||||||
FidelityState::ReferenceOnly => $snapshot->isBuilding() ? 'missing_input' : 'reference_only',
|
|
||||||
FidelityState::Unsupported => $snapshot->isBuilding() ? 'missing_input' : ($hasItems ? 'unsupported' : 'trusted'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (($snapshot->isBuilding() || $snapshot->isIncomplete()) && $reasonCode !== null) {
|
if (! $hasItems && $reasonCode !== null) {
|
||||||
$contentState = 'missing_input';
|
$contentState = 'missing_input';
|
||||||
}
|
}
|
||||||
|
|
||||||
$freshnessState = match (true) {
|
$freshnessState = $isHistorical ? 'stale' : 'current';
|
||||||
$snapshot->isBuilding() => 'unknown',
|
|
||||||
$isHistorical => 'stale',
|
|
||||||
default => 'current',
|
|
||||||
};
|
|
||||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
||||||
$actionability = match (true) {
|
$actionability = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
$snapshot->isBuilding() => 'optional',
|
|
||||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||||
$freshnessState === 'stale' => 'optional',
|
$freshnessState === 'stale' => 'optional',
|
||||||
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
||||||
@ -111,30 +94,26 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
|||||||
$artifactExistence === 'historical_only' => [
|
$artifactExistence === 'historical_only' => [
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
'historical_only',
|
'historical_only',
|
||||||
$reason?->shortExplanation ?? 'This snapshot remains readable for history, but a newer complete snapshot is the current baseline truth.',
|
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
||||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label,
|
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||||
],
|
],
|
||||||
$artifactExistence === 'created_but_not_usable' => [
|
$artifactExistence === 'created_but_not_usable' => [
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
'created_but_not_usable',
|
'created_but_not_usable',
|
||||||
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
||||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||||
],
|
],
|
||||||
$contentState !== 'trusted' => [
|
$contentState !== 'trusted' => [
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
$contentState,
|
$contentState,
|
||||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||||
$supportState === 'limited_support'
|
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||||
? 'Support limited'
|
|
||||||
: BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
'trusted',
|
'trusted',
|
||||||
$hasItems
|
'Structured capture content is available for this baseline snapshot.',
|
||||||
? 'Structured capture content is available for this baseline snapshot.'
|
null,
|
||||||
: 'This empty baseline snapshot completed successfully and can still be used for compare.',
|
|
||||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,19 +147,6 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
|||||||
relatedRunId: null,
|
relatedRunId: null,
|
||||||
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||||
includePublicationDimension: false,
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,25 +270,6 @@ public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEn
|
|||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
: null,
|
: null,
|
||||||
includePublicationDimension: false,
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -452,24 +399,6 @@ public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
|||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||||
: null,
|
: null,
|
||||||
includePublicationDimension: true,
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -590,24 +519,6 @@ public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
|||||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||||
: null,
|
: null,
|
||||||
includePublicationDimension: true,
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -649,10 +560,6 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
|||||||
relatedRunId: (int) $run->getKey(),
|
relatedRunId: (int) $run->getKey(),
|
||||||
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
||||||
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
||||||
countDescriptors: array_merge(
|
|
||||||
$artifactEnvelope->operatorExplanation?->countDescriptors ?? [],
|
|
||||||
$this->runCountDescriptors($run),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -694,16 +601,18 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
|||||||
},
|
},
|
||||||
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
nextActionLabel: $reason?->firstNextStep()?->label
|
nextActionLabel: $this->nextActionLabel(
|
||||||
?? ($actionability === 'required'
|
$actionability,
|
||||||
|
$reason,
|
||||||
|
$actionability === 'required'
|
||||||
? 'Inspect the blocked run details before retrying'
|
? '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,
|
nextActionUrl: null,
|
||||||
relatedRunId: (int) $run->getKey(),
|
relatedRunId: (int) $run->getKey(),
|
||||||
relatedArtifactUrl: null,
|
relatedArtifactUrl: null,
|
||||||
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
||||||
countDescriptors: $this->runCountDescriptors($run),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -789,7 +698,6 @@ private function makeEnvelope(
|
|||||||
?int $relatedRunId,
|
?int $relatedRunId,
|
||||||
?string $relatedArtifactUrl,
|
?string $relatedArtifactUrl,
|
||||||
bool $includePublicationDimension,
|
bool $includePublicationDimension,
|
||||||
array $countDescriptors = [],
|
|
||||||
): ArtifactTruthEnvelope {
|
): ArtifactTruthEnvelope {
|
||||||
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
||||||
$dimensions = [
|
$dimensions = [
|
||||||
@ -823,7 +731,7 @@ classification: 'diagnostic',
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$draftEnvelope = new ArtifactTruthEnvelope(
|
return new ArtifactTruthEnvelope(
|
||||||
artifactFamily: $artifactFamily,
|
artifactFamily: $artifactFamily,
|
||||||
artifactKey: $artifactKey,
|
artifactKey: $artifactKey,
|
||||||
workspaceId: $workspaceId,
|
workspaceId: $workspaceId,
|
||||||
@ -845,30 +753,6 @@ classification: 'diagnostic',
|
|||||||
dimensions: array_values($dimensions),
|
dimensions: array_values($dimensions),
|
||||||
reason: $reason,
|
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(
|
private function dimension(
|
||||||
@ -886,31 +770,4 @@ classification: $classification,
|
|||||||
badgeState: $state,
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
<?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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?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,7 +35,6 @@ public static function firstSlice(): array
|
|||||||
'evidence_snapshots',
|
'evidence_snapshots',
|
||||||
'inventory_items',
|
'inventory_items',
|
||||||
'entra_groups',
|
'entra_groups',
|
||||||
'tenant_reviews',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
'queue' => env('DB_QUEUE', 'default'),
|
'queue' => env('DB_QUEUE', 'default'),
|
||||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 600),
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -48,7 +48,7 @@
|
|||||||
'driver' => 'beanstalkd',
|
'driver' => 'beanstalkd',
|
||||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 600),
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
'block_for' => 0,
|
'block_for' => 0,
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
@ -68,7 +68,7 @@
|
|||||||
'driver' => 'redis',
|
'driver' => 'redis',
|
||||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
'queue' => env('REDIS_QUEUE', 'default'),
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 600),
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
'block_for' => null,
|
'block_for' => null,
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
@ -126,8 +126,4 @@
|
|||||||
'table' => 'failed_jobs',
|
'table' => 'failed_jobs',
|
||||||
],
|
],
|
||||||
|
|
||||||
'lifecycle_invariants' => [
|
|
||||||
'retry_after_safety_margin' => (int) env('QUEUE_RETRY_AFTER_SAFETY_MARGIN', 30),
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,113 +13,6 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'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),
|
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||||
|
|
||||||
'supported_policy_types' => [
|
'supported_policy_types' => [
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,50 +24,7 @@ public function definition(): array
|
|||||||
'baseline_profile_id' => BaselineProfile::factory(),
|
'baseline_profile_id' => BaselineProfile::factory(),
|
||||||
'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
|
'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
|
||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
|
||||||
'completed_at' => now(),
|
|
||||||
'failed_at' => null,
|
|
||||||
'summary_jsonb' => ['total_items' => 0],
|
'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,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,292 +0,0 @@
|
|||||||
<?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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -150,8 +150,6 @@ ### Operations UX
|
|||||||
- **3-surface feedback**: Toast (immediate) → Progress widget (polling) → DB notification (terminal).
|
- **3-surface feedback**: Toast (immediate) → Progress widget (polling) → DB notification (terminal).
|
||||||
- **OperationRun lifecycle**: Service-owned transitions only via `OperationRunService` — no direct status writes.
|
- **OperationRun lifecycle**: Service-owned transitions only via `OperationRunService` — no direct status writes.
|
||||||
- **Idempotent creation**: Hash-based dedup with partial unique index.
|
- **Idempotent creation**: Hash-based dedup with partial unique index.
|
||||||
- **Lifecycle guarantees (Spec 160)**: Covered queued runs (`baseline_capture`, `baseline_compare`, `inventory_sync`, `policy.sync`, `policy.sync_one`, `entra_group_sync`, `directory_role_definitions.sync`, `backup_schedule_run`, `restore.execute`, `tenant.review_pack.generate`, `tenant.review.compose`, `tenant.evidence.snapshot.generate`) now have a config-backed lifecycle policy, direct failed-job bridges where declared, scheduled stale-run reconciliation, and UI freshness semantics for stale or automatically reconciled runs.
|
|
||||||
- **Queue timing invariant**: Covered job `timeout` values and policy `expected_max_runtime_seconds` must remain safely below queue `retry_after` with the configured safety margin. After changing queue lifecycle settings, restart workers (`php artisan queue:restart` in the target environment) so the new contract takes effect.
|
|
||||||
|
|
||||||
### Filament Standards
|
### Filament Standards
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-24 (added Baseline Compare Scope Guardrails & Ambiguity Guidance candidate)
|
**Last reviewed**: 2026-03-23 (added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -224,130 +224,20 @@ ### Humanized Diagnostic Summaries for Governance Operations
|
|||||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Operator Explanation Layer for Degraded / Partial / Suppressed Results
|
|
||||||
- **Type**: cross-cutting UX / domain semantics / operator clarity
|
|
||||||
- **Source**: product analysis 2026-03-23; direct follow-up to Spec 156 (`operator-outcome-taxonomy`)
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: Spec 156 improves status, outcome, and run vocabulary, but pure outcome taxonomy does not resolve a deeper operator-readability gap: the product stores truth across multiple semantic dimensions (execution, evaluation, reliability, coverage, recommended action) that are currently presented side by side without separation or explanation. Several governance and operational surfaces — Baseline Compare, Baseline Capture, Operation Run detail, Tenant Reviews, evidence-dependent results — show technically correct but operatorisch schwer lesbare combinations such as `Run finished` + `Counts 0` + `Why no findings: evidence_capture_incomplete`. These force operators to synthesize whether: the run was technically successful, the result is trustworthy, findings were genuinely absent, data was missing, assignments were ambiguous, or follow-up is required. The product preserves truth better than it explains truth. Distinct truth dimensions collapse into shared reading surfaces — count blocks that look like complete results when they show only a subset, `0 findings` that reads as reassurance when evaluation was incomplete, reason codes that serve as primary operator explanation when they are diagnostic material. Enterprise operators need the UI to make without JSON and without implicit product knowledge clear: what happened, how reliable the result is, and what to do next.
|
|
||||||
- **Why it matters**: This is the missing interpretation layer between the outcome taxonomy foundation (Spec 156) and the operator's actual decision. Without it, the product remains formally correct but interpretively weak on its highest-trust governance surfaces. Concrete consequences: operators read `0 findings` as all-clear when evaluation was constrained by evidence gaps; support and debug cost stays high because run-detail pages require JSON or expert knowledge; reason codes leak into operator surfaces as primary explanation; semantic inconsistencies multiply with each new governance/evidence/review feature; enterprise readiness suffers despite strong internal modelling. The gap is systemic — not a single-surface fix — because the same class of problem (execution succeeded, but result is degraded/partial/suppressed) recurs across baselines, reviews, evidence, monitoring, and future governance domains.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- **R1 — Multi-dimensional outcome model in the UI**: operator surfaces must separate result communication into at least five distinguishable axes: execution status, evaluation result, reliability/confidence/trustworthiness, coverage/completeness, recommended action. Not every page needs all five equally prominent, but the UI model must keep them distinct instead of collapsing them into a single badge or count block.
|
|
||||||
- **R2 — Primary operator explanation over raw reason codes**: every relevant reason code or reason cluster gets an operator-readable title, clear meaning description, impact explanation, and next-step guidance. Technical reason codes remain stored and diagnostically usable but stop being the primary explanation. Example: not `evidence_capture_incomplete` as the headline, but "Vergleich unvollständig bewertet — 16 Evidence-Lücken verhinderten vollständige Bewertung."
|
|
||||||
- **R3 — Semantically unambiguous counts**: count blocks must not imply completeness when they represent only a subset. The spec defines which counts are execution counts, which are evaluation-output counts, which are data-quality/completeness counts, and how suppressed/incomplete/degraded states surface. `0 findings` when evidence gaps exist must not read as reassurance.
|
|
||||||
- **R4 — "Why no findings / no results / no report" explanation patterns**: a shared pattern for explaining absent output, distinguishing between genuinely no issues found, no results producible due to missing inputs, results suppressed due to unreliable evidence, prerequisite failure, viewer/rendering limitation, pending calculation, and intentionally unpublished.
|
|
||||||
- **R5 — Reliability/confidence visibility**: compare-, review-, and evidence-dependent results must show whether the result is trustworthy, limited in trustworthiness, incomplete, diagnostically usable but not decision-grade, or not usable. Terms like fidelity, coverage, partial, meta, full, degraded get a defined reading direction instead of appearing loosely side by side.
|
|
||||||
- **R6 — Semantically derived next-step guidance**: the next-step surface must distinguish between no action needed, observe, re-run, fix prerequisite, update inventory/sync, check evidence gaps, manually validate, and escalate — derived from cause and severity, not a generic fallback phrase.
|
|
||||||
- **R7 — Diagnostics available but not dominant**: technical raw data, run context, and reason codes remain accessible for debugging but the default reading path leads with meaning and action: what happened → how reliable → why it looks this way → what to do → then technical details.
|
|
||||||
- **R8 — Shared explanation patterns, not page-by-page special logic**: define reusable cross-domain explanation patterns: completed but degraded, completed but incomplete, no output because suppressed, no output because insufficient evidence, partial result with fallback, output exists but not publication-ready, viewer limitation vs source limitation, prerequisite missing vs execution failed.
|
|
||||||
- **R9 — Baseline Compare as reference case**: Baseline Compare is the golden path for this spec. The case where compare finishes technically, driftResults = 0, counts = 0, evidence gaps exist, and "why no findings" is currently reason-code-heavy must become immediately understandable without additional product knowledge.
|
|
||||||
- **R10 — No contradiction between top-level state and detail interpretation**: top-level states (run finished, completed with follow-up, succeeded, partially succeeded) must not conflict with the underlying result surfaces. Technically successful but fachlich limited results must read as a consistent composite.
|
|
||||||
- **Primary adoption surfaces**:
|
|
||||||
- Operation Run detail pages
|
|
||||||
- Baseline Capture
|
|
||||||
- Baseline Compare
|
|
||||||
- Baseline Snapshot / Compare truth surfaces
|
|
||||||
- Tenant Reviews / review generation states
|
|
||||||
- Evidence-dependent result surfaces
|
|
||||||
- Run summaries, state banners, next-step texts, count summaries
|
|
||||||
- Reason code presentation / translation layer
|
|
||||||
- Count semantics for runs with suppressed / incomplete / degraded results
|
|
||||||
- **Optional extension surfaces**: restore/backup operation outcomes, prerequisite/readiness/missing-input explanation surfaces, alerts that rely on degraded semantics
|
|
||||||
- **UX/semantics principles**:
|
|
||||||
- Truth first, but operator-first wording — truth is not softened but expressed in readable operator language
|
|
||||||
- No false reassurance — `0 findings` or `no errors` must not read as all-clear when evaluation was constrained
|
|
||||||
- Separate execution from confidence — a technically successful run can still produce a limited-confidence result
|
|
||||||
- Default path before diagnostics — the normal reading flow answers meaning and action first, then technical detail
|
|
||||||
- Consistent semantics across domains — the same kind of state must not be named or explained differently in baselines, reviews, and evidence
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: explanation dimension definitions, outcome-to-operator-message mapping, shared explanation pattern library, reference implementation for Baseline Compare / Operation Run detail / Baseline Capture, count semantics rules, reliability visibility rules, next-step guidance patterns, reason code presentation hierarchy
|
|
||||||
- **Out of scope**: queue/retry/job-runtime model redesign, large-scale raw data model restructuring (existing run-context data should suffice), purely visual polishing without semantic improvement, reason code elimination (they remain for diagnostics), complete redesign of every product page, compliance/governance domain logic changes
|
|
||||||
- **Phased delivery**:
|
|
||||||
- Phase 1 — Semantics model: define explanation dimensions, outcome vs reliability vs coverage vs action separation, reason-code-to-operator-message mapping, shared explanation pattern library
|
|
||||||
- Phase 2 — Reference implementation: Operation Run detail page, Baseline Compare, Baseline Capture
|
|
||||||
- Phase 3 — Extension: Tenant Reviews, evidence/report surfaces, further degraded/prerequisite-heavy flows
|
|
||||||
- **Success criteria**:
|
|
||||||
- An operator can understand degraded/suppressed results without JSON: what was executed, whether results were produced, how reliable they are, why nothing was generated, and what to do next
|
|
||||||
- Baseline Compare with 0 findings + evidence gaps shows no implicit all-clear
|
|
||||||
- Reason codes remain technically usable but no longer serve as sole primary explanation
|
|
||||||
- Count surfaces are semantically unambiguous, distinguishing output counts from completeness/reliability signals
|
|
||||||
- At least one shared explanation pattern is reused across domains rather than landing as one-off page logic
|
|
||||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy — shared vocabulary foundation), Spec 157 (reason-code-translation — humanized code labels), Spec 158 (artifact-truth-semantics — governance artifact truth model), Spec 159 (baseline-snapshot-truth — baseline truth surfaces)
|
|
||||||
- **Related specs / candidates**: Governance Operator Outcome Compression (governance-artifact list/scan compression — complementary, narrower scope), Humanized Diagnostic Summaries for Governance Operations (governance run-detail explainability — complementary, narrower scope), Baseline Capture Truthful Outcomes (capture-specific precondition and outcome hardening), OperationRun Humanization & Diagnostics Boundary (run-detail operator-first hierarchy)
|
|
||||||
- **Strategic importance**: This is not UI polish but an enterprise-readability and governance-trust layer. As the roadmap expands toward baseline governance, findings workflow, exceptions/risk acceptance, stored reports, tenant reviews, evidence packs, and MSP portfolio views, the cost of not having a systematic explanation layer increases with every new feature. The longer this is deferred, the more governance surfaces ship with locally invented explanation patterns that diverge instead of converging. This candidate is strategically anschlussfähig rather than locally reactive.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
> **Operator Truth Initiative — Sequencing Note**
|
> **Operator Truth Initiative — Sequencing Note**
|
||||||
>
|
>
|
||||||
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
||||||
>
|
>
|
||||||
> **Recommended order:**
|
> **Recommended order:**
|
||||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other candidates will invent local vocabularies that diverge.
|
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
||||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||||
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
|
> 4. **Governance Operator Outcome Compression** — applies the foundation to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
||||||
> 5. **Governance Operator Outcome Compression** — applies the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
> 5. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
||||||
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4.
|
|
||||||
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
|
||||||
>
|
>
|
||||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
|
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The compression follow-up is then what turns that engine into a scanable operator cockpit before more governance features land. Gate unification remains highly valuable, but it is a neighboring hardening lane rather than the immediate follow-up needed to make governance truth semantics feel product-ready.
|
||||||
>
|
>
|
||||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. The Operator Explanation Layer defines the shared interpretation semantics and explanation patterns. Governance operator outcome compression is a UI-information-architecture adoption slice across governance artifact surfaces. Humanized diagnostic summaries are an adoption slice for governance run-detail pages. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. Governance operator outcome compression is a UI-information-architecture adoption slice across governance surfaces. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
||||||
|
|
||||||
### Operation Run Active-State Visibility & Stale Escalation
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: product/operator visibility analysis 2026-03-24; operation-run lifecycle and stale-state communication review
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: TenantPilot already has the core lifecycle foundations for `OperationRun` records: canonical run modelling, workspace-level run viewing, per-type lifecycle policies, freshness and stale detection, overdue-run reconciliation, terminal notifications, and tenant-local active-run hints. The gap is no longer primarily lifecycle logic. The gap is that the same lifecycle truth is not communicated with enough consistency and urgency across the operator surfaces that matter. A run can be past its expected lifecycle or likely stuck while still looking like normal active work on tenant-local cards or dashboard attention surfaces. Operators then have to drill into the canonical run viewer to learn that the run is no longer healthy, which weakens monitoring trust and makes hanging work look deceptively normal.
|
|
||||||
- **Why it matters**: This is an observability and operator-trust problem in a core platform layer, not visual polish. If `queued` or `running` remains visually neutral after lifecycle expectations have been exceeded, operators receive false reassurance, support burden rises, queue or worker issues are discovered later, and the product trains users that active-state surfaces are not trustworthy without manual drill-down. As TenantPilot pushes more governance, review, drift, and evidence workflows through `OperationRun`, stale active work must never read as healthy progress anywhere in the product.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- Reuse the existing lifecycle, freshness, and reconciliation truth to define one **cross-surface active-state presentation contract** that distinguishes at least: `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active`
|
|
||||||
- Upgrade **tenant-local active-run and progress cards** so stale or past-lifecycle runs are visibly and linguistically different from healthy active work instead of reading as neutral `Queued • 1d` or `Running • 45m`
|
|
||||||
- Upgrade **tenant dashboard and attention surfaces** so they distinguish between healthy activity, activity that needs attention, and activity that is likely stale or hanging
|
|
||||||
- Upgrade the **workspace operations list / monitoring views** so problematic active runs become scanable at row level instead of being discoverable only through subtle secondary text or by opening each run
|
|
||||||
- Preserve the **workspace-level canonical run viewer** as the authoritative diagnostic surface, while ensuring compact and summary surfaces do not contradict it
|
|
||||||
- Apply a **same meaning, different density** rule: tenant cards, dashboard signals, list rows, and run detail may vary in information density, but not in lifecycle meaning or operator implication
|
|
||||||
- **Core product principles**:
|
|
||||||
- Execution lifecycle, freshness, and operator attention are related but not identical dimensions
|
|
||||||
- Compact surfaces may compress information, but must not downplay stale or hanging work
|
|
||||||
- The workspace-level run viewer remains canonical; this candidate improves visibility, not source-of-truth ownership
|
|
||||||
- Stale or past-lifecycle work must not look like healthy progress anywhere
|
|
||||||
- **Candidate requirements**:
|
|
||||||
- **R1 Cross-surface lifecycle visibility**: all relevant active-run surfaces can distinguish at least normal active, past-lifecycle active, stale/likely stuck, and terminal states
|
|
||||||
- **R2 Tenant active-run escalation**: tenant-local active-run and progress cards visibly and linguistically escalate stale or past-lifecycle work
|
|
||||||
- **R3 Dashboard attention separation**: dashboard and attention surfaces distinguish healthy activity from concerning active work
|
|
||||||
- **R4 Operations-list scanability**: the workspace operations list makes problematic active runs quickly identifiable without requiring row-by-row interpretation or drill-in
|
|
||||||
- **R5 Canonical viewer preservation**: the workspace-level run viewer remains the detailed and authoritative truth surface
|
|
||||||
- **R6 No hidden contradiction**: a run that is clearly stale or lifecycle-problematic on the detail page must not appear as ordinary active work on tenant or monitoring surfaces
|
|
||||||
- **R7 Existing lifecycle logic reuse**: the candidate reuses current freshness, lifecycle, and reconciliation semantics instead of introducing parallel UI-only heuristics
|
|
||||||
- **R8 No new backend lifecycle semantics unless necessary**: new status values or model-level lifecycle semantics are out unless the current semantics cannot carry the presentation contract cleanly
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: tenant-local active-run cards, tenant dashboard activity and attention surfaces, workspace operations list and monitoring surfaces, shared lifecycle presentation contract for active-state visibility, copy and visual semantics needed to distinguish healthy active work from stale active work
|
|
||||||
- **Out of scope**: retry, cancel, force-fail, or reconcile-now operator actions; queue or worker architecture changes; new scheduler or timeout engines; new notification channels; a full operations-hub redesign; cross-workspace fleet monitoring; introducing new `OperationRun` status values unless existing semantics are proven insufficient
|
|
||||||
- **Acceptance points**:
|
|
||||||
- An active run outside its lifecycle expectation is visibly distinct from healthy active work on tenant-local progress cards
|
|
||||||
- Tenant dashboard and attention surfaces clearly represent the difference between healthy activity and active work that needs attention
|
|
||||||
- The workspace operations list makes stale or problematic active runs quickly scanable
|
|
||||||
- No surface shows a run as stale/problematic while another still presents it as normal active work
|
|
||||||
- The canonical workspace-level run viewer remains the most detailed lifecycle and diagnosis surface
|
|
||||||
- Existing lifecycle and freshness logic is reused rather than duplicated into local UI-only state rules
|
|
||||||
- No retry, cancel, or force-fail intervention actions are introduced by this candidate
|
|
||||||
- Fresh active runs do not regress into false escalation
|
|
||||||
- Tenant and workspace scoping remain correct; no cross-tenant leakage appears in cards or monitoring views
|
|
||||||
- Regression coverage includes fresh and stale active runs across tenant and workspace surfaces
|
|
||||||
- **Suggested test matrix**:
|
|
||||||
- queued run within expected lifecycle
|
|
||||||
- queued run well past expected lifecycle
|
|
||||||
- running run within expected lifecycle
|
|
||||||
- running run well past expected lifecycle
|
|
||||||
- run becomes terminal while an operator navigates between tenant and run-detail surfaces
|
|
||||||
- stale state on detail surface remains semantically stale on tenant and monitoring surfaces
|
|
||||||
- fresh active runs do not escalate falsely
|
|
||||||
- tenant-scoped surfaces never show another tenant's runs
|
|
||||||
- operations list clearly surfaces problematic active runs for fast scan
|
|
||||||
- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces
|
|
||||||
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Operator Explanation Layer for Degraded / Partial / Suppressed Results (adjacent but broader interpretation layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane)
|
|
||||||
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Baseline Snapshot Fidelity Semantics
|
### Baseline Snapshot Fidelity Semantics
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
@ -359,50 +249,6 @@ ### Baseline Snapshot Fidelity Semantics
|
|||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Baseline Compare Scope Guardrails & Ambiguity Guidance
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: product/operator-trust analysis 2026-03-24 — baseline compare ambiguity and scope communication review
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: Baseline Compare currently produces confusing results when the baseline snapshot contains generic Microsoft/Intune default objects or subjects with non-unique display names. Not-uniquely-matchable subjects are surfaced as "duplicates" in the UI, implying operator error even when the root cause is provider-side generic naming. Separate truth dimensions — identity confidence (could subjects be matched uniquely?), evidence fidelity (how deep was the compare?), and result trust (how reliable is the overall outcome?) — are collapsed into ambiguous operator signals such as `No Drift Detected` + `Limited confidence` + `Fidelity: Meta` without explaining whether the issue is baseline scope, generic names, limited compare capability, or actual tenant drift.
|
|
||||||
- **Why it matters**: Operators reading compare results cannot distinguish between "everything is fine" and "we couldn't compare reliably." False reassurance (`No Drift Detected` at limited confidence) and false blame ("rename your duplicates" when subjects are provider-managed defaults) erode trust in the product's core governance promise. MSP operators managing baselines for multiple tenants need clear signals about what they can rely on and what requires scope curation — not academic-sounding fidelity labels next to misleading all-clear verdicts.
|
|
||||||
- **Product decision**: Baseline Compare in V1 is designed for uniquely identifiable, intentionally curated governance policies — not for arbitrary tenant-wide default/enrollment/generic standard objects. When compare subjects cannot be reliably matched due to generic names or weak identity, TenantPilot treats this primarily as a scope/suitability problem of the current baseline content and a transparency/guidance topic in the product — not as an occasion for building a large identity classification engine.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- **Compare wording correction**: replace pausal "rename your duplicates" messaging with neutral, scope-aware language explaining that some subjects cannot be matched uniquely by the current compare strategy, that this can happen with generic or provider-managed display names, and that the visible result is therefore only partially reliable
|
|
||||||
- **Scope guidance on compare surfaces**: make explicit that Baseline Compare is for curated governance-scope policies, not for every tenant content. Baseline/capture surfaces must frame Golden Master as a deliberate governance scope, not an unfiltered tenant full-extract
|
|
||||||
- **Actionable next-step guidance**: when ambiguity is detected, direct operators to review baseline profile scope, remove non-uniquely-identifiable subjects from governance scope, and re-run compare after scope cleanup — not to pauschal rename everything
|
|
||||||
- **Meta-fidelity and limited-confidence separation**: separate identity-matchability, evidence/compare-depth, and overall result trustworthiness in the communication so operators can tell which dimension is limited and why
|
|
||||||
- **Conservative framing for problematic V1 domains**: for known generically-named compare domains, allow conservative copy such as "not ideal for baseline compare," "limited compare confidence," "review scope before relying on result" — without introducing deep system-managed default detection
|
|
||||||
- **Evidence/snapshot surface consistency**: terms like `Missing input`, `Not collected yet`, `Limited confidence` must not read as runtime errors when the actual issue is scope suitability
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: compare result warning copy, limited-confidence explanation, next-step guidance, baseline profile/capture scope framing, conservative guardrail copy for problematic domains, evidence/snapshot surface term consistency
|
|
||||||
- **Out of scope**: comprehensive Microsoft default policy detection, new global identity strategy engine, object-class-based system-managed vs user-managed classification, new deep fidelity matrix for all policy types, automatic exclusion or repair of problematic baseline items, compare engine architecture redesign
|
|
||||||
- **UX direction**:
|
|
||||||
- **Bad (current)**: "32 policies share the same display name" / "Please rename the duplicates" / `No Drift Detected` despite `Limited confidence`
|
|
||||||
- **Good (target)**: neutral, honest, operator-actionable — e.g. "Some policies in the current baseline scope cannot be matched uniquely by the current compare strategy. This often happens with generic or provider-managed display names. Review your baseline scope and keep only uniquely identifiable governance policies before relying on this result."
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- AC1: ambiguous-match UI no longer pauschal blames operators for duplicates without explaining scope/generic-name context
|
|
||||||
- AC2: limited-trust compare results are visually and linguistically distinguishable from fully reliable results; operators can tell the result is technically complete but content-wise only partially reliable
|
|
||||||
- AC3: primary V1 guidance directs operators to baseline-scope review/cleanup and re-compare — not to pauschal rename or assume tenant misconfiguration
|
|
||||||
- AC4: baseline/compare surfaces convey that Golden Master is a curated governance scope
|
|
||||||
- AC5: `No Drift Detected` at `Limited confidence` is understandable as not-fully-trustworthy, not as definitive all-clear
|
|
||||||
- **Tests / validation**:
|
|
||||||
- Warning text for ambiguous matches uses neutral scope/matchability wording
|
|
||||||
- Next-step guidance points to baseline scope review, not pauschal rename
|
|
||||||
- `Limited confidence` + `No Drift Detected` is not presented as unambiguous all-clear
|
|
||||||
- Baseline/compare surfaces include governance-scope hint
|
|
||||||
- Known compare gaps do not produce misleading "user named everything wrong" messaging
|
|
||||||
- Existing compare status/outcome logic remains intact
|
|
||||||
- No new provider-specific special classification logic required for consistent UI
|
|
||||||
- **Risks**:
|
|
||||||
- R1: pure copy changes alone might address the symptom too weakly → mitigation: include scope/guidance framing, not just single-sentence edits
|
|
||||||
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
|
|
||||||
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
|
|
||||||
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
|
|
||||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Operator Explanation Layer candidate (consumes explanation patterns), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation)
|
|
||||||
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Operator Explanation Layer candidate, Governance Operator Outcome Compression candidate
|
|
||||||
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Restore Lifecycle Semantic Clarity
|
### Restore Lifecycle Semantic Clarity
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||||
@ -423,70 +269,6 @@ ### Inventory, Provider & Operability Semantics
|
|||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
||||||
- **Priority**: medium
|
- **Priority**: medium
|
||||||
|
|
||||||
### Tenant Operational Readiness & Status Truth Hierarchy
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: product/operator-trust analysis 2026-03-24 — tenant-facing status presentation and source-of-truth hierarchy review
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: Tenant-facing surfaces expose multiple parallel status domains — lifecycle, legacy app status, provider connection state, provider health, verification report availability, RBAC readiness, and recent run evidence — without a clear hierarchy. Some domains are valid but poorly explained; at least one (`Tenant.app_status`) is legacy/orphaned truth still presented as if authoritative. The combined presentation does not answer the operator's actual question: "Can I trust this tenant right now, and is any action required?" Instead, operators must mentally reconcile six semi-related status fragments with no clear precedence, creating three distinct risks: legacy truth leakage (dead fields displayed as current truth), state collision without hierarchy (valid domains answering different questions but appearing to compete), and support/trust burden (operators asking why a tenant is "active" yet also "unknown," or provider is "connected" but health is "unknown," even when operational evidence proves usability).
|
|
||||||
- **Why it matters**: TenantPilot is moving further into governance, evidence, reviews, drift, and portfolio visibility. As the product becomes more compliance- and operations-centric, source-of-truth quality on core tenant surfaces becomes more important, not less. If left unresolved: support load scales with tenant count, MSP operators learn to distrust or ignore status surfaces, future governance views inherit ambiguous foundations, and headline truth across baselines, evidence, findings, and reviews remains semantically inconsistent. For an enterprise governance platform, this is a product-truth and operator-confidence issue, not just a wording problem.
|
|
||||||
- **Core insight**: Not every status belongs at the same level. The product currently exposes multiple truths belonging to different semantic layers:
|
|
||||||
- **Layer 1 — Headline operator truth**: "Can I work with this tenant, and is action required?"
|
|
||||||
- **Layer 2 — Domain truth**: lifecycle, provider consent/access, verification, RBAC, recent operational evidence
|
|
||||||
- **Layer 3 — Diagnostic truth**: low-level or specialized states useful for investigation, not competing with headline summary
|
|
||||||
- **Layer 4 — Legacy/orphaned truth**: stale, weakly maintained, deprecated, or no longer authoritative fields
|
|
||||||
- **Proposed direction**:
|
|
||||||
- **Headline readiness model**: define a single tenant-facing readiness summary answering whether the tenant is usable and whether action is needed. Concise operator-facing states such as: Ready, Ready with follow-up, Limited, Action required, Not ready.
|
|
||||||
- **Source-of-truth hierarchy**: every tenant-facing status shown on primary surfaces classified as authoritative, derived, diagnostic, or legacy. Authoritative sources: lifecycle, canonical provider consent/access state, canonical verification state, RBAC readiness, recent operational evidence as supporting evidence.
|
|
||||||
- **Domain breakdown beneath headline**: each supporting domain exists in a clearly subordinate role — lifecycle, provider access/consent, verification state, RBAC readiness, recent operational evidence.
|
|
||||||
- **Action semantics clarity**: primary surfaces must distinguish between no action needed, recommended follow-up, required action, and informational only.
|
|
||||||
- **Verification semantics**: UI must distinguish between never verified, verification unavailable, verification stale, verification failed, and verified but follow-up recommended. These must not collapse into ambiguous "unknown" messaging.
|
|
||||||
- **Provider truth clarity**: provider access state must clearly differentiate access configured/consented, access verified, access usable but not freshly verified, access blocked or failed.
|
|
||||||
- **RBAC semantics clarity**: RBAC readiness must clearly state whether write actions are blocked, without implying that all tenant operations are unavailable when read-only operations still function.
|
|
||||||
- **Operational evidence handling**: recent successful operations may contribute supporting confidence, but must not silently overwrite or replace distinct provider verification truth.
|
|
||||||
- **Legacy truth removal/demotion**: fields that are legacy, orphaned, or too weak to serve as source of truth must not remain prominent on tenant overview surfaces. Explicit disposition for orphaned fields like `Tenant.app_status`.
|
|
||||||
- **Reusable semantics model**: the resulting truth hierarchy and readiness model must be reusable across tenant list/detail and future higher-level governance surfaces.
|
|
||||||
- **Functional requirements**:
|
|
||||||
- FR1 — Single tenant-facing readiness summary answering operability and action-needed
|
|
||||||
- FR2 — Every primary-surface status classified as authoritative, derived, diagnostic, or legacy
|
|
||||||
- FR3 — Legacy/orphaned fields not displayed as current operational truth on primary surfaces
|
|
||||||
- FR4 — No peer-level contradiction on primary surfaces
|
|
||||||
- FR5 — Verification semantics explicitly distinguishing not yet verified / unavailable / stale / failed / verified with follow-up
|
|
||||||
- FR6 — Provider access state clearly differentiating configured, verified, usable-but-not-fresh, blocked
|
|
||||||
- FR7 — RBAC readiness clarifying write-block vs full-block
|
|
||||||
- FR8 — Operational evidence supportive but not substitutive for verification truth
|
|
||||||
- FR9 — Actionability clarity on primary surfaces
|
|
||||||
- FR10 — Reusable semantics for future governance surfaces
|
|
||||||
- **UX/product rules**:
|
|
||||||
- Same question, one answer: if several states contribute to the same operator decision, present one synthesized answer first
|
|
||||||
- Summary before diagnostics: operator summary belongs first, domain detail underneath or behind expansion
|
|
||||||
- "Unknown" is not enough: must not substitute for not checked, no report stored, stale result, legacy field, or unavailable artifact
|
|
||||||
- Evidence is supportive, not substitutive: successful operations reinforce confidence but do not replace explicit verification
|
|
||||||
- Lifecycle is not health: active does not mean provider access is verified or write operations are ready
|
|
||||||
- Health is not onboarding history: historical onboarding verification is not automatically current operational truth
|
|
||||||
- **Likely surfaces affected**:
|
|
||||||
- Primary: tenant detail/overview page, tenant list presentation, tenant widgets/cards related to verification and recent operations, provider-related status presentation within tenant views, helper text/badge semantics on primary tenant surfaces
|
|
||||||
- Secondary follow-up: provider connection detail pages, onboarding completion/follow-up states, future portfolio rollup views
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: truth hierarchy definition, headline readiness model, tenant detail/overview presentation rules, provider state presentation rules on tenant surfaces, verification semantics on tenant surfaces, RBAC relationship to readiness, role of recent operational evidence, legacy truth cleanup on primary tenant surfaces
|
|
||||||
- **Out of scope**: redesigning OperationRun result semantics in general, revisiting every badge/helper in the product, evidence/reporting semantics outside tenant readiness, changing onboarding lifecycle requirements unless directly necessary for truth consistency, provider architecture overhaul, full data-model cleanup beyond what is needed to remove legacy truth from primary surfaces, full badge taxonomy standardization everywhere, color palette / visual design overhaul, findings severity or workflow semantics, broad IA/navigation redesign, portfolio-level rollup semantics beyond stating compatibility goals
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- AC1: Primary tenant surfaces present a single operator-facing readiness truth rather than several equal-weight raw statuses
|
|
||||||
- AC2: Lifecycle, provider access, verification, RBAC, and operational evidence shown with explicit semantic roles and no ambiguous precedence
|
|
||||||
- AC3: Legacy/orphaned status fields no longer presented as live operational truth on primary surfaces
|
|
||||||
- AC4: System clearly differentiates not yet verified / verification unavailable / stale / failed
|
|
||||||
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
|
|
||||||
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
|
|
||||||
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
|
|
||||||
- **Boundary with Tenant App Status False-Truth Removal**: That candidate is a quick, bounded removal of the single most obvious legacy truth field (`Tenant.app_status`). This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The false-truth removal is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
|
|
||||||
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
|
||||||
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
|
||||||
- **Boundary with Operator Explanation Layer**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
|
||||||
- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
|
||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
|
||||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
|
||||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Exception / Risk-Acceptance Workflow for Findings
|
### Exception / Risk-Acceptance Workflow for Findings
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
||||||
|
|||||||
@ -29,25 +29,6 @@
|
|||||||
'badge_fidelity' => 'Fidelity: :level',
|
'badge_fidelity' => 'Fidelity: :level',
|
||||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
||||||
'evidence_gap_details_heading' => 'Evidence gap details',
|
|
||||||
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, or subject key before falling back to raw diagnostics.',
|
|
||||||
'evidence_gap_search_label' => 'Search gap details',
|
|
||||||
'evidence_gap_search_placeholder' => 'Search by reason, policy type, or subject key',
|
|
||||||
'evidence_gap_search_help' => 'Filter matches across reason, policy type, and subject key.',
|
|
||||||
'evidence_gap_bucket_help' => 'Reason summaries stay separate from the detailed row table below.',
|
|
||||||
'evidence_gap_reason' => 'Reason',
|
|
||||||
'evidence_gap_reason_affected' => ':count affected',
|
|
||||||
'evidence_gap_reason_recorded' => ':count recorded',
|
|
||||||
'evidence_gap_reason_missing_detail' => ':count missing detail',
|
|
||||||
'evidence_gap_missing_details_title' => 'Detailed rows were not recorded for this run',
|
|
||||||
'evidence_gap_missing_details_body' => 'Evidence gaps were counted for this compare run, but subject-level detail was not stored. Review the raw diagnostics below or rerun the comparison for fresh detail.',
|
|
||||||
'evidence_gap_missing_reason_body' => ':count affected subjects were counted for this reason, but detailed rows were not recorded for this run.',
|
|
||||||
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
|
|
||||||
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
|
|
||||||
'evidence_gap_policy_type' => 'Policy type',
|
|
||||||
'evidence_gap_subject_key' => 'Subject key',
|
|
||||||
'evidence_gap_table_empty_heading' => 'No recorded gap rows match this view',
|
|
||||||
'evidence_gap_table_empty_description' => 'Adjust the current search or filters to review other affected subjects.',
|
|
||||||
|
|
||||||
// Comparing state
|
// Comparing state
|
||||||
'comparing_indicator' => 'Comparing…',
|
'comparing_indicator' => 'Comparing…',
|
||||||
|
|||||||
@ -1,170 +0,0 @@
|
|||||||
(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window.opsUxProgressWidgetPoller === 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.opsUxProgressWidgetPoller = function opsUxProgressWidgetPoller() {
|
|
||||||
return {
|
|
||||||
timer: null,
|
|
||||||
activeSinceMs: null,
|
|
||||||
fastUntilMs: null,
|
|
||||||
teardownObserver: null,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
|
||||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
||||||
|
|
||||||
this.onNavigated = this.onNavigated.bind(this);
|
|
||||||
window.addEventListener('livewire:navigated', this.onNavigated);
|
|
||||||
|
|
||||||
this.teardownObserver = new MutationObserver(() => {
|
|
||||||
if (!this.$el || this.$el.isConnected !== true) {
|
|
||||||
this.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
this.schedule(0);
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.stop();
|
|
||||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
||||||
window.removeEventListener('livewire:navigated', this.onNavigated);
|
|
||||||
|
|
||||||
if (this.teardownObserver) {
|
|
||||||
this.teardownObserver.disconnect();
|
|
||||||
this.teardownObserver = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isModalOpen() {
|
|
||||||
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
|
||||||
},
|
|
||||||
|
|
||||||
isPaused() {
|
|
||||||
if (document.hidden === true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isModalOpen()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.$el || this.$el.isConnected !== true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
|
|
||||||
onVisibilityChange() {
|
|
||||||
if (!this.isPaused()) {
|
|
||||||
this.schedule(0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onNavigated() {
|
|
||||||
if (!this.isPaused()) {
|
|
||||||
this.schedule(0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
activeAgeSeconds() {
|
|
||||||
if (this.activeSinceMs === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor((Date.now() - this.activeSinceMs) / 1000);
|
|
||||||
},
|
|
||||||
|
|
||||||
nextIntervalMs() {
|
|
||||||
if (this.$wire?.disabled === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.$wire?.hasActiveRuns !== true) {
|
|
||||||
this.activeSinceMs = null;
|
|
||||||
return 30_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeSinceMs === null) {
|
|
||||||
this.activeSinceMs = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (this.fastUntilMs && now < this.fastUntilMs) {
|
|
||||||
return 1_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
const age = this.activeAgeSeconds();
|
|
||||||
|
|
||||||
if (age < 10) {
|
|
||||||
return 1_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (age < 60) {
|
|
||||||
return 5_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 10_000;
|
|
||||||
},
|
|
||||||
|
|
||||||
async tick() {
|
|
||||||
if (this.isPaused()) {
|
|
||||||
this.schedule(2_000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.$wire.refreshRuns();
|
|
||||||
} catch (error) {
|
|
||||||
const isCancellation = Boolean(
|
|
||||||
error &&
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error.status === null &&
|
|
||||||
error.body === null &&
|
|
||||||
error.json === null &&
|
|
||||||
error.errors === null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isCancellation) {
|
|
||||||
console.warn('Ops UX widget refreshRuns failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = this.nextIntervalMs();
|
|
||||||
|
|
||||||
if (next === null) {
|
|
||||||
this.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.schedule(next);
|
|
||||||
},
|
|
||||||
|
|
||||||
schedule(delayMs) {
|
|
||||||
this.stop();
|
|
||||||
|
|
||||||
const delay = Math.max(0, Number(delayMs ?? 0));
|
|
||||||
|
|
||||||
this.timer = setTimeout(() => {
|
|
||||||
this.tick().catch(() => {});
|
|
||||||
}, delay);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.__tenantpilotUnhandledRejectionLoggerApplied) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
|
||||||
|
|
||||||
const recentKeys = new Map();
|
|
||||||
|
|
||||||
const cleanupRecentKeys = (nowMs) => {
|
|
||||||
for (const [key, timestampMs] of recentKeys.entries()) {
|
|
||||||
if (nowMs - timestampMs > 5_000) {
|
|
||||||
recentKeys.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeReason = (value, depth = 0) => {
|
|
||||||
if (depth > 3) {
|
|
||||||
return '[max-depth-reached]';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Error) {
|
|
||||||
return {
|
|
||||||
type: 'Error',
|
|
||||||
name: value.name,
|
|
||||||
message: value.message,
|
|
||||||
stack: value.stack,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.slice(0, 10).map((item) => normalizeReason(item, depth + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
const result = {};
|
|
||||||
const allowedKeys = [
|
|
||||||
'message',
|
|
||||||
'stack',
|
|
||||||
'name',
|
|
||||||
'type',
|
|
||||||
'status',
|
|
||||||
'body',
|
|
||||||
'json',
|
|
||||||
'errors',
|
|
||||||
'reason',
|
|
||||||
'code',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of allowedKeys) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
||||||
result[key] = normalizeReason(value[key], depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(result).length > 0) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stringTag = Object.prototype.toString.call(value);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: stringTag,
|
|
||||||
value: String(value),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toStableJson = (payload) => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(payload);
|
|
||||||
} catch {
|
|
||||||
return JSON.stringify({
|
|
||||||
source: payload.source,
|
|
||||||
href: payload.href,
|
|
||||||
timestamp: payload.timestamp,
|
|
||||||
reason: '[unserializable]',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (event) => {
|
|
||||||
const payload = {
|
|
||||||
source: 'window.unhandledrejection',
|
|
||||||
href: window.location.href,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
reason: normalizeReason(event.reason),
|
|
||||||
};
|
|
||||||
|
|
||||||
const payloadJson = toStableJson(payload);
|
|
||||||
const nowMs = Date.now();
|
|
||||||
|
|
||||||
cleanupRecentKeys(nowMs);
|
|
||||||
|
|
||||||
if (recentKeys.has(payloadJson)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recentKeys.set(payloadJson, nowMs);
|
|
||||||
|
|
||||||
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@ -1,9 +1,5 @@
|
|||||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||||
|
|
||||||
@theme {
|
|
||||||
--radius-2xl: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@source '../../../../app/Filament/**/*';
|
@source '../../../../app/Filament/**/*';
|
||||||
@source '../../../../resources/views/filament/**/*.blade.php';
|
@source '../../../../resources/views/filament/**/*.blade.php';
|
||||||
@source '../../../../resources/views/livewire/**/*.blade.php';
|
@source '../../../../resources/views/livewire/**/*.blade.php';
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||||
|
|
||||||
@theme {
|
|
||||||
--radius-2xl: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@source '../../../../app/Filament/System/**/*';
|
@source '../../../../app/Filament/System/**/*';
|
||||||
@source '../../../../resources/views/filament/system/**/*.blade.php';
|
@source '../../../../resources/views/filament/system/**/*.blade.php';
|
||||||
|
|||||||
@ -8,12 +8,21 @@
|
|||||||
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament::section
|
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||||
:heading="$card['title'] ?? 'Supporting detail'"
|
<div class="flex items-start justify-between gap-3">
|
||||||
:description="$card['description'] ?? null"
|
<div>
|
||||||
>
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $card['title'] ?? 'Supporting detail' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($card['description'] ?? null))
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $card['description'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
@if ($action !== null && filled($action['url'] ?? null))
|
@if ($action !== null && filled($action['url'] ?? null))
|
||||||
<x-slot name="headerEnd">
|
|
||||||
<a
|
<a
|
||||||
href="{{ $action['url'] }}"
|
href="{{ $action['url'] }}"
|
||||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
||||||
@ -21,10 +30,10 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
|||||||
>
|
>
|
||||||
{{ $action['label'] }}
|
{{ $action['label'] }}
|
||||||
</a>
|
</a>
|
||||||
</x-slot>
|
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="mt-4">
|
||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
||||||
@elseif ($items !== [])
|
@elseif ($items !== [])
|
||||||
@ -33,4 +42,4 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
|||||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</div>
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
@php
|
|
||||||
$summary = is_array($summary ?? null) ? $summary : [];
|
|
||||||
$buckets = is_array($buckets ?? null) ? $buckets : [];
|
|
||||||
$detailState = is_string($summary['detail_state'] ?? null) ? $summary['detail_state'] : 'no_gaps';
|
|
||||||
$tableContext = is_string($searchId ?? null) && $searchId !== '' ? $searchId : 'evidence-gap-search';
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($detailState === 'details_not_recorded' && $buckets === [])
|
|
||||||
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
|
|
||||||
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-warning-900 dark:text-warning-200">
|
|
||||||
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@elseif ($buckets !== [])
|
|
||||||
<div class="space-y-4">
|
|
||||||
@if ($detailState === 'details_not_recorded')
|
|
||||||
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
|
|
||||||
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-warning-900 dark:text-warning-200">
|
|
||||||
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
|
||||||
@foreach ($buckets as $bucket)
|
|
||||||
@php
|
|
||||||
$reasonLabel = is_string($bucket['reason_label'] ?? null) ? $bucket['reason_label'] : 'Evidence gap';
|
|
||||||
$count = is_numeric($bucket['count'] ?? null) ? (int) $bucket['count'] : 0;
|
|
||||||
$recordedCount = is_numeric($bucket['recorded_count'] ?? null) ? (int) $bucket['recorded_count'] : 0;
|
|
||||||
$missingDetailCount = is_numeric($bucket['missing_detail_count'] ?? null) ? (int) $bucket['missing_detail_count'] : 0;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<section class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $reasonLabel }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ __('baseline-compare.evidence_gap_bucket_help') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/40 dark:text-warning-100">
|
|
||||||
{{ __('baseline-compare.evidence_gap_reason_affected', ['count' => $count]) }}
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/40 dark:text-primary-100">
|
|
||||||
{{ __('baseline-compare.evidence_gap_reason_recorded', ['count' => $recordedCount]) }}
|
|
||||||
</span>
|
|
||||||
@if ($missingDetailCount > 0)
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
|
||||||
{{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($missingDetailCount > 0)
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-200">
|
|
||||||
{{ __('baseline-compare.evidence_gap_missing_reason_body', ['count' => $missingDetailCount]) }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ __('baseline-compare.evidence_gap_search_label') }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ __('baseline-compare.evidence_gap_search_help') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@livewire(
|
|
||||||
\App\Livewire\BaselineCompareEvidenceGapTable::class,
|
|
||||||
[
|
|
||||||
'buckets' => $buckets,
|
|
||||||
'context' => $tableContext,
|
|
||||||
],
|
|
||||||
key('baseline-compare-evidence-gap-table-'.$tableContext)
|
|
||||||
)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@ -31,31 +31,11 @@
|
|||||||
$actionabilitySpec = $specFor($actionability);
|
$actionabilitySpec = $specFor($actionability);
|
||||||
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
||||||
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
||||||
$operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : [];
|
|
||||||
$evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null)
|
|
||||||
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult'])
|
|
||||||
: null;
|
|
||||||
$trustSpec = is_string($operatorExplanation['trustworthinessLevel'] ?? null)
|
|
||||||
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel'])
|
|
||||||
: null;
|
|
||||||
$operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []);
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="flex flex-wrap items-start gap-2">
|
<div class="flex flex-wrap items-start gap-2">
|
||||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
|
||||||
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
|
||||||
{{ $evaluationSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
|
||||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
||||||
{{ $trustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($primarySpec)
|
@if ($primarySpec)
|
||||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
||||||
{{ $primarySpec->label }}
|
{{ $primarySpec->label }}
|
||||||
@ -71,31 +51,15 @@
|
|||||||
|
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
||||||
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
|
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
|
@if (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ $operatorExplanation['reliabilityStatement'] }}
|
|
||||||
</p>
|
|
||||||
@elseif (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ $state['primaryExplanation'] }}
|
{{ $state['primaryExplanation'] }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (is_string(data_get($operatorExplanation, 'dominantCause.explanation')) && trim(data_get($operatorExplanation, 'dominantCause.explanation')) !== '')
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ data_get($operatorExplanation, 'dominantCause.explanation') }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
|
|
||||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
Coverage: {{ $operatorExplanation['coverageStatement'] }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
||||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
Diagnostic: {{ $state['diagnosticLabel'] }}
|
Diagnostic: {{ $state['diagnosticLabel'] }}
|
||||||
@ -138,47 +102,14 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
|
|
||||||
<dd class="mt-1">
|
|
||||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
||||||
{{ $trustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
{{ data_get($operatorExplanation, 'nextAction.text') ?? ($state['nextActionLabel'] ?? 'No action needed') }}
|
{{ $state['nextActionLabel'] ?? 'No action needed' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
@if ($operatorCounts->isNotEmpty())
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
@foreach ($operatorCounts as $count)
|
|
||||||
@continue(! is_array($count))
|
|
||||||
|
|
||||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $count['label'] ?? 'Count' }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{{ (int) ($count['value'] ?? 0) }}
|
|
||||||
</div>
|
|
||||||
@if (filled($count['qualifier'] ?? null))
|
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $count['qualifier'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($nextSteps !== [])
|
@if ($nextSteps !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||||
|
|||||||
@ -6,30 +6,9 @@
|
|||||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||||
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
||||||
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
||||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if ($operatorExplanation !== [])
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $operatorExplanation['headline'] ?? 'Review explanation' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($operatorExplanation['reliabilityStatement'] ?? null))
|
|
||||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ $operatorExplanation['reliabilityStatement'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled(data_get($operatorExplanation, 'nextAction.text')))
|
|
||||||
<div class="mt-3 rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
|
|
||||||
{{ data_get($operatorExplanation, 'nextAction.text') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
@foreach ($metrics as $metric)
|
@foreach ($metrics as $metric)
|
||||||
@php
|
@php
|
||||||
|
|||||||
@ -6,14 +6,6 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
||||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
|
||||||
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
|
||||||
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
|
||||||
: null;
|
|
||||||
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
|
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
|
|
||||||
: null;
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($duplicateNamePoliciesCountValue > 0)
|
@if ($duplicateNamePoliciesCountValue > 0)
|
||||||
@ -35,96 +27,6 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($explanation !== null)
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex flex-wrap items-start gap-2">
|
|
||||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
|
||||||
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
|
||||||
{{ $evaluationSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
|
||||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
|
||||||
{{ $trustSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $explanation['headline'] ?? 'Compare explanation' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($explanation['reliabilityStatement'] ?? null))
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
{{ $explanation['reliabilityStatement'] }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled(data_get($explanation, 'dominantCause.explanation')))
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ data_get($explanation, 'dominantCause.explanation') }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt>
|
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ $explanation['executionOutcomeLabel'] ?? 'Completed' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
|
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ $explanation['trustworthinessLabel'] ?? 'Needs review' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
@if (filled($explanation['coverageStatement'] ?? null))
|
|
||||||
<div class="rounded-lg border border-primary-200 bg-primary-50/70 px-4 py-3 text-sm text-primary-950 dark:border-primary-900/40 dark:bg-primary-950/20 dark:text-primary-100">
|
|
||||||
<span class="font-semibold">Coverage:</span>
|
|
||||||
{{ $explanation['coverageStatement'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($explanationCounts->isNotEmpty())
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
@foreach ($explanationCounts as $count)
|
|
||||||
@continue(! is_array($count))
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $count['label'] ?? 'Count' }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ (int) ($count['value'] ?? 0) }}
|
|
||||||
</div>
|
|
||||||
@if (filled($count['qualifier'] ?? null))
|
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $count['qualifier'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Row 1: Stats Overview --}}
|
{{-- Row 1: Stats Overview --}}
|
||||||
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
@ -353,20 +255,6 @@ class="w-fit"
|
|||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($hasEvidenceGapDetailSection)
|
|
||||||
<x-filament::section :heading="__('baseline-compare.evidence_gap_details_heading')">
|
|
||||||
<x-slot name="description">
|
|
||||||
{{ __('baseline-compare.evidence_gap_details_description') }}
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
@include('filament.infolists.entries.evidence-gap-subjects', [
|
|
||||||
'summary' => $evidenceGapSummary,
|
|
||||||
'buckets' => $evidenceGapBuckets ?? [],
|
|
||||||
'searchId' => 'tenant-baseline-compare-gap-search',
|
|
||||||
])
|
|
||||||
</x-filament::section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Severity breakdown + actions --}}
|
{{-- Severity breakdown + actions --}}
|
||||||
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
|
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
@ -490,14 +378,4 @@ class="w-fit"
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($hasEvidenceGapDiagnostics)
|
|
||||||
<x-filament::section :heading="__('baseline-compare.evidence_gap_diagnostics_heading')">
|
|
||||||
<x-slot name="description">
|
|
||||||
{{ __('baseline-compare.evidence_gap_diagnostics_description') }}
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
@include('filament.partials.json-viewer', ['value' => $baselineCompareDiagnostics])
|
|
||||||
</x-filament::section>
|
|
||||||
@endif
|
|
||||||
</x-filament::page>
|
</x-filament::page>
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@php($selectedAudit = $this->selectedAuditRecord())
|
|
||||||
@php($selectedAuditLink = $this->selectedAuditTargetLink())
|
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
@ -18,14 +15,134 @@
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
|
||||||
|
@php
|
||||||
|
$selectedAudit = $this->selectedAuditLog();
|
||||||
|
$selectedAuditLink = $this->selectedAuditLink();
|
||||||
|
@endphp
|
||||||
|
|
||||||
@if ($selectedAudit)
|
@if ($selectedAudit)
|
||||||
<x-filament::section>
|
<x-filament::section
|
||||||
@include('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
:heading="$selectedAudit->summaryText()"
|
||||||
'selectedAudit' => $selectedAudit,
|
:description="$selectedAudit->recorded_at?->toDayDateTimeString()"
|
||||||
'selectedAuditLink' => $selectedAuditLink,
|
>
|
||||||
])
|
<div class="flex flex-col gap-6">
|
||||||
</x-filament::section>
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||||
|
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditOutcome)($selectedAudit->normalizedOutcome()->value) }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||||
|
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditActorType)($selectedAudit->actorSnapshot()->type->value) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (is_array($selectedAuditLink))
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||||
|
href="{{ $selectedAuditLink['url'] }}"
|
||||||
|
>
|
||||||
|
{{ $selectedAuditLink['label'] }}
|
||||||
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $this->table }}
|
<button
|
||||||
|
class="inline-flex items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
type="button"
|
||||||
|
wire:click="clearSelectedAuditLog"
|
||||||
|
>
|
||||||
|
Close details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Actor
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $selectedAudit->actorDisplayLabel() }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $selectedAudit->actorSnapshot()->type->label() }}
|
||||||
|
</div>
|
||||||
|
@if ($selectedAudit->actorSnapshot()->email)
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $selectedAudit->actorSnapshot()->email }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Target
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $selectedAudit->targetDisplayLabel() ?? 'No target snapshot' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $selectedAudit->resource_type ? ucfirst(str_replace('_', ' ', $selectedAudit->resource_type)) : 'Workspace event' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Scope
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $selectedAudit->tenant?->name ?? 'Workspace-wide event' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Workspace #{{ $selectedAudit->workspace_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Readable context
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($selectedAudit->contextItems() === [])
|
||||||
|
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
No additional context was recorded for this event.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<dl class="mt-3 space-y-3">
|
||||||
|
@foreach ($selectedAudit->contextItems() as $item)
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['label'] }}
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ is_bool($item['value']) ? ($item['value'] ? 'true' : 'false') : $item['value'] }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Technical metadata
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-3 space-y-3">
|
||||||
|
@foreach ($selectedAudit->technicalMetadata() as $label => $value)
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $label }}
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 break-all text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $value }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user