Compare commits
4 Commits
158-artifa
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 |
9
.github/agents/copilot-instructions.md
vendored
9
.github/agents/copilot-instructions.md
vendored
@ -100,6 +100,9 @@ ## 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.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -119,8 +122,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
- 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
|
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -18,8 +19,10 @@ 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(OperationRunService $operationRunService): int
|
public function handle(
|
||||||
{
|
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');
|
||||||
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
||||||
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;
|
||||||
|
|||||||
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TenantpilotReconcileOperationRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:operation-runs:reconcile
|
||||||
|
{--type=* : Limit reconciliation to one or more covered operation types}
|
||||||
|
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
||||||
|
{--workspace=* : Limit reconciliation to workspace ids}
|
||||||
|
{--limit=100 : Maximum number of active runs to inspect}
|
||||||
|
{--dry-run : Report the changes without writing them}';
|
||||||
|
|
||||||
|
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationLifecycleReconciler $reconciler,
|
||||||
|
OperationLifecyclePolicy $policy,
|
||||||
|
): int {
|
||||||
|
$types = array_values(array_filter(
|
||||||
|
(array) $this->option('type'),
|
||||||
|
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||||
|
));
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
$types = $policy->coveredTypeNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $reconciler->reconcile([
|
||||||
|
'types' => $types,
|
||||||
|
'tenant_ids' => $tenantIds,
|
||||||
|
'workspace_ids' => $workspaceIds,
|
||||||
|
'limit' => max(1, (int) $this->option('limit')),
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = collect($result['changes'] ?? [])
|
||||||
|
->map(static function (array $change): array {
|
||||||
|
return [
|
||||||
|
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
||||||
|
'Type' => (string) ($change['type'] ?? '—'),
|
||||||
|
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
||||||
|
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($rows !== []) {
|
||||||
|
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Inspected %d run(s); reconciled %d; skipped %d.',
|
||||||
|
(int) ($result['candidates'] ?? 0),
|
||||||
|
(int) ($result['reconciled'] ?? 0),
|
||||||
|
(int) ($result['skipped'] ?? 0),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Dry-run: no changes written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -89,6 +89,9 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @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();
|
||||||
@ -140,6 +143,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->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -307,9 +311,22 @@ 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('Reason: '.($result['reason_code'] ?? 'unknown'))
|
->body($message)
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
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;
|
||||||
@ -82,14 +83,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
if ($this->selectedAuditLogId !== null) {
|
if ($requestedEventId !== null) {
|
||||||
$this->selectedAuditLog();
|
$this->resolveAuditLog($requestedEventId);
|
||||||
|
$this->selectedAuditLogId = $requestedEventId;
|
||||||
|
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,31 +101,10 @@ public function mount(): void
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = app(OperateHubShell::class)->headerActions(
|
return 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
|
||||||
@ -195,9 +177,19 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (AuditLogModel $record): void {
|
->before(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')
|
||||||
@ -209,48 +201,11 @@ 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>
|
||||||
*/
|
*/
|
||||||
@ -323,6 +278,54 @@ 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,6 +13,7 @@
|
|||||||
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;
|
||||||
@ -165,6 +166,68 @@ public function table(Table $table): Table
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{likely_stale:int,reconciled:int}
|
||||||
|
*/
|
||||||
|
public function lifecycleVisibilitySummary(): array
|
||||||
|
{
|
||||||
|
$baseQuery = $this->scopedSummaryQuery();
|
||||||
|
|
||||||
|
if (! $baseQuery instanceof Builder) {
|
||||||
|
return [
|
||||||
|
'likely_stale' => 0,
|
||||||
|
'reconciled' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled = (clone $baseQuery)
|
||||||
|
->whereNotNull('context->reconciliation->reconciled_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$policy = app(OperationLifecyclePolicy::class);
|
||||||
|
$likelyStale = (clone $baseQuery)
|
||||||
|
->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])
|
||||||
|
->where(function (Builder $query) use ($policy): void {
|
||||||
|
foreach ($policy->coveredTypeNames() as $type) {
|
||||||
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
|
$typeQuery
|
||||||
|
->where('type', $type)
|
||||||
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
|
$stateQuery
|
||||||
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
|
$queuedQuery
|
||||||
|
->where('status', OperationRunStatus::Queued->value)
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
||||||
|
$runningQuery
|
||||||
|
->where('status', OperationRunStatus::Running->value)
|
||||||
|
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
||||||
|
$startedAtQuery
|
||||||
|
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
||||||
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
||||||
|
$fallbackQuery
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'likely_stale' => $likelyStale,
|
||||||
|
'reconciled' => $reconciled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@ -187,4 +250,26 @@ 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,6 +24,8 @@
|
|||||||
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;
|
||||||
@ -170,11 +172,18 @@ 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 = $reasonEnvelope?->toBodyLines() ?? [
|
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? array_values(array_filter([
|
||||||
|
$operatorExplanation->headline,
|
||||||
|
$operatorExplanation->dominantCauseExplanation,
|
||||||
|
OperationUxPresenter::surfaceGuidance($this->run),
|
||||||
|
]))
|
||||||
|
: ($reasonEnvelope?->toBodyLines() ?? [
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
OperationUxPresenter::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',
|
||||||
@ -183,6 +192,40 @@ public function blockedExecutionBanner(): ?array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
|
*/
|
||||||
|
public function lifecycleBanner(): ?array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||||
|
|
||||||
|
if ($attention === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
||||||
|
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
||||||
|
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
||||||
|
|
||||||
|
return match ($this->run->freshnessState()->value) {
|
||||||
|
'likely_stale' => [
|
||||||
|
'tone' => 'amber',
|
||||||
|
'title' => 'Likely stale run',
|
||||||
|
'body' => $body,
|
||||||
|
],
|
||||||
|
'reconciled_failed' => [
|
||||||
|
'tone' => 'rose',
|
||||||
|
'title' => 'Automatically reconciled',
|
||||||
|
'body' => $body,
|
||||||
|
],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{tone: string, title: string, body: string}|null
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
*/
|
*/
|
||||||
@ -417,4 +460,13 @@ 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)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? 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)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
|||||||
@ -6,19 +6,28 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -288,15 +297,32 @@ 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')
|
||||||
@ -355,10 +381,27 @@ 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('activeSnapshot.captured_at')
|
TextColumn::make('current_snapshot_truth')
|
||||||
->label('Last snapshot')
|
->label('Current snapshot')
|
||||||
->dateTime()
|
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
||||||
->placeholder('No snapshot'),
|
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('latest_attempted_snapshot_truth')
|
||||||
|
->label('Latest attempt')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
||||||
|
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('compare_readiness')
|
||||||
|
->label('Compare readiness')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||||
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||||
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('baseline_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -545,4 +588,167 @@ 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 active baseline snapshot.';
|
: 'Select the target tenant to compare its current inventory against the effective current 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() === [])
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -256,7 +256,11 @@ 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 => 'This baseline profile has no active snapshot.',
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -395,4 +399,12 @@ 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,10 +9,12 @@
|
|||||||
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;
|
||||||
@ -177,7 +179,23 @@ 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)->primaryExplanation)
|
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('lifecycle_state')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('current_truth')
|
||||||
|
->label('Current truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
|
||||||
|
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
@ -185,15 +203,8 @@ 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)->nextStepText())
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? 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])
|
||||||
@ -203,10 +214,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Baseline')
|
->label('Baseline')
|
||||||
->options(static::baselineProfileOptions())
|
->options(static::baselineProfileOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('snapshot_state')
|
SelectFilter::make('lifecycle_state')
|
||||||
->label('State')
|
->label('Lifecycle')
|
||||||
->options(static::snapshotStateOptions())
|
->options(static::lifecycleOptions())
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
||||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@ -267,9 +278,9 @@ private static function baselineProfileOptions(): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function snapshotStateOptions(): array
|
private static function lifecycleOptions(): array
|
||||||
{
|
{
|
||||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveWorkspace(): ?Workspace
|
public static function resolveWorkspace(): ?Workspace
|
||||||
@ -343,24 +354,18 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
|||||||
return self::gapsCount($snapshot) > 0;
|
return self::gapsCount($snapshot) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||||
{
|
{
|
||||||
return self::gapSpec($snapshot)->label;
|
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
||||||
{
|
{
|
||||||
if (! is_string($value) || trim($value) === '') {
|
if (! is_string($value) || trim($value) === '') {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
$gapCountExpression = self::gapCountExpression($query);
|
return $query->where('lifecycle_state', trim($value));
|
||||||
|
|
||||||
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
|
||||||
@ -384,4 +389,51 @@ 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,6 +35,8 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@ -128,10 +128,11 @@ public static function table(Table $table): Table
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||||
Tables\Columns\TextColumn::make('type')
|
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))
|
||||||
@ -154,10 +155,10 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -253,13 +254,25 @@ 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, $record->status);
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||||
$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(
|
||||||
@ -294,8 +307,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' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
||||||
visible: $record->isGovernanceArtifactOperation(),
|
visible: $artifactTruth !== null,
|
||||||
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(
|
||||||
@ -315,6 +328,15 @@ 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',
|
||||||
@ -333,6 +355,26 @@ 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,
|
||||||
@ -399,6 +441,19 @@ 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);
|
||||||
@ -709,6 +764,82 @@ 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)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? 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)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
@ -563,6 +563,7 @@ 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,6 +22,8 @@ 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,
|
||||||
@ -43,6 +45,8 @@ 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,8 +44,10 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => $hasWarnings && $runUrl !== null,
|
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
||||||
'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,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -11,11 +12,18 @@
|
|||||||
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 Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public int $bulkRunId = 0;
|
public int $bulkRunId = 0;
|
||||||
|
|
||||||
@ -68,32 +76,6 @@ public function handle(OperationRunService $runs): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function failed(Throwable $e): void
|
|
||||||
{
|
|
||||||
$run = $this->operationRun;
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
|
||||||
$run = OperationRun::query()->find($this->bulkRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$runs->updateRun(
|
|
||||||
$run,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
failures: [[
|
|
||||||
'code' => 'bulk_job.failed',
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOperationRun(): OperationRun
|
private function resolveOperationRun(): OperationRun
|
||||||
{
|
{
|
||||||
if ($this->operationRun instanceof OperationRun) {
|
if ($this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -15,7 +16,15 @@
|
|||||||
|
|
||||||
class BulkTenantSyncJob implements ShouldQueue
|
class BulkTenantSyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 180;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -21,7 +22,9 @@
|
|||||||
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;
|
||||||
@ -33,10 +36,19 @@
|
|||||||
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 Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
@ -60,13 +72,13 @@ public function handle(
|
|||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
|
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
|
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
@ -208,16 +220,17 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$snapshot = $this->findOrCreateSnapshot(
|
$snapshotResult = $this->captureSnapshotArtifact(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
$items,
|
$items,
|
||||||
$snapshotSummary,
|
$snapshotSummary,
|
||||||
);
|
);
|
||||||
|
|
||||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
$snapshot = $snapshotResult['snapshot'];
|
||||||
|
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||||
|
|
||||||
if ($profile->status === BaselineProfileStatus::Active) {
|
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,6 +271,7 @@ 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]);
|
||||||
|
|
||||||
@ -508,29 +522,151 @@ private function buildSnapshotItems(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findOrCreateSnapshot(
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $snapshotItems
|
||||||
|
* @param array<string, mixed> $summaryJsonb
|
||||||
|
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||||
|
*/
|
||||||
|
private function captureSnapshotArtifact(
|
||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
string $identityHash,
|
string $identityHash,
|
||||||
array $snapshotItems,
|
array $snapshotItems,
|
||||||
array $summaryJsonb,
|
array $summaryJsonb,
|
||||||
): BaselineSnapshot {
|
): array {
|
||||||
|
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
||||||
|
|
||||||
|
if ($existing instanceof BaselineSnapshot) {
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $existing,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: false,
|
||||||
|
expectedItems: count($snapshotItems),
|
||||||
|
persistedItems: count($snapshotItems),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'snapshot' => $existing,
|
||||||
|
'was_new_snapshot' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedItems = count($snapshotItems);
|
||||||
|
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: $expectedItems,
|
||||||
|
persistedItems: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
||||||
|
|
||||||
|
if ($persistedItems !== $expectedItems) {
|
||||||
|
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot->markComplete($identityHash, [
|
||||||
|
'expected_identity_hash' => $identityHash,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
'persisted_items' => $persistedItems,
|
||||||
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'was_empty_capture' => $expectedItems === 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot->refresh();
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: $expectedItems,
|
||||||
|
persistedItems: $persistedItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'snapshot' => $snapshot,
|
||||||
|
'was_new_snapshot' => true,
|
||||||
|
];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$persistedItems = (int) BaselineSnapshotItem::query()
|
||||||
|
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$reasonCode = $exception instanceof RuntimeException
|
||||||
|
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
||||||
|
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
||||||
|
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
||||||
|
|
||||||
|
$snapshot->markIncomplete($reasonCode, [
|
||||||
|
'expected_identity_hash' => $identityHash,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
'persisted_items' => $persistedItems,
|
||||||
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'was_empty_capture' => $expectedItems === 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot->refresh();
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: $expectedItems,
|
||||||
|
persistedItems: $persistedItems,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||||
|
{
|
||||||
$existing = BaselineSnapshot::query()
|
$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();
|
||||||
|
|
||||||
if ($existing instanceof BaselineSnapshot) {
|
return $existing instanceof BaselineSnapshot ? $existing : null;
|
||||||
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' => $identityHash,
|
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
||||||
'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(
|
||||||
@ -549,9 +685,56 @@ private function findOrCreateSnapshot(
|
|||||||
);
|
);
|
||||||
|
|
||||||
BaselineSnapshotItem::insert($rows);
|
BaselineSnapshotItem::insert($rows);
|
||||||
|
$persistedItems += count($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $snapshot;
|
return $persistedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return hash(
|
||||||
|
'sha256',
|
||||||
|
implode('|', [
|
||||||
|
'building',
|
||||||
|
(string) $profile->getKey(),
|
||||||
|
(string) $this->operationRun->getKey(),
|
||||||
|
(string) microtime(true),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rememberSnapshotOnRun(
|
||||||
|
BaselineSnapshot $snapshot,
|
||||||
|
string $identityHash,
|
||||||
|
bool $wasNewSnapshot,
|
||||||
|
int $expectedItems,
|
||||||
|
int $persistedItems,
|
||||||
|
?string $reasonCode = null,
|
||||||
|
): void {
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
||||||
|
$context['result'] = array_merge(
|
||||||
|
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||||
|
[
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'snapshot_identity_hash' => $identityHash,
|
||||||
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
|
'items_captured' => $persistedItems,
|
||||||
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||||
|
$context['reason_code'] = $reasonCode;
|
||||||
|
$context['result']['snapshot_reason_code'] = $reasonCode;
|
||||||
|
} else {
|
||||||
|
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRun->update(['context' => $context]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
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;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
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;
|
||||||
@ -37,6 +39,7 @@
|
|||||||
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;
|
||||||
@ -44,6 +47,7 @@
|
|||||||
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;
|
||||||
@ -54,7 +58,15 @@
|
|||||||
|
|
||||||
class CompareBaselineToTenantJob implements ShouldQueue
|
class CompareBaselineToTenantJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
@ -84,6 +96,7 @@ 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,
|
||||||
@ -92,6 +105,7 @@ 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);
|
||||||
@ -278,12 +292,52 @@ 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(['id', 'captured_at']);
|
->first();
|
||||||
|
|
||||||
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;
|
||||||
@ -545,6 +599,10 @@ 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(
|
||||||
@ -790,6 +848,7 @@ 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]);
|
||||||
|
|
||||||
@ -896,6 +955,34 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
unset($context['reason_translation'], $context['next_steps']);
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||||
|
|
||||||
|
if ($translation === null) {
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context['reason_translation'] = $translation->toArray();
|
||||||
|
$context['reason_code'] = $reasonCode;
|
||||||
|
|
||||||
|
if ($translation->toLegacyNextSteps() !== []) {
|
||||||
|
$context['next_steps'] = $translation->toLegacyNextSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load current inventory items keyed by "policy_type|subject_key".
|
* Load current inventory items keyed by "policy_type|subject_key".
|
||||||
*
|
*
|
||||||
@ -1004,6 +1091,17 @@ 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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -17,8 +18,13 @@
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Concerns;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
trait BridgesFailedOperationRun
|
||||||
|
{
|
||||||
|
public function failed(Throwable $exception): void
|
||||||
|
{
|
||||||
|
$operationRun = $this->failedBridgeOperationRun();
|
||||||
|
|
||||||
|
if (! $operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function failedBridgeOperationRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
|
||||||
|
return $this->operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
|
||||||
|
return $this->run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateIds = [];
|
||||||
|
|
||||||
|
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
|
||||||
|
if (! property_exists($this, $property)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->{$property};
|
||||||
|
|
||||||
|
if (is_numeric($value) && (int) $value > 0) {
|
||||||
|
$candidateIds[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
|
||||||
|
$operationRun = OperationRun::query()->find($candidateId);
|
||||||
|
|
||||||
|
if ($operationRun instanceof OperationRun) {
|
||||||
|
return $operationRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,10 @@ class EntraGroupSyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
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,6 +25,10 @@ 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,6 +19,10 @@ 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,6 +28,10 @@ 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,6 +40,10 @@ 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,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -24,7 +25,15 @@
|
|||||||
|
|
||||||
class RunInventorySyncJob implements ShouldQueue
|
class RunInventorySyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -21,7 +22,15 @@
|
|||||||
|
|
||||||
class SyncPoliciesJob implements ShouldQueue
|
class SyncPoliciesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 180;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,10 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
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;
|
||||||
@ -121,6 +122,37 @@ 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,11 +1,17 @@
|
|||||||
<?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
|
||||||
{
|
{
|
||||||
@ -13,10 +19,20 @@ 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
|
||||||
{
|
{
|
||||||
@ -32,4 +48,100 @@ 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,6 +3,7 @@
|
|||||||
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;
|
||||||
@ -134,6 +135,11 @@ 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);
|
||||||
@ -159,4 +165,32 @@ 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)
|
||||||
->requiredCapabilityForType((string) $run->type);
|
->requiredCapabilityForRun($run);
|
||||||
|
|
||||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
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;
|
||||||
@ -202,7 +203,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
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;
|
||||||
@ -60,6 +61,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,
|
||||||
])
|
])
|
||||||
->viteTheme('resources/css/filament/system/theme.css');
|
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\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;
|
||||||
@ -112,7 +113,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
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;
|
||||||
@ -151,25 +152,23 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
|||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$runs->updateRun(
|
$runs->updateRunWithReconciliation(
|
||||||
$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,16 +18,18 @@
|
|||||||
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}
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
||||||
*/
|
*/
|
||||||
public function startCompare(
|
public function startCompare(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -40,38 +42,45 @@ public function startCompare(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
$precondition = $this->validatePreconditions($profile);
|
||||||
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
return ['ok' => false, 'reason_code' => $precondition];
|
return $this->failedStart($precondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
$selectedSnapshot = null;
|
||||||
|
|
||||||
if ($snapshotId > 0) {
|
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
||||||
$snapshot = BaselineSnapshot::query()
|
$selectedSnapshot = 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($snapshotId)
|
->whereKey((int) $baselineSnapshotId)
|
||||||
->first(['id']);
|
->first();
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$snapshotId = (int) $profile->active_snapshot_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
||||||
|
|
||||||
|
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||||
|
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineSnapshot $snapshot */
|
||||||
|
$snapshot = $snapshotResolution['snapshot'];
|
||||||
|
$snapshotId = (int) $snapshot->getKey();
|
||||||
|
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
);
|
);
|
||||||
@ -113,7 +122,7 @@ public function startCompare(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
@ -123,10 +132,20 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
|
|||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Baselines;
|
||||||
|
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
|
||||||
|
final class BaselineSnapshotTruthResolver
|
||||||
|
{
|
||||||
|
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* ok: bool,
|
||||||
|
* snapshot: ?BaselineSnapshot,
|
||||||
|
* effective_snapshot: ?BaselineSnapshot,
|
||||||
|
* latest_attempted_snapshot: ?BaselineSnapshot,
|
||||||
|
* reason_code: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
|
||||||
|
{
|
||||||
|
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||||
|
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
|
||||||
|
|
||||||
|
if ($explicitSnapshot instanceof BaselineSnapshot) {
|
||||||
|
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|
||||||
|
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'snapshot' => null,
|
||||||
|
'effective_snapshot' => $effectiveSnapshot,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => $reasonCode === null,
|
||||||
|
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
|
||||||
|
'effective_snapshot' => $effectiveSnapshot,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($effectiveSnapshot instanceof BaselineSnapshot) {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'snapshot' => $effectiveSnapshot,
|
||||||
|
'effective_snapshot' => $effectiveSnapshot,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'snapshot' => null,
|
||||||
|
'effective_snapshot' => null,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
|
||||||
|
{
|
||||||
|
$effectiveSnapshot ??= BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
|
||||||
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot->isConsumable()
|
||||||
|
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
|
||||||
|
{
|
||||||
|
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||||
|
$snapshot->loadMissing('baselineProfile');
|
||||||
|
|
||||||
|
$profile = $snapshot->baselineProfile;
|
||||||
|
|
||||||
|
if ($profile instanceof BaselineProfile) {
|
||||||
|
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshot->isBuilding()) {
|
||||||
|
return BaselineReasonCodes::SNAPSHOT_BUILDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshot->isIncomplete()) {
|
||||||
|
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
|
||||||
|
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||||
|
? trim($reasonCode)
|
||||||
|
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
|
||||||
|
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareBlockedReasonForSnapshot(
|
||||||
|
BaselineSnapshot $snapshot,
|
||||||
|
?BaselineSnapshot $effectiveSnapshot,
|
||||||
|
bool $explicitSelection,
|
||||||
|
): ?string {
|
||||||
|
if ($snapshot->isBuilding()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshot->isIncomplete()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $snapshot->isConsumable()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
|
||||||
|
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||||
|
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
use App\Support\Ui\EnterpriseDetail\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;
|
||||||
@ -99,13 +100,21 @@ 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);
|
||||||
|
|
||||||
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
$truthBadge = $factory->statusBadge(
|
||||||
$stateBadge = $factory->statusBadge(
|
$truth->primaryBadgeSpec()->label,
|
||||||
$stateSpec->label,
|
$truth->primaryBadgeSpec()->color,
|
||||||
$stateSpec->color,
|
$truth->primaryBadgeSpec()->icon,
|
||||||
$stateSpec->icon,
|
$truth->primaryBadgeSpec()->iconColor,
|
||||||
$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);
|
||||||
@ -120,20 +129,27 @@ 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,
|
||||||
));
|
));
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
$currentTruth = $this->currentTruthPresentation($truth);
|
||||||
|
$currentTruthBadge = $factory->statusBadge(
|
||||||
|
$currentTruth['label'],
|
||||||
|
$currentTruth['color'],
|
||||||
|
$currentTruth['icon'],
|
||||||
|
$currentTruth['iconColor'],
|
||||||
|
);
|
||||||
|
$operatorExplanation = $truth->operatorExplanation;
|
||||||
|
|
||||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
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: [$stateBadge, $fidelityBadge],
|
statusBadges: [$truthBadge, $lifecycleBadge, $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: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
@ -175,11 +191,30 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
->addSupportingCard(
|
->addSupportingCard(
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
title: 'Snapshot status',
|
title: 'Snapshot truth',
|
||||||
|
items: array_values(array_filter([
|
||||||
|
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||||
|
$operatorExplanation !== null
|
||||||
|
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||||
|
: null,
|
||||||
|
$operatorExplanation !== null
|
||||||
|
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||||
|
: null,
|
||||||
|
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||||
|
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||||
|
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||||
|
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||||
|
: null,
|
||||||
|
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
|
||||||
|
])),
|
||||||
|
),
|
||||||
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'coverage',
|
||||||
|
title: 'Coverage',
|
||||||
items: [
|
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(
|
||||||
@ -187,6 +222,8 @@ 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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -338,6 +375,33 @@ private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, color: string, icon: string, iconColor: string}
|
||||||
|
*/
|
||||||
|
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
||||||
|
{
|
||||||
|
return match ($truth->artifactExistence) {
|
||||||
|
'historical_only' => [
|
||||||
|
'label' => 'Historical trace',
|
||||||
|
'color' => 'gray',
|
||||||
|
'icon' => 'heroicon-m-clock',
|
||||||
|
'iconColor' => 'gray',
|
||||||
|
],
|
||||||
|
'created_but_not_usable' => [
|
||||||
|
'label' => 'Not compare input',
|
||||||
|
'color' => 'warning',
|
||||||
|
'icon' => 'heroicon-m-exclamation-triangle',
|
||||||
|
'iconColor' => 'warning',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'label' => 'Current baseline',
|
||||||
|
'color' => 'success',
|
||||||
|
'icon' => 'heroicon-m-check-badge',
|
||||||
|
'iconColor' => 'success',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function typeLabel(string $policyType): string
|
private function typeLabel(string $policyType): string
|
||||||
{
|
{
|
||||||
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
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;
|
||||||
@ -62,15 +63,45 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
|||||||
|
|
||||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||||
{
|
{
|
||||||
return $this->updateRun(
|
return $this->forceFailNonTerminalRun(
|
||||||
$run,
|
$run,
|
||||||
status: OperationRunStatus::Completed->value,
|
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
message: $message,
|
||||||
failures: [
|
source: 'scheduled_reconciler',
|
||||||
[
|
evidence: [
|
||||||
'code' => 'run.stale_queued',
|
'status' => OperationRunStatus::Queued->value,
|
||||||
'message' => $message,
|
'created_at' => $run->created_at?->toIso8601String(),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
|
||||||
|
{
|
||||||
|
if ($run->status !== OperationRunStatus::Running->value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startedAt = $run->started_at ?? $run->created_at;
|
||||||
|
|
||||||
|
if ($startedAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failStaleRunningRun(
|
||||||
|
OperationRun $run,
|
||||||
|
string $message = 'Run stopped reporting progress and was marked failed.',
|
||||||
|
): OperationRun {
|
||||||
|
return $this->forceFailNonTerminalRun(
|
||||||
|
$run,
|
||||||
|
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
|
||||||
|
message: $message,
|
||||||
|
source: 'scheduled_reconciler',
|
||||||
|
evidence: [
|
||||||
|
'status' => OperationRunStatus::Running->value,
|
||||||
|
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -721,6 +752,136 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $evidence
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
*/
|
||||||
|
public function forceFailNonTerminalRun(
|
||||||
|
OperationRun $run,
|
||||||
|
string $reasonCode,
|
||||||
|
string $message,
|
||||||
|
string $source = 'scheduled_reconciler',
|
||||||
|
array $evidence = [],
|
||||||
|
array $summaryCounts = [],
|
||||||
|
): OperationRun {
|
||||||
|
return $this->updateRunWithReconciliation(
|
||||||
|
run: $run,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: [[
|
||||||
|
'code' => $reasonCode,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
reasonMessage: $message,
|
||||||
|
source: $source,
|
||||||
|
evidence: $evidence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bridgeFailedJobFailure(
|
||||||
|
OperationRun $run,
|
||||||
|
Throwable $exception,
|
||||||
|
string $source = 'failed_callback',
|
||||||
|
): OperationRun {
|
||||||
|
$reason = $this->bridgeReasonForThrowable($exception);
|
||||||
|
$message = $reason->defaultMessage();
|
||||||
|
$exceptionMessage = $this->sanitizeMessage($exception->getMessage());
|
||||||
|
|
||||||
|
if ($exceptionMessage !== '') {
|
||||||
|
$message = $exceptionMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->forceFailNonTerminalRun(
|
||||||
|
$run,
|
||||||
|
reasonCode: $reason->value,
|
||||||
|
message: $message,
|
||||||
|
source: $source,
|
||||||
|
evidence: [
|
||||||
|
'exception_class' => $exception::class,
|
||||||
|
'bridge_source' => $source,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
* @param array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}> $failures
|
||||||
|
* @param array<string, mixed> $evidence
|
||||||
|
*/
|
||||||
|
public function updateRunWithReconciliation(
|
||||||
|
OperationRun $run,
|
||||||
|
string $status,
|
||||||
|
string $outcome,
|
||||||
|
array $summaryCounts,
|
||||||
|
array $failures,
|
||||||
|
string $reasonCode,
|
||||||
|
string $reasonMessage,
|
||||||
|
string $source = 'scheduled_reconciler',
|
||||||
|
array $evidence = [],
|
||||||
|
): OperationRun {
|
||||||
|
/** @var OperationRun $updated */
|
||||||
|
$updated = DB::transaction(function () use (
|
||||||
|
$run,
|
||||||
|
$status,
|
||||||
|
$outcome,
|
||||||
|
$summaryCounts,
|
||||||
|
$failures,
|
||||||
|
$reasonCode,
|
||||||
|
$reasonMessage,
|
||||||
|
$source,
|
||||||
|
$evidence,
|
||||||
|
): OperationRun {
|
||||||
|
$locked = OperationRun::query()
|
||||||
|
->whereKey($run->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $locked instanceof OperationRun) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $locked->status === OperationRunStatus::Completed->value) {
|
||||||
|
return $locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($locked->context) ? $locked->context : [];
|
||||||
|
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
$context['reconciliation'] = $this->reconciliationMetadata(
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
reasonMessage: $reasonMessage,
|
||||||
|
source: $source,
|
||||||
|
evidence: $evidence,
|
||||||
|
);
|
||||||
|
|
||||||
|
$translatedContext = $this->withReasonTranslationContext(
|
||||||
|
run: $locked,
|
||||||
|
context: $context,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
|
||||||
|
$locked->update([
|
||||||
|
'context' => $translatedContext ?? $context,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$locked->refresh();
|
||||||
|
|
||||||
|
return $this->updateRun(
|
||||||
|
$locked,
|
||||||
|
status: $status,
|
||||||
|
outcome: $outcome,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$updated->refresh();
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
||||||
*
|
*
|
||||||
@ -1033,16 +1194,49 @@ private function isDirectlyTranslatableReason(string $reasonCode): bool
|
|||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
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) {
|
||||||
@ -1072,6 +1266,7 @@ 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,
|
||||||
|
|||||||
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Operations;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class OperationLifecyclePolicyValidator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationLifecyclePolicy $policy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* valid:bool,
|
||||||
|
* errors:array<int, string>,
|
||||||
|
* definitions:array<string, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function validate(): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$definitions = [];
|
||||||
|
|
||||||
|
foreach ($this->policy->coveredTypeNames() as $operationType) {
|
||||||
|
$definition = $this->policy->definition($operationType);
|
||||||
|
|
||||||
|
if ($definition === null) {
|
||||||
|
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions[$operationType] = $definition;
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeout = $this->jobTimeoutSeconds($operationType);
|
||||||
|
|
||||||
|
if (! is_int($timeout) || $timeout <= 0) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->jobFailsOnTimeout($operationType)) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
|
||||||
|
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
|
||||||
|
|
||||||
|
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
|
||||||
|
$errors[] = sprintf(
|
||||||
|
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
|
||||||
|
$operationType,
|
||||||
|
$timeout,
|
||||||
|
$retryAfter,
|
||||||
|
$safetyMargin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
|
||||||
|
|
||||||
|
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
|
||||||
|
$errors[] = sprintf(
|
||||||
|
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
|
||||||
|
$operationType,
|
||||||
|
$expectedMaxRuntime,
|
||||||
|
$retryAfter,
|
||||||
|
$safetyMargin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => $errors === [],
|
||||||
|
'errors' => $errors,
|
||||||
|
'definitions' => $definitions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertValid(): void
|
||||||
|
{
|
||||||
|
$result = $this->validate();
|
||||||
|
|
||||||
|
if (($result['valid'] ?? false) === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobTimeoutSeconds(string $operationType): ?int
|
||||||
|
{
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($timeout) ? (int) $timeout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobFailsOnTimeout(string $operationType): bool
|
||||||
|
{
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobUsesDirectFailedBridge(string $operationType): bool
|
||||||
|
{
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Operations;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\LifecycleReconciliationReason;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
final class OperationLifecycleReconciler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationLifecyclePolicy $policy,
|
||||||
|
private readonly OperationRunService $operationRunService,
|
||||||
|
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* types?: array<int, string>,
|
||||||
|
* tenant_ids?: array<int, int>,
|
||||||
|
* workspace_ids?: array<int, int>,
|
||||||
|
* limit?: int,
|
||||||
|
* dry_run?: bool
|
||||||
|
* } $options
|
||||||
|
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function reconcile(array $options = []): array
|
||||||
|
{
|
||||||
|
$types = array_values(array_filter(
|
||||||
|
$options['types'] ?? $this->policy->coveredTypeNames(),
|
||||||
|
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||||
|
));
|
||||||
|
$tenantIds = array_values(array_filter(
|
||||||
|
$options['tenant_ids'] ?? [],
|
||||||
|
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
|
||||||
|
));
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
$options['workspace_ids'] ?? [],
|
||||||
|
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
|
||||||
|
$dryRun = (bool) ($options['dry_run'] ?? false);
|
||||||
|
|
||||||
|
$runs = OperationRun::query()
|
||||||
|
->with(['tenant', 'user'])
|
||||||
|
->whereIn('type', $types)
|
||||||
|
->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])
|
||||||
|
->when(
|
||||||
|
$tenantIds !== [],
|
||||||
|
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$workspaceIds !== [],
|
||||||
|
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
|
||||||
|
)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
$reconciled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($runs as $run) {
|
||||||
|
$change = $this->reconcileRun($run, $dryRun);
|
||||||
|
|
||||||
|
if ($change === null) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[] = $change;
|
||||||
|
|
||||||
|
if (($change['applied'] ?? false) === true) {
|
||||||
|
$reconciled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'candidates' => $runs->count(),
|
||||||
|
'reconciled' => $reconciled,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'changes' => $changes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
|
||||||
|
{
|
||||||
|
$assessment = $this->assessment($run);
|
||||||
|
|
||||||
|
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = [
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
|
||||||
|
];
|
||||||
|
$after = [
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return [
|
||||||
|
'applied' => false,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'type' => (string) $run->type,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
'reason_code' => $assessment['reason_code'],
|
||||||
|
'reason_message' => $assessment['reason_message'],
|
||||||
|
'evidence' => $assessment['evidence'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->operationRunService->forceFailNonTerminalRun(
|
||||||
|
run: $run,
|
||||||
|
reasonCode: (string) $assessment['reason_code'],
|
||||||
|
message: (string) $assessment['reason_message'],
|
||||||
|
source: 'scheduled_reconciler',
|
||||||
|
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'applied' => true,
|
||||||
|
'operation_run_id' => (int) $updated->getKey(),
|
||||||
|
'type' => (string) $updated->type,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
'reason_code' => $assessment['reason_code'],
|
||||||
|
'reason_message' => $assessment['reason_message'],
|
||||||
|
'evidence' => $assessment['evidence'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
public function assessment(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
|
||||||
|
|
||||||
|
if (! $freshnessState->isLikelyStale()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? LifecycleReconciliationReason::StaleQueued
|
||||||
|
: LifecycleReconciliationReason::StaleRunning;
|
||||||
|
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? $run->created_at
|
||||||
|
: ($run->started_at ?? $run->created_at);
|
||||||
|
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
|
||||||
|
: $this->policy->runningStaleAfterSeconds((string) $run->type);
|
||||||
|
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'should_reconcile' => true,
|
||||||
|
'reason_code' => $reason->value,
|
||||||
|
'reason_message' => $reason->defaultMessage(),
|
||||||
|
'evidence' => [
|
||||||
|
'evaluated_at' => now()->toIso8601String(),
|
||||||
|
'freshness_state' => $freshnessState->value,
|
||||||
|
'threshold_seconds' => $thresholdSeconds,
|
||||||
|
'reference_time' => $referenceTime?->toIso8601String(),
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'execution_legitimacy' => $legitimacy,
|
||||||
|
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
|
||||||
|
? 'direct_and_scheduled'
|
||||||
|
: 'scheduled_only',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,9 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
BadgeDomain::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,6 +11,9 @@ 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';
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
|
||||||
|
final class BaselineSnapshotLifecycleBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
BaselineSnapshotLifecycleState::Building->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-arrow-path'),
|
||||||
|
BaselineSnapshotLifecycleState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-check-circle'),
|
||||||
|
BaselineSnapshotLifecycleState::Incomplete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-x-circle'),
|
||||||
|
'superseded' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-clock'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,12 +8,46 @@
|
|||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\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 = BadgeCatalog::normalizeState($value);
|
$state = null;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
|
||||||
|
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||||
|
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||||
|
|
||||||
|
if ($outcome === null) {
|
||||||
|
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
|
||||||
|
$outcome = OperationRunOutcome::Failed->value;
|
||||||
|
} elseif (
|
||||||
|
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||||
|
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||||
|
) {
|
||||||
|
$outcome = OperationRunOutcome::Pending->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($outcome === OperationRunOutcome::Failed->value
|
||||||
|
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
|
||||||
|
) {
|
||||||
|
return new BadgeSpec(
|
||||||
|
label: 'Reconciled failed',
|
||||||
|
color: 'danger',
|
||||||
|
icon: 'heroicon-m-arrow-path-rounded-square',
|
||||||
|
iconColor: 'danger',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state ??= BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
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,12 +8,33 @@
|
|||||||
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 = BadgeCatalog::normalizeState($value);
|
$state = null;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||||
|
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||||
|
|
||||||
|
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||||
|
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||||
|
) {
|
||||||
|
return new BadgeSpec(
|
||||||
|
label: 'Likely stale',
|
||||||
|
color: 'warning',
|
||||||
|
icon: 'heroicon-m-exclamation-triangle',
|
||||||
|
iconColor: 'warning',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state ??= BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
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'),
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
|
final class OperatorExplanationEvaluationResultBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
||||||
|
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
||||||
|
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
||||||
|
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
|
final class OperatorExplanationTrustworthinessBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'trustworthy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-check-badge'),
|
||||||
|
'limited_confidence' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'diagnostic_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-beaker'),
|
||||||
|
'unusable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-x-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,7 +71,7 @@ final class OperatorOutcomeTaxonomy
|
|||||||
],
|
],
|
||||||
'partial' => [
|
'partial' => [
|
||||||
'axis' => 'data_coverage',
|
'axis' => 'data_coverage',
|
||||||
'label' => 'Partial',
|
'label' => 'Partially complete',
|
||||||
'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' => 'Stale',
|
'label' => 'Refresh recommended',
|
||||||
'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' => 'Blocked',
|
'label' => 'Publication blocked',
|
||||||
'color' => 'warning',
|
'color' => 'warning',
|
||||||
'classification' => 'primary',
|
'classification' => 'primary',
|
||||||
'next_action_policy' => 'required',
|
'next_action_policy' => 'required',
|
||||||
@ -220,6 +220,129 @@ final class OperatorOutcomeTaxonomy
|
|||||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
'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',
|
||||||
|
|||||||
203
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
203
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<?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,6 +4,9 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -22,4 +25,37 @@ 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,13 +5,17 @@
|
|||||||
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
|
||||||
@ -73,7 +77,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
|
|
||||||
$profileName = (string) $profile->name;
|
$profileName = (string) $profile->name;
|
||||||
$profileId = (int) $profile->getKey();
|
$profileId = (int) $profile->getKey();
|
||||||
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||||
|
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||||
|
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||||
|
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||||
|
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||||
|
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
@ -86,12 +94,21 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||||
|
|
||||||
if ($snapshotId === null) {
|
if ($snapshotId === null) {
|
||||||
return self::empty(
|
return new self(
|
||||||
'no_snapshot',
|
state: 'no_snapshot',
|
||||||
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,6 +308,11 @@ 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()
|
||||||
@ -314,11 +336,11 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
state: $totalFindings > 0 ? 'ready' : 'idle',
|
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
|
||||||
message: null,
|
message: $snapshotId === null ? $snapshotReasonMessage : null,
|
||||||
profileName: (string) $profile->name,
|
profileName: (string) $profile->name,
|
||||||
profileId: (int) $profile->getKey(),
|
profileId: (int) $profile->getKey(),
|
||||||
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: null,
|
duplicateNamePoliciesCount: null,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
@ -330,6 +352,8 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -561,6 +585,31 @@ 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,
|
||||||
@ -583,4 +632,15 @@ 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,13 +18,125 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
app/Support/Baselines/BaselineSnapshotLifecycleState.php
Normal file
42
app/Support/Baselines/BaselineSnapshotLifecycleState.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
enum BaselineSnapshotLifecycleState: string
|
||||||
|
{
|
||||||
|
case Building = 'building';
|
||||||
|
case Complete = 'complete';
|
||||||
|
case Incomplete = 'incomplete';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Building => 'Building',
|
||||||
|
self::Complete => 'Complete',
|
||||||
|
self::Incomplete => 'Incomplete',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConsumable(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTerminal(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::Complete, self::Incomplete], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (self $state): string => $state->value,
|
||||||
|
self::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Support/Filament/PanelThemeAsset.php
Normal file
27
app/Support/Filament/PanelThemeAsset.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Filament;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Vite;
|
||||||
|
|
||||||
|
class PanelThemeAsset
|
||||||
|
{
|
||||||
|
public static function resolve(string $entry): string
|
||||||
|
{
|
||||||
|
$manifest = public_path('build/manifest.json');
|
||||||
|
|
||||||
|
if (! is_file($manifest)) {
|
||||||
|
return Vite::asset($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, array{file?: string}>|null $decoded */
|
||||||
|
$decoded = json_decode((string) file_get_contents($manifest), true);
|
||||||
|
$file = $decoded[$entry]['file'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($file) || $file === '') {
|
||||||
|
return Vite::asset($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset('build/'.$file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
|
snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
|
||||||
workspaceId: (int) $profile->workspace_id,
|
workspaceId: (int) $profile->workspace_id,
|
||||||
),
|
),
|
||||||
default => null,
|
default => null,
|
||||||
|
|||||||
@ -121,4 +121,12 @@ 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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
app/Support/Operations/LifecycleReconciliationReason.php
Normal file
83
app/Support/Operations/LifecycleReconciliationReason.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
|
||||||
|
enum LifecycleReconciliationReason: string
|
||||||
|
{
|
||||||
|
case StaleQueued = 'run.stale_queued';
|
||||||
|
case StaleRunning = 'run.stale_running';
|
||||||
|
case InfrastructureTimeoutOrAbandonment = 'run.infrastructure_timeout_or_abandonment';
|
||||||
|
case QueueFailureBridge = 'run.queue_failure_bridge';
|
||||||
|
case AdapterOutOfSync = 'run.adapter_out_of_sync';
|
||||||
|
|
||||||
|
public function operatorLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::StaleQueued => 'Run never started',
|
||||||
|
self::StaleRunning => 'Run stopped reporting progress',
|
||||||
|
self::InfrastructureTimeoutOrAbandonment => 'Infrastructure ended the run',
|
||||||
|
self::QueueFailureBridge => 'Queue failure was reconciled',
|
||||||
|
self::AdapterOutOfSync => 'Lifecycle was reconciled from related records',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shortExplanation(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::StaleQueued => 'The run stayed queued past its lifecycle window and was marked failed.',
|
||||||
|
self::StaleRunning => 'The run stayed active past its lifecycle window and was marked failed.',
|
||||||
|
self::InfrastructureTimeoutOrAbandonment => 'Queue infrastructure ended the job before normal completion could update the run.',
|
||||||
|
self::QueueFailureBridge => 'The platform bridged a queue failure back to the owning run and marked it failed.',
|
||||||
|
self::AdapterOutOfSync => 'A related restore record reached terminal truth before the operation run was updated.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actionability(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::AdapterOutOfSync => 'non_actionable',
|
||||||
|
default => 'retryable_transient',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, NextStepOption>
|
||||||
|
*/
|
||||||
|
public function nextSteps(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::AdapterOutOfSync => [
|
||||||
|
NextStepOption::instruction('Review the related restore record before deciding whether to run the workflow again.'),
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
NextStepOption::instruction('Review worker health and logs before retrying this operation.'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultMessage(): string
|
||||||
|
{
|
||||||
|
return $this->shortExplanation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
return new ReasonResolutionEnvelope(
|
||||||
|
internalCode: $this->value,
|
||||||
|
operatorLabel: $this->operatorLabel(),
|
||||||
|
shortExplanation: $this->shortExplanation(),
|
||||||
|
actionability: $this->actionability(),
|
||||||
|
nextSteps: $this->nextSteps(),
|
||||||
|
showNoActionNeeded: false,
|
||||||
|
diagnosticCodeLabel: $this->value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Support/Operations/OperationLifecyclePolicy.php
Normal file
152
app/Support/Operations/OperationLifecyclePolicy.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
final class OperationLifecyclePolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array{
|
||||||
|
* job_class?: class-string,
|
||||||
|
* queued_stale_after_seconds?: int,
|
||||||
|
* running_stale_after_seconds?: int,
|
||||||
|
* expected_max_runtime_seconds?: int,
|
||||||
|
* direct_failed_bridge?: bool,
|
||||||
|
* scheduled_reconciliation?: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function coveredTypes(): array
|
||||||
|
{
|
||||||
|
$coveredTypes = config('tenantpilot.operations.lifecycle.covered_types', []);
|
||||||
|
|
||||||
|
return is_array($coveredTypes) ? $coveredTypes : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* job_class?: class-string,
|
||||||
|
* queued_stale_after_seconds:int,
|
||||||
|
* running_stale_after_seconds:int,
|
||||||
|
* expected_max_runtime_seconds:?int,
|
||||||
|
* direct_failed_bridge:bool,
|
||||||
|
* scheduled_reconciliation:bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function definition(string $operationType): ?array
|
||||||
|
{
|
||||||
|
$operationType = trim($operationType);
|
||||||
|
|
||||||
|
if ($operationType === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $this->coveredTypes()[$operationType] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($definition)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'job_class' => is_string($definition['job_class'] ?? null) ? $definition['job_class'] : null,
|
||||||
|
'queued_stale_after_seconds' => max(1, (int) ($definition['queued_stale_after_seconds'] ?? 300)),
|
||||||
|
'running_stale_after_seconds' => max(1, (int) ($definition['running_stale_after_seconds'] ?? 900)),
|
||||||
|
'expected_max_runtime_seconds' => is_numeric($definition['expected_max_runtime_seconds'] ?? null)
|
||||||
|
? max(1, (int) $definition['expected_max_runtime_seconds'])
|
||||||
|
: null,
|
||||||
|
'direct_failed_bridge' => (bool) ($definition['direct_failed_bridge'] ?? false),
|
||||||
|
'scheduled_reconciliation' => (bool) ($definition['scheduled_reconciliation'] ?? true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string $operationType): bool
|
||||||
|
{
|
||||||
|
return $this->definition($operationType) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function coveredTypeNames(): array
|
||||||
|
{
|
||||||
|
return array_values(array_keys($this->coveredTypes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queuedStaleAfterSeconds(string $operationType): int
|
||||||
|
{
|
||||||
|
return (int) ($this->definition($operationType)['queued_stale_after_seconds'] ?? 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runningStaleAfterSeconds(string $operationType): int
|
||||||
|
{
|
||||||
|
return (int) ($this->definition($operationType)['running_stale_after_seconds'] ?? 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expectedMaxRuntimeSeconds(string $operationType): ?int
|
||||||
|
{
|
||||||
|
$expectedMaxRuntimeSeconds = $this->definition($operationType)['expected_max_runtime_seconds'] ?? null;
|
||||||
|
|
||||||
|
return is_int($expectedMaxRuntimeSeconds) ? $expectedMaxRuntimeSeconds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresDirectFailedBridge(string $operationType): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->definition($operationType)['direct_failed_bridge'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsScheduledReconciliation(string $operationType): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->definition($operationType)['scheduled_reconciliation'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reconciliationBatchLimit(): int
|
||||||
|
{
|
||||||
|
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.batch_limit', 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reconciliationScheduleMinutes(): int
|
||||||
|
{
|
||||||
|
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.schedule_minutes', 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryAfterSafetyMarginSeconds(): int
|
||||||
|
{
|
||||||
|
return max(1, (int) config('queue.lifecycle_invariants.retry_after_safety_margin', 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queueConnection(string $operationType): ?string
|
||||||
|
{
|
||||||
|
$jobClass = $this->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = Arr::get(get_class_vars($jobClass), 'connection');
|
||||||
|
|
||||||
|
return is_string($connection) && trim($connection) !== '' ? trim($connection) : config('queue.default');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queueRetryAfterSeconds(?string $connection = null): ?int
|
||||||
|
{
|
||||||
|
$connection = is_string($connection) && trim($connection) !== '' ? trim($connection) : (string) config('queue.default', 'database');
|
||||||
|
$retryAfter = config("queue.connections.{$connection}.retry_after");
|
||||||
|
|
||||||
|
if (is_numeric($retryAfter)) {
|
||||||
|
return max(1, (int) $retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$databaseRetryAfter = config('queue.connections.database.retry_after');
|
||||||
|
|
||||||
|
return is_numeric($databaseRetryAfter) ? max(1, (int) $databaseRetryAfter) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobClass(string $operationType): ?string
|
||||||
|
{
|
||||||
|
$jobClass = $this->definition($operationType)['job_class'] ?? null;
|
||||||
|
|
||||||
|
return is_string($jobClass) && $jobClass !== '' ? $jobClass : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Support\Operations;
|
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);
|
||||||
|
|||||||
69
app/Support/Operations/OperationRunFreshnessState.php
Normal file
69
app/Support/Operations/OperationRunFreshnessState.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
|
||||||
|
enum OperationRunFreshnessState: string
|
||||||
|
{
|
||||||
|
case FreshActive = 'fresh_active';
|
||||||
|
case LikelyStale = 'likely_stale';
|
||||||
|
case ReconciledFailed = 'reconciled_failed';
|
||||||
|
case TerminalNormal = 'terminal_normal';
|
||||||
|
case Unknown = 'unknown';
|
||||||
|
|
||||||
|
public static function forRun(OperationRun $run, ?OperationLifecyclePolicy $policy = null): self
|
||||||
|
{
|
||||||
|
$policy ??= app(OperationLifecyclePolicy::class);
|
||||||
|
|
||||||
|
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||||
|
return $run->isLifecycleReconciled() ? self::ReconciledFailed : self::TerminalNormal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $policy->supports((string) $run->type)) {
|
||||||
|
return self::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
||||||
|
if ($run->started_at !== null || $run->created_at === null) {
|
||||||
|
return self::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run->created_at->lte(now()->subSeconds($policy->queuedStaleAfterSeconds((string) $run->type)))
|
||||||
|
? self::LikelyStale
|
||||||
|
: self::FreshActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $run->status === OperationRunStatus::Running->value) {
|
||||||
|
$startedAt = $run->started_at ?? $run->created_at;
|
||||||
|
|
||||||
|
if ($startedAt === null) {
|
||||||
|
return self::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $startedAt->lte(now()->subSeconds($policy->runningStaleAfterSeconds((string) $run->type)))
|
||||||
|
? self::LikelyStale
|
||||||
|
: self::FreshActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFreshActive(): bool
|
||||||
|
{
|
||||||
|
return $this === self::FreshActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLikelyStale(): bool
|
||||||
|
{
|
||||||
|
return $this === self::LikelyStale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReconciledFailed(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ReconciledFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,8 +7,11 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\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
|
||||||
@ -98,11 +101,33 @@ 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 (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
if ($freshnessState->isLikelyStale()) {
|
||||||
|
return 'This run is past its lifecycle window. Review worker health and logs before retrying from the start surface.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($freshnessState->isReconciledFailed()) {
|
||||||
|
return $operatorExplanationGuidance
|
||||||
|
?? $reasonGuidance
|
||||||
|
?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true)) {
|
||||||
|
if ($operatorExplanationGuidance !== null) {
|
||||||
|
return $operatorExplanationGuidance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonGuidance !== null) {
|
||||||
return $reasonGuidance;
|
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.',
|
||||||
@ -124,15 +149,44 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
if (self::freshnessState($run)->isLikelyStale()) {
|
||||||
|
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
|
||||||
|
}
|
||||||
|
|
||||||
return self::sanitizeFailureMessage($failureMessage);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function freshnessState(OperationRun $run): OperationRunFreshnessState
|
||||||
|
{
|
||||||
|
return $run->freshnessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return match (self::freshnessState($run)) {
|
||||||
|
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||||
|
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -142,6 +196,15 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
{
|
{
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$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' => [
|
||||||
@ -223,4 +286,32 @@ 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,6 +6,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -118,6 +119,10 @@ 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,7 +3,9 @@
|
|||||||
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
|
||||||
@ -130,7 +132,15 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
|
|||||||
ExecutionDenialReasonCode::cases(),
|
ExecutionDenialReasonCode::cases(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
$lifecycleReasonCodes = array_map(
|
||||||
|
static fn (LifecycleReconciliationReason $reasonCode): string => $reasonCode->value,
|
||||||
|
LifecycleReconciliationReason::cases(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ProviderReasonCodes::isKnown($candidate)
|
||||||
|
|| BaselineReasonCodes::isKnown($candidate)
|
||||||
|
|| in_array($candidate, $executionDenialReasonCodes, true)
|
||||||
|
|| in_array($candidate, $lifecycleReasonCodes, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function sanitizeMessage(string $message): string
|
public static function sanitizeMessage(string $message): string
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
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
|
||||||
@ -43,6 +44,8 @@ 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,4 +112,36 @@ 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,6 +8,7 @@
|
|||||||
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;
|
||||||
@ -24,14 +25,16 @@ 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 = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
|
$storedTranslation = $this->storedOperationRunTranslation($context);
|
||||||
|
|
||||||
if ($storedTranslation !== null) {
|
if ($storedTranslation !== null) {
|
||||||
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
||||||
|
|
||||||
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
||||||
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
|
$nextSteps = $this->operationRunNextSteps($context);
|
||||||
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
|
|
||||||
|
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
|
||||||
|
return $storedEnvelope->withNextSteps($nextSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $storedEnvelope;
|
return $storedEnvelope;
|
||||||
@ -39,7 +42,8 @@ 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);
|
||||||
@ -67,11 +71,33 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
|||||||
return $envelope;
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
$legacyNextSteps = $this->operationRunNextSteps($context);
|
||||||
|
|
||||||
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
|
||||||
*/
|
*/
|
||||||
@ -91,6 +117,7 @@ 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;
|
||||||
}
|
}
|
||||||
@ -167,6 +194,26 @@ 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,6 +4,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -19,6 +20,8 @@ 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.');
|
||||||
@ -41,6 +44,24 @@ 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.');
|
||||||
@ -70,6 +91,12 @@ 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;
|
||||||
@ -83,6 +110,8 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,6 +128,8 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,6 +210,8 @@ 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
|
||||||
@ -194,6 +227,8 @@ 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,11 +4,15 @@
|
|||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@ -43,8 +47,13 @@ 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,
|
||||||
@ -74,4 +83,184 @@ 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,6 +6,7 @@
|
|||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@ -18,6 +19,8 @@ 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 = [],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -35,6 +38,8 @@ 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,
|
||||||
@ -42,6 +47,23 @@ public static function fromReasonResolutionEnvelope(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
return new ReasonResolutionEnvelope(
|
||||||
|
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
|
||||||
|
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
|
||||||
|
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
|
||||||
|
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing',
|
||||||
|
nextSteps: array_map(
|
||||||
|
static fn (string $label): NextStepOption => NextStepOption::instruction($label),
|
||||||
|
$this->nextSteps,
|
||||||
|
),
|
||||||
|
diagnosticCodeLabel: $this->diagnosticCode,
|
||||||
|
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||||
|
absencePattern: $this->absencePattern,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* reasonCode: ?string,
|
* reasonCode: ?string,
|
||||||
@ -49,6 +71,8 @@ public static function fromReasonResolutionEnvelope(
|
|||||||
* operatorLabel: ?string,
|
* operatorLabel: ?string,
|
||||||
* shortExplanation: ?string,
|
* shortExplanation: ?string,
|
||||||
* diagnosticCode: ?string,
|
* diagnosticCode: ?string,
|
||||||
|
* trustImpact: string,
|
||||||
|
* absencePattern: ?string,
|
||||||
* nextSteps: array<int, string>
|
* nextSteps: array<int, string>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@ -60,6 +84,8 @@ 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,6 +5,7 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -32,6 +33,7 @@ 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
|
||||||
@ -99,8 +101,11 @@ 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
|
||||||
@ -132,6 +137,7 @@ public function toArray(): array
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
'reason' => $this->reason?->toArray(),
|
'reason' => $this->reason?->toArray(),
|
||||||
|
'operatorExplanation' => $this->operatorExplanation?->toArray(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
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;
|
||||||
@ -20,17 +21,22 @@
|
|||||||
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
|
||||||
@ -52,38 +58,49 @@ 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);
|
||||||
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
$effectiveSnapshot = $snapshot->baselineProfile !== null
|
||||||
&& $snapshot->baselineProfile !== null;
|
? $this->snapshotTruthResolver->resolveEffectiveSnapshot($snapshot->baselineProfile)
|
||||||
|
: null;
|
||||||
|
$isHistorical = $this->snapshotTruthResolver->isHistoricallySuperseded($snapshot, $effectiveSnapshot);
|
||||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
$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->firstReasonCode($severeGapReasons);
|
$reasonCode = $this->snapshotTruthResolver->artifactReasonCode($snapshot, $effectiveSnapshot)
|
||||||
|
?? $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',
|
||||||
! $hasItems => 'created_but_not_usable',
|
$snapshot->isBuilding(), $snapshot->isIncomplete() => 'created_but_not_usable',
|
||||||
|
! $snapshot->isConsumable() => 'created_but_not_usable',
|
||||||
default => 'created',
|
default => 'created',
|
||||||
};
|
};
|
||||||
|
|
||||||
$contentState = match ($fidelity) {
|
$contentState = match ($fidelity) {
|
||||||
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
FidelityState::Full => $snapshot->isIncomplete()
|
||||||
FidelityState::Partial => 'partial',
|
? ($hasItems ? 'partial' : 'missing_input')
|
||||||
FidelityState::ReferenceOnly => 'reference_only',
|
: ($severeGapReasons === [] ? 'trusted' : 'partial'),
|
||||||
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
FidelityState::Partial => $snapshot->isBuilding() ? 'missing_input' : 'partial',
|
||||||
|
FidelityState::ReferenceOnly => $snapshot->isBuilding() ? 'missing_input' : 'reference_only',
|
||||||
|
FidelityState::Unsupported => $snapshot->isBuilding() ? 'missing_input' : ($hasItems ? 'unsupported' : 'trusted'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (! $hasItems && $reasonCode !== null) {
|
if (($snapshot->isBuilding() || $snapshot->isIncomplete()) && $reasonCode !== null) {
|
||||||
$contentState = 'missing_input';
|
$contentState = 'missing_input';
|
||||||
}
|
}
|
||||||
|
|
||||||
$freshnessState = $isHistorical ? 'stale' : 'current';
|
$freshnessState = match (true) {
|
||||||
|
$snapshot->isBuilding() => 'unknown',
|
||||||
|
$isHistorical => 'stale',
|
||||||
|
default => 'current',
|
||||||
|
};
|
||||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
$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',
|
||||||
@ -94,26 +111,30 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
|||||||
$artifactExistence === 'historical_only' => [
|
$artifactExistence === 'historical_only' => [
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
'historical_only',
|
'historical_only',
|
||||||
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
$reason?->shortExplanation ?? 'This snapshot remains readable for history, but a newer complete snapshot is the current baseline truth.',
|
||||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label,
|
||||||
],
|
],
|
||||||
$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.',
|
||||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||||
],
|
],
|
||||||
$contentState !== 'trusted' => [
|
$contentState !== 'trusted' => [
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
$contentState,
|
$contentState,
|
||||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
$supportState === 'limited_support'
|
||||||
|
? 'Support limited'
|
||||||
|
: BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
'trusted',
|
'trusted',
|
||||||
'Structured capture content is available for this baseline snapshot.',
|
$hasItems
|
||||||
null,
|
? 'Structured capture content is available for this baseline snapshot.'
|
||||||
|
: 'This empty baseline snapshot completed successfully and can still be used for compare.',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -147,6 +168,19 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
|||||||
relatedRunId: null,
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,6 +304,25 @@ 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,6 +452,24 @@ 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,6 +590,24 @@ 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,6 +649,10 @@ 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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -601,18 +694,16 @@ 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: $this->nextActionLabel(
|
nextActionLabel: $reason?->firstNextStep()?->label
|
||||||
$actionability,
|
?? ($actionability === 'required'
|
||||||
$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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -698,6 +789,7 @@ 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 = [
|
||||||
@ -731,7 +823,7 @@ classification: 'diagnostic',
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ArtifactTruthEnvelope(
|
$draftEnvelope = new ArtifactTruthEnvelope(
|
||||||
artifactFamily: $artifactFamily,
|
artifactFamily: $artifactFamily,
|
||||||
artifactKey: $artifactKey,
|
artifactKey: $artifactKey,
|
||||||
workspaceId: $workspaceId,
|
workspaceId: $workspaceId,
|
||||||
@ -753,6 +845,30 @@ 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(
|
||||||
@ -770,4 +886,31 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\OperatorExplanation;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class CountDescriptor
|
||||||
|
{
|
||||||
|
public const string ROLE_EXECUTION = 'execution';
|
||||||
|
|
||||||
|
public const string ROLE_EVALUATION_OUTPUT = 'evaluation_output';
|
||||||
|
|
||||||
|
public const string ROLE_COVERAGE = 'coverage';
|
||||||
|
|
||||||
|
public const string ROLE_RELIABILITY_SIGNAL = 'reliability_signal';
|
||||||
|
|
||||||
|
public const string VISIBILITY_PRIMARY = 'primary';
|
||||||
|
|
||||||
|
public const string VISIBILITY_DIAGNOSTIC = 'diagnostic';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $label,
|
||||||
|
public int $value,
|
||||||
|
public string $role,
|
||||||
|
public ?string $qualifier = null,
|
||||||
|
public string $visibilityTier = self::VISIBILITY_PRIMARY,
|
||||||
|
) {
|
||||||
|
if (trim($this->label) === '') {
|
||||||
|
throw new InvalidArgumentException('Count descriptors require a label.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->role, [
|
||||||
|
self::ROLE_EXECUTION,
|
||||||
|
self::ROLE_EVALUATION_OUTPUT,
|
||||||
|
self::ROLE_COVERAGE,
|
||||||
|
self::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported count descriptor role: '.$this->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->visibilityTier, [self::VISIBILITY_PRIMARY, self::VISIBILITY_DIAGNOSTIC], true)) {
|
||||||
|
throw new InvalidArgumentException('Unsupported count descriptor visibility tier: '.$this->visibilityTier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* label: string,
|
||||||
|
* value: int,
|
||||||
|
* role: string,
|
||||||
|
* qualifier: ?string,
|
||||||
|
* visibilityTier: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => $this->label,
|
||||||
|
'value' => $this->value,
|
||||||
|
'role' => $this->role,
|
||||||
|
'qualifier' => $this->qualifier,
|
||||||
|
'visibilityTier' => $this->visibilityTier,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\OperatorExplanation;
|
||||||
|
|
||||||
|
enum ExplanationFamily: string
|
||||||
|
{
|
||||||
|
case TrustworthyResult = 'trustworthy_result';
|
||||||
|
case NoIssuesDetected = 'no_issues_detected';
|
||||||
|
case CompletedButLimited = 'completed_but_limited';
|
||||||
|
case SuppressedOutput = 'suppressed_output';
|
||||||
|
case MissingInput = 'missing_input';
|
||||||
|
case BlockedPrerequisite = 'blocked_prerequisite';
|
||||||
|
case Unavailable = 'unavailable';
|
||||||
|
case InProgress = 'in_progress';
|
||||||
|
}
|
||||||
@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\OperatorExplanation;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
|
||||||
|
final class OperatorExplanationBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, CountDescriptor> $countDescriptors
|
||||||
|
*/
|
||||||
|
public function build(
|
||||||
|
ExplanationFamily $family,
|
||||||
|
string $headline,
|
||||||
|
string $executionOutcome,
|
||||||
|
string $executionOutcomeLabel,
|
||||||
|
string $evaluationResult,
|
||||||
|
TrustworthinessLevel $trustworthinessLevel,
|
||||||
|
string $reliabilityStatement,
|
||||||
|
?string $coverageStatement,
|
||||||
|
?string $dominantCauseCode,
|
||||||
|
?string $dominantCauseLabel,
|
||||||
|
?string $dominantCauseExplanation,
|
||||||
|
string $nextActionCategory,
|
||||||
|
string $nextActionText,
|
||||||
|
array $countDescriptors = [],
|
||||||
|
bool $diagnosticsAvailable = false,
|
||||||
|
?string $diagnosticsSummary = null,
|
||||||
|
): OperatorExplanationPattern {
|
||||||
|
return new OperatorExplanationPattern(
|
||||||
|
family: $family,
|
||||||
|
headline: $headline,
|
||||||
|
executionOutcome: $executionOutcome,
|
||||||
|
executionOutcomeLabel: $executionOutcomeLabel,
|
||||||
|
evaluationResult: $evaluationResult,
|
||||||
|
trustworthinessLevel: $trustworthinessLevel,
|
||||||
|
reliabilityStatement: $reliabilityStatement,
|
||||||
|
coverageStatement: $coverageStatement,
|
||||||
|
dominantCauseCode: $dominantCauseCode,
|
||||||
|
dominantCauseLabel: $dominantCauseLabel,
|
||||||
|
dominantCauseExplanation: $dominantCauseExplanation,
|
||||||
|
nextActionCategory: $nextActionCategory,
|
||||||
|
nextActionText: $nextActionText,
|
||||||
|
countDescriptors: $countDescriptors,
|
||||||
|
diagnosticsAvailable: $diagnosticsAvailable,
|
||||||
|
diagnosticsSummary: $diagnosticsSummary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, CountDescriptor> $countDescriptors
|
||||||
|
*/
|
||||||
|
public function fromArtifactTruthEnvelope(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
array $countDescriptors = [],
|
||||||
|
): OperatorExplanationPattern {
|
||||||
|
$reason = $truth->reason?->toReasonResolutionEnvelope();
|
||||||
|
$family = $this->familyForTruth($truth, $reason);
|
||||||
|
$trustworthiness = $this->trustworthinessForTruth($truth, $reason);
|
||||||
|
$evaluationResult = $this->evaluationResultForTruth($truth, $family);
|
||||||
|
$executionOutcome = $this->executionOutcomeKey($truth->executionOutcome);
|
||||||
|
$executionOutcomeLabel = $this->executionOutcomeLabel($truth->executionOutcome);
|
||||||
|
$dominantCauseCode = $reason?->internalCode;
|
||||||
|
$dominantCauseLabel = $reason?->operatorLabel ?? $truth->primaryLabel;
|
||||||
|
$dominantCauseExplanation = $reason?->shortExplanation ?? $truth->primaryExplanation;
|
||||||
|
$headline = $this->headlineForTruth($truth, $family, $trustworthiness);
|
||||||
|
$reliabilityStatement = $this->reliabilityStatementForTruth($truth, $trustworthiness);
|
||||||
|
$coverageStatement = $this->coverageStatementForTruth($truth, $reason);
|
||||||
|
$nextActionText = $truth->nextStepText();
|
||||||
|
$nextActionCategory = $this->nextActionCategory($truth->actionability, $reason);
|
||||||
|
$diagnosticsAvailable = $truth->reason !== null
|
||||||
|
|| $truth->diagnosticLabel !== null
|
||||||
|
|| $countDescriptors !== [];
|
||||||
|
|
||||||
|
return $this->build(
|
||||||
|
family: $family,
|
||||||
|
headline: $headline,
|
||||||
|
executionOutcome: $executionOutcome,
|
||||||
|
executionOutcomeLabel: $executionOutcomeLabel,
|
||||||
|
evaluationResult: $evaluationResult,
|
||||||
|
trustworthinessLevel: $trustworthiness,
|
||||||
|
reliabilityStatement: $reliabilityStatement,
|
||||||
|
coverageStatement: $coverageStatement,
|
||||||
|
dominantCauseCode: $dominantCauseCode,
|
||||||
|
dominantCauseLabel: $dominantCauseLabel,
|
||||||
|
dominantCauseExplanation: $dominantCauseExplanation,
|
||||||
|
nextActionCategory: $nextActionCategory,
|
||||||
|
nextActionText: $nextActionText,
|
||||||
|
countDescriptors: $countDescriptors,
|
||||||
|
diagnosticsAvailable: $diagnosticsAvailable,
|
||||||
|
diagnosticsSummary: $diagnosticsAvailable
|
||||||
|
? 'Technical truth detail remains available below the primary explanation.'
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function familyForTruth(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
?ReasonResolutionEnvelope $reason,
|
||||||
|
): ExplanationFamily {
|
||||||
|
return match (true) {
|
||||||
|
$reason?->absencePattern === 'suppressed_output' => ExplanationFamily::SuppressedOutput,
|
||||||
|
$reason?->absencePattern === 'blocked_prerequisite' => ExplanationFamily::BlockedPrerequisite,
|
||||||
|
$truth->executionOutcome === 'pending' || $truth->artifactExistence === 'not_created' && $truth->actionability !== 'required' => ExplanationFamily::InProgress,
|
||||||
|
$truth->executionOutcome === 'failed' || $truth->executionOutcome === 'blocked' => ExplanationFamily::BlockedPrerequisite,
|
||||||
|
$truth->artifactExistence === 'created_but_not_usable' || $truth->contentState === 'missing_input' => ExplanationFamily::MissingInput,
|
||||||
|
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' && $truth->primaryLabel === 'Trustworthy artifact' => ExplanationFamily::TrustworthyResult,
|
||||||
|
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => ExplanationFamily::NoIssuesDetected,
|
||||||
|
$truth->artifactExistence === 'historical_only' => ExplanationFamily::Unavailable,
|
||||||
|
default => ExplanationFamily::CompletedButLimited,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trustworthinessForTruth(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
?ReasonResolutionEnvelope $reason,
|
||||||
|
): TrustworthinessLevel {
|
||||||
|
if ($reason?->trustImpact !== null) {
|
||||||
|
return TrustworthinessLevel::tryFrom($reason->trustImpact) ?? TrustworthinessLevel::LimitedConfidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$truth->artifactExistence === 'created_but_not_usable',
|
||||||
|
$truth->contentState === 'missing_input',
|
||||||
|
$truth->executionOutcome === 'failed',
|
||||||
|
$truth->executionOutcome === 'blocked' => TrustworthinessLevel::Unusable,
|
||||||
|
$truth->supportState === 'limited_support',
|
||||||
|
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => TrustworthinessLevel::DiagnosticOnly,
|
||||||
|
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => TrustworthinessLevel::Trustworthy,
|
||||||
|
default => TrustworthinessLevel::LimitedConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluationResultForTruth(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
ExplanationFamily $family,
|
||||||
|
): string {
|
||||||
|
return match ($family) {
|
||||||
|
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable => 'unavailable',
|
||||||
|
ExplanationFamily::InProgress => 'unavailable',
|
||||||
|
ExplanationFamily::CompletedButLimited => 'incomplete_result',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function executionOutcomeKey(?string $executionOutcome): string
|
||||||
|
{
|
||||||
|
$normalized = BadgeCatalog::normalizeState($executionOutcome);
|
||||||
|
|
||||||
|
return match ($normalized) {
|
||||||
|
'queued', 'running', 'pending' => 'in_progress',
|
||||||
|
'partially_succeeded' => 'completed_with_follow_up',
|
||||||
|
'blocked' => 'blocked',
|
||||||
|
'failed' => 'failed',
|
||||||
|
default => 'completed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function executionOutcomeLabel(?string $executionOutcome): string
|
||||||
|
{
|
||||||
|
if (! is_string($executionOutcome) || trim($executionOutcome) === '') {
|
||||||
|
return 'Completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $executionOutcome);
|
||||||
|
|
||||||
|
return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim($executionOutcome)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function headlineForTruth(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
ExplanationFamily $family,
|
||||||
|
TrustworthinessLevel $trustworthiness,
|
||||||
|
): string {
|
||||||
|
return match ($family) {
|
||||||
|
ExplanationFamily::TrustworthyResult => 'The result is ready to use.',
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'No follow-up was detected from this result.',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'The run completed, but normal output was intentionally suppressed.',
|
||||||
|
ExplanationFamily::MissingInput => 'The result exists, but missing inputs keep it from being decision-grade.',
|
||||||
|
ExplanationFamily::BlockedPrerequisite => 'The workflow did not produce a usable result because a prerequisite blocked it.',
|
||||||
|
ExplanationFamily::InProgress => 'The result is still being prepared.',
|
||||||
|
ExplanationFamily::Unavailable => 'A result is not currently available for this surface.',
|
||||||
|
ExplanationFamily::CompletedButLimited => match ($trustworthiness) {
|
||||||
|
TrustworthinessLevel::DiagnosticOnly => 'The result is available for diagnostics, not for a final decision.',
|
||||||
|
TrustworthinessLevel::LimitedConfidence => 'The result is available, but it should be read with caution.',
|
||||||
|
TrustworthinessLevel::Unusable => 'The result is not reliable enough to use as-is.',
|
||||||
|
default => 'The result completed with operator follow-up.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reliabilityStatementForTruth(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
TrustworthinessLevel $trustworthiness,
|
||||||
|
): string {
|
||||||
|
return match ($trustworthiness) {
|
||||||
|
TrustworthinessLevel::Trustworthy => 'Trustworthiness is high for the intended operator task.',
|
||||||
|
TrustworthinessLevel::LimitedConfidence => $truth->primaryExplanation
|
||||||
|
?? 'Trustworthiness is limited because coverage, freshness, or publication readiness still need review.',
|
||||||
|
TrustworthinessLevel::DiagnosticOnly => 'This output is suitable for diagnostics only and should not be treated as the final answer.',
|
||||||
|
TrustworthinessLevel::Unusable => 'This output is not reliable enough to support the intended operator action yet.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function coverageStatementForTruth(
|
||||||
|
ArtifactTruthEnvelope $truth,
|
||||||
|
?ReasonResolutionEnvelope $reason,
|
||||||
|
): ?string {
|
||||||
|
return match (true) {
|
||||||
|
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' => 'Coverage and artifact quality are sufficient for the default reading path.',
|
||||||
|
$truth->freshnessState === 'stale' => 'The artifact exists, but freshness limits how confidently it should be used.',
|
||||||
|
$truth->contentState === 'partial' => 'Coverage is incomplete, so the visible output should be treated as partial.',
|
||||||
|
$truth->contentState === 'missing_input' => $reason?->shortExplanation ?? 'Required inputs were missing or unusable when this result was assembled.',
|
||||||
|
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => 'Only reduced-fidelity support is available for this result.',
|
||||||
|
$truth->publicationReadiness === 'blocked' => 'The artifact exists, but it is still blocked from the intended downstream use.',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextActionCategory(
|
||||||
|
string $actionability,
|
||||||
|
?ReasonResolutionEnvelope $reason,
|
||||||
|
): string {
|
||||||
|
if ($reason?->actionability === 'retryable_transient') {
|
||||||
|
return 'retry_later';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($actionability) {
|
||||||
|
'none' => 'none',
|
||||||
|
'optional' => 'review_evidence_gaps',
|
||||||
|
default => $reason?->actionability === 'prerequisite_missing'
|
||||||
|
? 'fix_prerequisite'
|
||||||
|
: 'manual_validate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\OperatorExplanation;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, CountDescriptor> $countDescriptors
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ExplanationFamily $family,
|
||||||
|
public string $headline,
|
||||||
|
public string $executionOutcome,
|
||||||
|
public string $executionOutcomeLabel,
|
||||||
|
public string $evaluationResult,
|
||||||
|
public TrustworthinessLevel $trustworthinessLevel,
|
||||||
|
public string $reliabilityStatement,
|
||||||
|
public ?string $coverageStatement,
|
||||||
|
public ?string $dominantCauseCode,
|
||||||
|
public ?string $dominantCauseLabel,
|
||||||
|
public ?string $dominantCauseExplanation,
|
||||||
|
public string $nextActionCategory,
|
||||||
|
public string $nextActionText,
|
||||||
|
public array $countDescriptors = [],
|
||||||
|
public bool $diagnosticsAvailable = false,
|
||||||
|
public ?string $diagnosticsSummary = null,
|
||||||
|
) {
|
||||||
|
if (trim($this->headline) === '') {
|
||||||
|
throw new InvalidArgumentException('Operator explanation patterns require a headline.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->executionOutcome) === '' || trim($this->executionOutcomeLabel) === '') {
|
||||||
|
throw new InvalidArgumentException('Operator explanation patterns require an execution outcome and label.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->evaluationResult) === '') {
|
||||||
|
throw new InvalidArgumentException('Operator explanation patterns require an evaluation result state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->reliabilityStatement) === '') {
|
||||||
|
throw new InvalidArgumentException('Operator explanation patterns require a reliability statement.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->nextActionCategory) === '' || trim($this->nextActionText) === '') {
|
||||||
|
throw new InvalidArgumentException('Operator explanation patterns require a next action category and text.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->countDescriptors as $descriptor) {
|
||||||
|
if (! $descriptor instanceof CountDescriptor) {
|
||||||
|
throw new InvalidArgumentException('Operator explanation count descriptors must contain CountDescriptor instances.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evaluationResultLabel(): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $this->evaluationResult)->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trustworthinessLabel(): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $this->trustworthinessLevel)->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* family: string,
|
||||||
|
* headline: string,
|
||||||
|
* executionOutcome: string,
|
||||||
|
* executionOutcomeLabel: string,
|
||||||
|
* evaluationResult: string,
|
||||||
|
* evaluationResultLabel: string,
|
||||||
|
* trustworthinessLevel: string,
|
||||||
|
* reliabilityLevel: string,
|
||||||
|
* trustworthinessLabel: string,
|
||||||
|
* reliabilityStatement: string,
|
||||||
|
* coverageStatement: ?string,
|
||||||
|
* dominantCause: array{
|
||||||
|
* code: ?string,
|
||||||
|
* label: ?string,
|
||||||
|
* explanation: ?string
|
||||||
|
* },
|
||||||
|
* nextAction: array{
|
||||||
|
* category: string,
|
||||||
|
* text: string
|
||||||
|
* },
|
||||||
|
* countDescriptors: array<int, array{
|
||||||
|
* label: string,
|
||||||
|
* value: int,
|
||||||
|
* role: string,
|
||||||
|
* qualifier: ?string,
|
||||||
|
* visibilityTier: string
|
||||||
|
* }>,
|
||||||
|
* diagnosticsAvailable: bool,
|
||||||
|
* diagnosticsSummary: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'family' => $this->family->value,
|
||||||
|
'headline' => $this->headline,
|
||||||
|
'executionOutcome' => $this->executionOutcome,
|
||||||
|
'executionOutcomeLabel' => $this->executionOutcomeLabel,
|
||||||
|
'evaluationResult' => $this->evaluationResult,
|
||||||
|
'evaluationResultLabel' => $this->evaluationResultLabel(),
|
||||||
|
'trustworthinessLevel' => $this->trustworthinessLevel->value,
|
||||||
|
'reliabilityLevel' => $this->trustworthinessLevel->value,
|
||||||
|
'trustworthinessLabel' => $this->trustworthinessLabel(),
|
||||||
|
'reliabilityStatement' => $this->reliabilityStatement,
|
||||||
|
'coverageStatement' => $this->coverageStatement,
|
||||||
|
'dominantCause' => [
|
||||||
|
'code' => $this->dominantCauseCode,
|
||||||
|
'label' => $this->dominantCauseLabel,
|
||||||
|
'explanation' => $this->dominantCauseExplanation,
|
||||||
|
],
|
||||||
|
'nextAction' => [
|
||||||
|
'category' => $this->nextActionCategory,
|
||||||
|
'text' => $this->nextActionText,
|
||||||
|
],
|
||||||
|
'countDescriptors' => array_map(
|
||||||
|
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
|
||||||
|
$this->countDescriptors,
|
||||||
|
),
|
||||||
|
'diagnosticsAvailable' => $this->diagnosticsAvailable,
|
||||||
|
'diagnosticsSummary' => $this->diagnosticsSummary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\OperatorExplanation;
|
||||||
|
|
||||||
|
enum TrustworthinessLevel: string
|
||||||
|
{
|
||||||
|
case Trustworthy = 'trustworthy';
|
||||||
|
case LimitedConfidence = 'limited_confidence';
|
||||||
|
case DiagnosticOnly = 'diagnostic_only';
|
||||||
|
case Unusable = 'unusable';
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ public static function firstSlice(): array
|
|||||||
'evidence_snapshots',
|
'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', 90),
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 600),
|
||||||
'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', 90),
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 600),
|
||||||
'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', 90),
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 600),
|
||||||
'block_for' => null,
|
'block_for' => null,
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
@ -126,4 +126,8 @@
|
|||||||
'table' => 'failed_jobs',
|
'table' => 'failed_jobs',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'lifecycle_invariants' => [
|
||||||
|
'retry_after_safety_margin' => (int) env('QUEUE_RETRY_AFTER_SAFETY_MARGIN', 30),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,6 +13,113 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'operations' => [
|
||||||
|
'lifecycle' => [
|
||||||
|
'reconciliation' => [
|
||||||
|
'batch_limit' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_BATCH_LIMIT', 100),
|
||||||
|
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
|
||||||
|
],
|
||||||
|
'covered_types' => [
|
||||||
|
'baseline_capture' => [
|
||||||
|
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
|
||||||
|
'queued_stale_after_seconds' => 600,
|
||||||
|
'running_stale_after_seconds' => 1800,
|
||||||
|
'expected_max_runtime_seconds' => 300,
|
||||||
|
'direct_failed_bridge' => true,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'baseline_compare' => [
|
||||||
|
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
|
||||||
|
'queued_stale_after_seconds' => 600,
|
||||||
|
'running_stale_after_seconds' => 1800,
|
||||||
|
'expected_max_runtime_seconds' => 300,
|
||||||
|
'direct_failed_bridge' => true,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'inventory_sync' => [
|
||||||
|
'job_class' => \App\Jobs\RunInventorySyncJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 1200,
|
||||||
|
'expected_max_runtime_seconds' => 240,
|
||||||
|
'direct_failed_bridge' => true,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'policy.sync' => [
|
||||||
|
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 180,
|
||||||
|
'direct_failed_bridge' => true,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'policy.sync_one' => [
|
||||||
|
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 180,
|
||||||
|
'direct_failed_bridge' => true,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'entra_group_sync' => [
|
||||||
|
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 240,
|
||||||
|
'direct_failed_bridge' => false,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'directory_role_definitions.sync' => [
|
||||||
|
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 240,
|
||||||
|
'direct_failed_bridge' => false,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'backup_schedule_run' => [
|
||||||
|
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 1200,
|
||||||
|
'expected_max_runtime_seconds' => 300,
|
||||||
|
'direct_failed_bridge' => false,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'restore.execute' => [
|
||||||
|
'job_class' => \App\Jobs\ExecuteRestoreRunJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 1500,
|
||||||
|
'expected_max_runtime_seconds' => 420,
|
||||||
|
'direct_failed_bridge' => false,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'tenant.review_pack.generate' => [
|
||||||
|
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 240,
|
||||||
|
'direct_failed_bridge' => false,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'tenant.review.compose' => [
|
||||||
|
'job_class' => \App\Jobs\ComposeTenantReviewJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 240,
|
||||||
|
'direct_failed_bridge' => true,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
'tenant.evidence.snapshot.generate' => [
|
||||||
|
'job_class' => \App\Jobs\GenerateEvidenceSnapshotJob::class,
|
||||||
|
'queued_stale_after_seconds' => 300,
|
||||||
|
'running_stale_after_seconds' => 900,
|
||||||
|
'expected_max_runtime_seconds' => 240,
|
||||||
|
'direct_failed_bridge' => false,
|
||||||
|
'scheduled_reconciliation' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||||
|
|
||||||
'supported_policy_types' => [
|
'supported_policy_types' => [
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,7 +25,50 @@ 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('baseline_snapshots')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needsLifecycleState = ! Schema::hasColumn('baseline_snapshots', 'lifecycle_state');
|
||||||
|
$needsCompletedAt = ! Schema::hasColumn('baseline_snapshots', 'completed_at');
|
||||||
|
$needsFailedAt = ! Schema::hasColumn('baseline_snapshots', 'failed_at');
|
||||||
|
$needsCompletionMeta = ! Schema::hasColumn('baseline_snapshots', 'completion_meta_jsonb');
|
||||||
|
|
||||||
|
if ($needsLifecycleState || $needsCompletedAt || $needsFailedAt || $needsCompletionMeta) {
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table) use ($needsLifecycleState, $needsCompletedAt, $needsFailedAt, $needsCompletionMeta): void {
|
||||||
|
if ($needsLifecycleState) {
|
||||||
|
$table->string('lifecycle_state')->nullable()->after('captured_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($needsCompletedAt) {
|
||||||
|
$table->timestampTz('completed_at')->nullable()->after('lifecycle_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($needsFailedAt) {
|
||||||
|
$table->timestampTz('failed_at')->nullable()->after('completed_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($needsCompletionMeta) {
|
||||||
|
$table->jsonb('completion_meta_jsonb')->nullable()->after('summary_jsonb');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('baseline_snapshots')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function ($rows): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$summary = $this->decodeJson($row->summary_jsonb);
|
||||||
|
$persistedItems = (int) DB::table('baseline_snapshot_items')
|
||||||
|
->where('baseline_snapshot_id', (int) $row->id)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$classification = $this->classifyLegacySnapshot($row, $summary, $persistedItems);
|
||||||
|
|
||||||
|
DB::table('baseline_snapshots')
|
||||||
|
->where('id', (int) $row->id)
|
||||||
|
->update([
|
||||||
|
'lifecycle_state' => $classification['lifecycle_state'],
|
||||||
|
'completed_at' => $classification['completed_at'],
|
||||||
|
'failed_at' => $classification['failed_at'],
|
||||||
|
'completion_meta_jsonb' => json_encode($classification['completion_meta'], JSON_THROW_ON_ERROR),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, 'id');
|
||||||
|
|
||||||
|
DB::table('baseline_snapshots')
|
||||||
|
->whereNull('lifecycle_state')
|
||||||
|
->update([
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($needsLifecycleState) {
|
||||||
|
DB::statement(sprintf(
|
||||||
|
"UPDATE baseline_snapshots SET lifecycle_state = '%s' WHERE lifecycle_state IS NULL",
|
||||||
|
BaselineSnapshotLifecycleState::Incomplete->value,
|
||||||
|
));
|
||||||
|
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||||
|
$table->string('lifecycle_state')->default(BaselineSnapshotLifecycleState::Building->value)->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||||
|
$table->index('lifecycle_state', 'baseline_snapshots_lifecycle_state_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||||
|
$table->index(
|
||||||
|
['workspace_id', 'baseline_profile_id', 'lifecycle_state', 'completed_at'],
|
||||||
|
'baseline_snapshots_profile_lifecycle_completed_idx',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('baseline_snapshots')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('baseline_snapshots_profile_lifecycle_completed_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('baseline_snapshots_lifecycle_state_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||||
|
foreach (['completion_meta_jsonb', 'failed_at', 'completed_at', 'lifecycle_state'] as $column) {
|
||||||
|
if (Schema::hasColumn('baseline_snapshots', $column)) {
|
||||||
|
$table->dropColumn($column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summary
|
||||||
|
* @return array{
|
||||||
|
* lifecycle_state: string,
|
||||||
|
* completed_at: mixed,
|
||||||
|
* failed_at: mixed,
|
||||||
|
* completion_meta: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function classifyLegacySnapshot(object $row, array $summary, int $persistedItems): array
|
||||||
|
{
|
||||||
|
$expectedItems = $this->normalizeInteger(data_get($summary, 'total_items'));
|
||||||
|
$producerRun = $this->resolveProducerRunForSnapshot((int) $row->id, (int) $row->workspace_id, (int) $row->baseline_profile_id);
|
||||||
|
$producerRunContext = $producerRun !== null ? $this->decodeJson($producerRun->context ?? null) : [];
|
||||||
|
$producerExpectedItems = $this->normalizeInteger(data_get($producerRunContext, 'result.items_captured'))
|
||||||
|
?? $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
|
||||||
|
$producerSubjectsTotal = $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
|
||||||
|
$producerSucceeded = $producerRun !== null
|
||||||
|
&& in_array((string) ($producerRun->outcome ?? ''), [
|
||||||
|
OperationRunOutcome::Succeeded->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
], true);
|
||||||
|
|
||||||
|
$completionMeta = [
|
||||||
|
'expected_items' => $expectedItems ?? $producerExpectedItems,
|
||||||
|
'persisted_items' => $persistedItems,
|
||||||
|
'producer_run_id' => $producerRun?->id !== null ? (int) $producerRun->id : null,
|
||||||
|
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($expectedItems !== null && $expectedItems === $persistedItems) {
|
||||||
|
return [
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||||
|
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||||
|
'failed_at' => null,
|
||||||
|
'completion_meta' => $completionMeta + [
|
||||||
|
'finalization_reason_code' => 'baseline.snapshot.legacy_count_proof',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) {
|
||||||
|
return [
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||||
|
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||||
|
'failed_at' => null,
|
||||||
|
'completion_meta' => $completionMeta + [
|
||||||
|
'finalization_reason_code' => 'baseline.snapshot.legacy_producer_run_proof',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($producerSucceeded && $persistedItems === 0 && in_array(0, array_filter([
|
||||||
|
$expectedItems,
|
||||||
|
$producerExpectedItems,
|
||||||
|
$producerSubjectsTotal,
|
||||||
|
], static fn (?int $value): bool => $value !== null), true)) {
|
||||||
|
return [
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||||
|
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||||
|
'failed_at' => null,
|
||||||
|
'completion_meta' => $completionMeta + [
|
||||||
|
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = $expectedItems !== null
|
||||||
|
|| $producerExpectedItems !== null
|
||||||
|
|| $producerRun !== null
|
||||||
|
? BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY
|
||||||
|
: BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||||
|
'completed_at' => null,
|
||||||
|
'failed_at' => $row->updated_at ?? $row->captured_at ?? $row->created_at,
|
||||||
|
'completion_meta' => $completionMeta + [
|
||||||
|
'finalization_reason_code' => $reasonCode,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveProducerRunForSnapshot(int $snapshotId, int $workspaceId, int $profileId): ?object
|
||||||
|
{
|
||||||
|
$runs = DB::table('operation_runs')
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('type', OperationRunType::BaselineCapture->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(500)
|
||||||
|
->get(['id', 'outcome', 'completed_at', 'context']);
|
||||||
|
|
||||||
|
foreach ($runs as $run) {
|
||||||
|
$context = $this->decodeJson($run->context ?? null);
|
||||||
|
$resultSnapshotId = $this->normalizeInteger(data_get($context, 'result.snapshot_id'));
|
||||||
|
|
||||||
|
if ($resultSnapshotId === $snapshotId) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($runs as $run) {
|
||||||
|
$context = $this->decodeJson($run->context ?? null);
|
||||||
|
$runProfileId = $this->normalizeInteger(data_get($context, 'baseline_profile_id'));
|
||||||
|
|
||||||
|
if ($runProfileId === $profileId) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function decodeJson(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeInteger(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (is_int($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && ctype_digit(trim($value))) {
|
||||||
|
return (int) trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasIndex(string $indexName): bool
|
||||||
|
{
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
|
||||||
|
return match ($driver) {
|
||||||
|
'pgsql' => DB::table('pg_indexes')
|
||||||
|
->where('schemaname', 'public')
|
||||||
|
->where('indexname', $indexName)
|
||||||
|
->exists(),
|
||||||
|
'sqlite' => collect(DB::select("PRAGMA index_list('baseline_snapshots')"))
|
||||||
|
->contains(fn (object $index): bool => ($index->name ?? null) === $indexName),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -150,6 +150,8 @@ ### 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-23 (added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
**Last reviewed**: 2026-03-24 (added Operation Run Active-State Visibility & Stale Escalation candidate)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -224,20 +224,130 @@ ### 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 two 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 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. **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.
|
> 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.
|
||||||
> 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.
|
> 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.
|
||||||
|
> 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 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 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 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.
|
> **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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|||||||
170
public/js/tenantpilot/ops-ux-progress-widget-poller.js
Normal file
170
public/js/tenantpilot/ops-ux-progress-widget-poller.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
(() => {
|
||||||
|
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,5 +1,9 @@
|
|||||||
@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,4 +1,8 @@
|
|||||||
@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,21 +8,12 @@
|
|||||||
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
<x-filament::section
|
||||||
<div class="flex items-start justify-between gap-3">
|
:heading="$card['title'] ?? 'Supporting detail'"
|
||||||
<div>
|
:description="$card['description'] ?? null"
|
||||||
<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
|
||||||
@ -30,10 +21,10 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
|||||||
>
|
>
|
||||||
{{ $action['label'] }}
|
{{ $action['label'] }}
|
||||||
</a>
|
</a>
|
||||||
|
</x-slot>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div>
|
||||||
@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 !== [])
|
||||||
@ -42,4 +33,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>
|
||||||
</div>
|
</x-filament::section>
|
||||||
|
|||||||
@ -31,11 +31,31 @@
|
|||||||
$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 }}
|
||||||
@ -51,15 +71,31 @@
|
|||||||
|
|
||||||
<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">
|
||||||
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
@if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
|
||||||
|
<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'] }}
|
||||||
@ -102,14 +138,47 @@
|
|||||||
</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">
|
||||||
{{ $state['nextActionLabel'] ?? 'No action needed' }}
|
{{ data_get($operatorExplanation, 'nextAction.text') ?? ($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,9 +6,30 @@
|
|||||||
$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,6 +6,14 @@
|
|||||||
|
|
||||||
@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)
|
||||||
@ -27,6 +35,96 @@
|
|||||||
</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">
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
<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">
|
||||||
@ -15,134 +18,14 @@
|
|||||||
</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>
|
||||||
:heading="$selectedAudit->summaryText()"
|
@include('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||||
:description="$selectedAudit->recorded_at?->toDayDateTimeString()"
|
'selectedAudit' => $selectedAudit,
|
||||||
>
|
'selectedAuditLink' => $selectedAuditLink,
|
||||||
<div class="flex flex-col gap-6">
|
])
|
||||||
<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
|
|
||||||
|
|
||||||
<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>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
|
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
||||||
|
|
||||||
<x-filament::tabs label="Operations tabs">
|
<x-filament::tabs label="Operations tabs">
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'all'"
|
:active="$this->activeTab === 'all'"
|
||||||
@ -22,21 +24,28 @@
|
|||||||
:active="$this->activeTab === 'succeeded'"
|
:active="$this->activeTab === 'succeeded'"
|
||||||
wire:click="$set('activeTab', 'succeeded')"
|
wire:click="$set('activeTab', 'succeeded')"
|
||||||
>
|
>
|
||||||
Completed successfully
|
Succeeded
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'partial'"
|
:active="$this->activeTab === 'partial'"
|
||||||
wire:click="$set('activeTab', 'partial')"
|
wire:click="$set('activeTab', 'partial')"
|
||||||
>
|
>
|
||||||
Needs follow-up
|
Partial
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'failed'"
|
:active="$this->activeTab === 'failed'"
|
||||||
wire:click="$set('activeTab', 'failed')"
|
wire:click="$set('activeTab', 'failed')"
|
||||||
>
|
>
|
||||||
Execution failed
|
Failed
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
</x-filament::tabs>
|
</x-filament::tabs>
|
||||||
|
|
||||||
|
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||||
|
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
|
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active run(s) are beyond their lifecycle window.
|
||||||
|
{{ ($lifecycleSummary['reconciled'] ?? 0) }} run(s) have already been automatically reconciled.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@ -0,0 +1,115 @@
|
|||||||
|
@php
|
||||||
|
$selectedAudit = $selectedAudit ?? null;
|
||||||
|
$selectedAuditLink = $selectedAuditLink ?? null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($selectedAudit)
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
@endif
|
||||||
@ -1,6 +1,7 @@
|
|||||||
@php
|
@php
|
||||||
$contextBanner = $this->canonicalContextBanner();
|
$contextBanner = $this->canonicalContextBanner();
|
||||||
$blockedBanner = $this->blockedExecutionBanner();
|
$blockedBanner = $this->blockedExecutionBanner();
|
||||||
|
$lifecycleBanner = $this->lifecycleBanner();
|
||||||
$pollInterval = $this->pollInterval();
|
$pollInterval = $this->pollInterval();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@ -37,6 +38,20 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($lifecycleBanner !== null)
|
||||||
|
@php
|
||||||
|
$lifecycleBannerClasses = match ($lifecycleBanner['tone']) {
|
||||||
|
'rose' => 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100',
|
||||||
|
default => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="mb-6 rounded-lg border px-4 py-3 text-sm {{ $lifecycleBannerClasses }}">
|
||||||
|
<p class="font-semibold">{{ $lifecycleBanner['title'] }}</p>
|
||||||
|
<p class="mt-1">{{ $lifecycleBanner['body'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($this->redactionIntegrityNote())
|
@if ($this->redactionIntegrityNote())
|
||||||
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
{{ $this->redactionIntegrityNote() }}
|
{{ $this->redactionIntegrityNote() }}
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
<script src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||||
<script defer src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
<script src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
||||||
|
<script src="{{ asset('js/tenantpilot/ops-ux-progress-widget-poller.js') }}"></script>
|
||||||
|
|||||||
@ -15,6 +15,14 @@
|
|||||||
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@elseif (($state ?? null) === 'no_snapshot')
|
||||||
|
<div class="flex items-start gap-3 rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||||
|
<x-heroicon-o-camera class="mt-0.5 h-5 w-5 shrink-0 text-warning-500 dark:text-warning-400" />
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-warning-900 dark:text-warning-100">Current Baseline Unavailable</div>
|
||||||
|
<div class="mt-0.5 text-sm text-warning-800 dark:text-warning-200">{{ $message }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{{-- Profile + last compared --}}
|
{{-- Profile + last compared --}}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
@php
|
@php
|
||||||
/** @var bool $shouldShow */
|
/** @var bool $shouldShow */
|
||||||
/** @var ?string $runUrl */
|
/** @var ?string $runUrl */
|
||||||
|
/** @var ?string $state */
|
||||||
|
/** @var ?string $message */
|
||||||
/** @var ?string $coverageStatus */
|
/** @var ?string $coverageStatus */
|
||||||
/** @var ?string $fidelity */
|
/** @var ?string $fidelity */
|
||||||
/** @var int $uncoveredTypesCount */
|
/** @var int $uncoveredTypesCount */
|
||||||
@ -10,12 +12,20 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@if ($shouldShow && $coverageHasWarnings)
|
@if ($shouldShow && ($coverageHasWarnings || ($state ?? null) === 'no_snapshot'))
|
||||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="text-sm font-semibold">Baseline compare coverage warnings</div>
|
<div class="text-sm font-semibold">
|
||||||
|
@if (($state ?? null) === 'no_snapshot')
|
||||||
|
Current baseline unavailable
|
||||||
|
@else
|
||||||
|
Baseline compare coverage warnings
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@if (($coverageStatus ?? null) === 'unproven')
|
@if (($state ?? null) === 'no_snapshot')
|
||||||
|
{{ $message }}
|
||||||
|
@elseif (($coverageStatus ?? null) === 'unproven')
|
||||||
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
||||||
@else
|
@else
|
||||||
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
|
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
|
||||||
@ -32,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($runUrl))
|
@if (($state ?? null) !== 'no_snapshot' && filled($runUrl))
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
|
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
|
||||||
View run
|
View run
|
||||||
@ -43,4 +53,3 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user