Compare commits
3 Commits
158-artifa
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 |
10
.github/agents/copilot-instructions.md
vendored
10
.github/agents/copilot-instructions.md
vendored
@ -98,6 +98,10 @@ ## Active Technologies
|
|||||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||||
- 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)
|
||||||
|
- 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)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -117,8 +121,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
||||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
- 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
|
||||||
<!-- 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -307,9 +307,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;
|
||||||
@ -42,8 +43,6 @@ class AuditLog extends Page implements HasTable
|
|||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
public ?int $selectedAuditLogId = null;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
@ -82,14 +81,15 @@ 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->mountTableAction('inspect', (string) $requestedEventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,31 +98,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 +174,16 @@ 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 {
|
->slideOver()
|
||||||
$this->selectedAuditLogId = (int) $record->getKey();
|
->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 +195,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 +272,27 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,10 +8,13 @@
|
|||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -87,6 +90,9 @@ public function mount(): void
|
|||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||||
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
'tenant_id' => (int) $snapshot->tenant_id,
|
||||||
@ -95,7 +101,21 @@ public function mount(): void
|
|||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
'artifact_truth' => [
|
||||||
|
'label' => $truth->primaryLabel,
|
||||||
|
'color' => $truth->primaryBadgeSpec()->color,
|
||||||
|
'icon' => $truth->primaryBadgeSpec()->icon,
|
||||||
|
'explanation' => $truth->primaryExplanation,
|
||||||
|
],
|
||||||
|
'freshness' => [
|
||||||
|
'label' => $freshnessSpec->label,
|
||||||
|
'color' => $freshnessSpec->color,
|
||||||
|
'icon' => $freshnessSpec->icon,
|
||||||
|
],
|
||||||
|
'next_step' => $truth->nextStepText(),
|
||||||
|
'view_url' => $snapshot->tenant
|
||||||
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
|
: null,
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,6 +183,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -114,6 +115,15 @@ public function table(Table $table): Table
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||||
|
TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
||||||
|
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('completeness_state')
|
TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -123,15 +133,29 @@ public function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('summary.publish_blockers')
|
TextColumn::make('publication_truth')
|
||||||
->label('Publish blockers')
|
->label('Publication')
|
||||||
->formatStateUsing(static function (mixed $state): string {
|
->badge()
|
||||||
if (! is_array($state) || $state === []) {
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
return '0';
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
}
|
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
return (string) count($state);
|
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
}),
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->iconColor),
|
||||||
|
TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('tenant_id')
|
||||||
|
|||||||
@ -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;
|
||||||
@ -21,6 +23,8 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -168,17 +172,39 @@ public static function table(Table $table): Table
|
|||||||
->label('Captured')
|
->label('Captured')
|
||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('lifecycle_state')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('current_truth')
|
||||||
|
->label('Current truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
|
||||||
|
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('snapshot_state')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('State')
|
->label('Next step')
|
||||||
->badge()
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
->wrap(),
|
||||||
->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])
|
||||||
@ -188,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([
|
||||||
@ -252,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
|
||||||
@ -328,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
|
||||||
@ -364,4 +384,56 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
|
|||||||
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,8 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -133,6 +135,15 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
|
Section::make('Artifact truth')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('artifact_truth')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
|
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Snapshot')
|
Section::make('Snapshot')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -214,6 +225,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -225,6 +245,10 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
@ -588,6 +612,11 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
|||||||
return $label === 'Unknown' ? null : $label;
|
return $label === 'Unknown' ? null : $label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
||||||
|
}
|
||||||
|
|
||||||
private static function stringifySummaryValue(mixed $value): string
|
private static function stringifySummaryValue(mixed $value): string
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -127,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))
|
||||||
@ -153,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([
|
||||||
@ -252,13 +254,24 @@ 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->isGovernanceArtifactOperation()
|
||||||
|
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||||
|
: null;
|
||||||
|
$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(
|
||||||
@ -288,6 +301,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'artifact_truth',
|
||||||
|
kind: 'current_status',
|
||||||
|
title: 'Artifact truth',
|
||||||
|
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||||
|
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
||||||
|
visible: $record->isGovernanceArtifactOperation(),
|
||||||
|
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||||
|
),
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'related_context',
|
id: 'related_context',
|
||||||
kind: 'related_context',
|
kind: 'related_context',
|
||||||
@ -305,6 +327,9 @@ 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,
|
||||||
$referencedTenantLifecycle !== null
|
$referencedTenantLifecycle !== null
|
||||||
? $factory->keyFact(
|
? $factory->keyFact(
|
||||||
'Tenant lifecycle',
|
'Tenant lifecycle',
|
||||||
@ -323,6 +348,21 @@ 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,
|
||||||
|
$artifactTruth !== null
|
||||||
|
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
||||||
|
: 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,
|
||||||
@ -389,6 +429,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);
|
||||||
@ -699,6 +752,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) {
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
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\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -19,11 +20,14 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -111,6 +115,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Artifact truth')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('artifact_truth')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
|
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Status')
|
Section::make('Status')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -238,6 +251,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -257,6 +279,29 @@ public static function table(Table $table): Table
|
|||||||
->label('Size')
|
->label('Size')
|
||||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('publication_truth')
|
||||||
|
->label('Publication')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
|
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->color)
|
||||||
|
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->icon)
|
||||||
|
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->iconColor),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Created')
|
->label('Created')
|
||||||
->since()
|
->since()
|
||||||
@ -352,6 +397,11 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -28,6 +28,8 @@
|
|||||||
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;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -143,6 +145,15 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
|
Section::make('Artifact truth')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('artifact_truth')
|
||||||
|
->hiddenLabel()
|
||||||
|
->view('filament.infolists.entries.governance-artifact-truth')
|
||||||
|
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Review')
|
Section::make('Review')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -239,6 +250,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_truth')
|
||||||
|
->label('Artifact truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||||
|
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||||
|
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -251,9 +271,32 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
||||||
|
Tables\Columns\TextColumn::make('publication_truth')
|
||||||
|
->label('Publication')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
|
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->color)
|
||||||
|
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->icon)
|
||||||
|
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->iconColor),
|
||||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||||
->label('Export')
|
->label('Export')
|
||||||
->boolean(),
|
->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -561,4 +604,9 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
'links' => [],
|
'links' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -12,6 +13,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
use App\Services\Baselines\InventoryMetaContract;
|
use App\Services\Baselines\InventoryMetaContract;
|
||||||
@ -20,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;
|
||||||
@ -32,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,10 +73,12 @@ public function handle(
|
|||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = 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);
|
||||||
$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) {
|
||||||
@ -183,7 +198,12 @@ public function handle(
|
|||||||
gaps: $captureGaps,
|
gaps: $captureGaps,
|
||||||
);
|
);
|
||||||
|
|
||||||
$items = $snapshotItems['items'] ?? [];
|
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
||||||
|
$items = $normalizedItems['items'];
|
||||||
|
|
||||||
|
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
||||||
|
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
||||||
|
}
|
||||||
|
|
||||||
$identityHash = $identity->computeIdentity($items);
|
$identityHash = $identity->computeIdentity($items);
|
||||||
|
|
||||||
@ -200,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()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -250,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]);
|
||||||
|
|
||||||
@ -500,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(
|
||||||
@ -541,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;
|
||||||
@ -54,7 +57,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 +95,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 +104,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 +291,51 @@ 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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$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;
|
||||||
@ -1004,6 +1056,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 = [
|
/**
|
||||||
'summary_jsonb' => 'array',
|
* @return array<string, string>
|
||||||
'captured_at' => 'datetime',
|
*/
|
||||||
];
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
||||||
|
'summary_jsonb' => 'array',
|
||||||
|
'completion_meta_jsonb' => 'array',
|
||||||
|
'captured_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'failed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
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;
|
||||||
@ -127,4 +129,63 @@ public function setFinishedAtAttribute(mixed $value): void
|
|||||||
{
|
{
|
||||||
$this->completed_at = $value;
|
$this->completed_at = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isGovernanceArtifactOperation(): bool
|
||||||
|
{
|
||||||
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function governanceArtifactFamily(): ?string
|
||||||
|
{
|
||||||
|
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function artifactResultContext(): array
|
||||||
|
{
|
||||||
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
|
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
||||||
|
|
||||||
|
return array_merge($context, ['result' => $result]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relatedArtifactId(): ?int
|
||||||
|
{
|
||||||
|
return match ($this->governanceArtifactFamily()) {
|
||||||
|
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
||||||
|
? (int) data_get($this->context, 'result.snapshot_id')
|
||||||
|
: null,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function reconciliation(): array
|
||||||
|
{
|
||||||
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
|
$reconciliation = $context['reconciliation'] ?? null;
|
||||||
|
|
||||||
|
return is_array($reconciliation) ? $reconciliation : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLifecycleReconciled(): bool
|
||||||
|
{
|
||||||
|
return $this->reconciliation() !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lifecycleReconciliationReasonCode(): ?string
|
||||||
|
{
|
||||||
|
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function freshnessState(): OperationRunFreshnessState
|
||||||
|
{
|
||||||
|
return OperationRunFreshnessState::forRun($this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,29 +50,36 @@ public function startCompare(
|
|||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
return ['ok' => false, 'reason_code' => 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 ['ok' => false, 'reason_code' => $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 ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$snapshotId = (int) $profile->active_snapshot_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
||||||
|
|
||||||
|
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||||
|
return ['ok' => false, 'reason_code' => $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 +121,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 +131,6 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
app/Services/Baselines/BaselineSnapshotItemNormalizer.php
Normal file
90
app/Services/Baselines/BaselineSnapshotItemNormalizer.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Baselines;
|
||||||
|
|
||||||
|
final class BaselineSnapshotItemNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $items
|
||||||
|
* @return array{items: list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>, duplicates: int}
|
||||||
|
*/
|
||||||
|
public function deduplicate(array $items): array
|
||||||
|
{
|
||||||
|
$uniqueItems = [];
|
||||||
|
$duplicates = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$key = trim((string) ($item['subject_type'] ?? '')).'|'.trim((string) ($item['subject_external_id'] ?? ''));
|
||||||
|
|
||||||
|
if ($key === '|') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists($key, $uniqueItems)) {
|
||||||
|
$uniqueItems[$key] = $item;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicates++;
|
||||||
|
|
||||||
|
if ($this->shouldReplace($uniqueItems[$key], $item)) {
|
||||||
|
$uniqueItems[$key] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => array_values($uniqueItems),
|
||||||
|
'duplicates' => $duplicates,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $current
|
||||||
|
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $candidate
|
||||||
|
*/
|
||||||
|
private function shouldReplace(array $current, array $candidate): bool
|
||||||
|
{
|
||||||
|
$currentFidelity = $this->fidelityRank($current);
|
||||||
|
$candidateFidelity = $this->fidelityRank($candidate);
|
||||||
|
|
||||||
|
if ($candidateFidelity !== $currentFidelity) {
|
||||||
|
return $candidateFidelity > $currentFidelity;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentObservedAt = $this->observedAt($current);
|
||||||
|
$candidateObservedAt = $this->observedAt($candidate);
|
||||||
|
|
||||||
|
if ($candidateObservedAt !== $currentObservedAt) {
|
||||||
|
return $candidateObservedAt > $currentObservedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) ($candidate['baseline_hash'] ?? ''), (string) ($current['baseline_hash'] ?? '')) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{meta_jsonb?: array<string, mixed>} $item
|
||||||
|
*/
|
||||||
|
private function fidelityRank(array $item): int
|
||||||
|
{
|
||||||
|
$fidelity = data_get($item, 'meta_jsonb.evidence.fidelity');
|
||||||
|
|
||||||
|
return match ($fidelity) {
|
||||||
|
'content' => 2,
|
||||||
|
'meta' => 1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{meta_jsonb?: array<string, mixed>} $item
|
||||||
|
*/
|
||||||
|
private function observedAt(array $item): string
|
||||||
|
{
|
||||||
|
$observedAt = data_get($item, 'meta_jsonb.evidence.observed_at');
|
||||||
|
|
||||||
|
return is_string($observedAt) ? $observedAt : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
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,8 @@
|
|||||||
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 Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -98,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);
|
||||||
@ -119,21 +129,36 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||||
$rendered->summaryRows,
|
$rendered->summaryRows,
|
||||||
));
|
));
|
||||||
|
$currentTruth = $this->currentTruthPresentation($truth);
|
||||||
|
$currentTruthBadge = $factory->statusBadge(
|
||||||
|
$currentTruth['label'],
|
||||||
|
$currentTruth['color'],
|
||||||
|
$currentTruth['icon'],
|
||||||
|
$currentTruth['iconColor'],
|
||||||
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
id: 'artifact_truth',
|
||||||
|
kind: 'current_status',
|
||||||
|
title: 'Artifact truth',
|
||||||
|
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||||
|
viewData: ['state' => $truth->toArray()],
|
||||||
|
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
|
||||||
|
),
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'coverage_summary',
|
id: 'coverage_summary',
|
||||||
kind: 'current_status',
|
kind: 'current_status',
|
||||||
@ -165,11 +190,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
->addSupportingCard(
|
->addSupportingCard(
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
title: 'Snapshot status',
|
title: 'Snapshot truth',
|
||||||
|
items: [
|
||||||
|
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||||
|
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||||
|
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||||
|
$factory->keyFact('Next step', $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(
|
||||||
@ -177,6 +212,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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -328,6 +365,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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,12 @@ final class BadgeCatalog
|
|||||||
private const DOMAIN_MAPPERS = [
|
private const DOMAIN_MAPPERS = [
|
||||||
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
||||||
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::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,
|
||||||
|
|||||||
@ -6,6 +6,12 @@ enum BadgeDomain: string
|
|||||||
{
|
{
|
||||||
case AuditOutcome = 'audit_outcome';
|
case AuditOutcome = 'audit_outcome';
|
||||||
case AuditActorType = 'audit_actor_type';
|
case AuditActorType = 'audit_actor_type';
|
||||||
|
case GovernanceArtifactExistence = 'governance_artifact_existence';
|
||||||
|
case GovernanceArtifactContent = 'governance_artifact_content';
|
||||||
|
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||||
|
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||||
|
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?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 GovernanceArtifactActionabilityBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'none' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-check'),
|
||||||
|
'optional' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-information-circle'),
|
||||||
|
'required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?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 GovernanceArtifactContentBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'trusted' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-check-badge'),
|
||||||
|
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'missing_input' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-circle'),
|
||||||
|
'metadata_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-document-text'),
|
||||||
|
'reference_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-link'),
|
||||||
|
'empty' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-no-symbol'),
|
||||||
|
'unsupported' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-question-mark-circle'),
|
||||||
|
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 GovernanceArtifactExistenceBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'not_created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-clock'),
|
||||||
|
'historical_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-archive-box'),
|
||||||
|
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-check-circle'),
|
||||||
|
'created_but_not_usable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?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 GovernanceArtifactFreshnessBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'current' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-check-circle'),
|
||||||
|
'stale' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-arrow-path'),
|
||||||
|
'unknown' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-question-mark-circle'),
|
||||||
|
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 GovernanceArtifactPublicationReadinessBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'not_applicable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-minus-circle'),
|
||||||
|
'internal_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-document-duplicate'),
|
||||||
|
'publishable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-check-badge'),
|
||||||
|
'blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-no-symbol'),
|
||||||
|
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'),
|
||||||
|
|||||||
@ -21,6 +21,243 @@ final class OperatorOutcomeTaxonomy
|
|||||||
* }>>
|
* }>>
|
||||||
*/
|
*/
|
||||||
private const ENTRIES = [
|
private const ENTRIES = [
|
||||||
|
'governance_artifact_existence' => [
|
||||||
|
'not_created' => [
|
||||||
|
'axis' => 'artifact_existence',
|
||||||
|
'label' => 'Not created yet',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'optional',
|
||||||
|
'legacy_aliases' => ['No artifact'],
|
||||||
|
'notes' => 'The intended artifact has not been produced yet.',
|
||||||
|
],
|
||||||
|
'historical_only' => [
|
||||||
|
'axis' => 'artifact_existence',
|
||||||
|
'label' => 'Historical artifact',
|
||||||
|
'color' => 'gray',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Historical only'],
|
||||||
|
'notes' => 'The artifact remains readable for history but is no longer the current working artifact.',
|
||||||
|
],
|
||||||
|
'created' => [
|
||||||
|
'axis' => 'artifact_existence',
|
||||||
|
'label' => 'Artifact available',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Created'],
|
||||||
|
'notes' => 'The intended artifact exists and can be inspected.',
|
||||||
|
],
|
||||||
|
'created_but_not_usable' => [
|
||||||
|
'axis' => 'artifact_existence',
|
||||||
|
'label' => 'Artifact not usable',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Created but not usable'],
|
||||||
|
'notes' => 'The artifact record exists, but the operator cannot safely rely on it for the primary task.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'governance_artifact_content' => [
|
||||||
|
'trusted' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Trustworthy artifact',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Trusted'],
|
||||||
|
'notes' => 'The artifact content is fit for the primary operator workflow.',
|
||||||
|
],
|
||||||
|
'partial' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Partial',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Partially complete'],
|
||||||
|
'notes' => 'The artifact exists but key content is incomplete.',
|
||||||
|
],
|
||||||
|
'missing_input' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Missing input',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Missing'],
|
||||||
|
'notes' => 'The artifact is blocked by missing upstream inputs.',
|
||||||
|
],
|
||||||
|
'metadata_only' => [
|
||||||
|
'axis' => 'evidence_depth',
|
||||||
|
'label' => 'Metadata only',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Metadata-only'],
|
||||||
|
'notes' => 'Only metadata is available. This is diagnostic context and should not replace the primary truth state.',
|
||||||
|
],
|
||||||
|
'reference_only' => [
|
||||||
|
'axis' => 'evidence_depth',
|
||||||
|
'label' => 'Reference only',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Reference-only'],
|
||||||
|
'notes' => 'Only reference placeholders are available. This is diagnostic context and should not replace the primary truth state.',
|
||||||
|
],
|
||||||
|
'empty' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Empty snapshot',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Empty'],
|
||||||
|
'notes' => 'The artifact exists but captured no usable content.',
|
||||||
|
],
|
||||||
|
'unsupported' => [
|
||||||
|
'axis' => 'product_support_maturity',
|
||||||
|
'label' => 'Support limited',
|
||||||
|
'color' => 'gray',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Unsupported'],
|
||||||
|
'notes' => 'The product is representing the source with limited fidelity. This remains diagnostic unless a stronger truth dimension applies.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'governance_artifact_freshness' => [
|
||||||
|
'current' => [
|
||||||
|
'axis' => 'data_freshness',
|
||||||
|
'label' => 'Current',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Fresh'],
|
||||||
|
'notes' => 'The available artifact is current enough for the primary task.',
|
||||||
|
],
|
||||||
|
'stale' => [
|
||||||
|
'axis' => 'data_freshness',
|
||||||
|
'label' => 'Stale',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'optional',
|
||||||
|
'legacy_aliases' => ['Refresh recommended'],
|
||||||
|
'notes' => 'The artifact exists but should be refreshed before relying on it.',
|
||||||
|
],
|
||||||
|
'unknown' => [
|
||||||
|
'axis' => 'data_freshness',
|
||||||
|
'label' => 'Freshness unknown',
|
||||||
|
'color' => 'gray',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Unknown'],
|
||||||
|
'notes' => 'The system cannot determine freshness from the available payload.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'governance_artifact_publication_readiness' => [
|
||||||
|
'not_applicable' => [
|
||||||
|
'axis' => 'publication_readiness',
|
||||||
|
'label' => 'Not applicable',
|
||||||
|
'color' => 'gray',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['N/A'],
|
||||||
|
'notes' => 'Publication readiness does not apply to this artifact family.',
|
||||||
|
],
|
||||||
|
'internal_only' => [
|
||||||
|
'axis' => 'publication_readiness',
|
||||||
|
'label' => 'Internal only',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'optional',
|
||||||
|
'legacy_aliases' => ['Draft'],
|
||||||
|
'notes' => 'The artifact is useful internally but not ready for stakeholder delivery.',
|
||||||
|
],
|
||||||
|
'publishable' => [
|
||||||
|
'axis' => 'publication_readiness',
|
||||||
|
'label' => 'Publishable',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Ready'],
|
||||||
|
'notes' => 'The artifact is ready for stakeholder publication or export.',
|
||||||
|
],
|
||||||
|
'blocked' => [
|
||||||
|
'axis' => 'publication_readiness',
|
||||||
|
'label' => 'Blocked',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Not publishable'],
|
||||||
|
'notes' => 'The artifact exists but is blocked from publication or export.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'governance_artifact_actionability' => [
|
||||||
|
'none' => [
|
||||||
|
'axis' => 'operator_actionability',
|
||||||
|
'label' => 'No action needed',
|
||||||
|
'color' => 'gray',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['No follow-up'],
|
||||||
|
'notes' => 'The current non-green state is informational only and does not require action.',
|
||||||
|
],
|
||||||
|
'optional' => [
|
||||||
|
'axis' => 'operator_actionability',
|
||||||
|
'label' => 'Review recommended',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'optional',
|
||||||
|
'legacy_aliases' => ['Optional follow-up'],
|
||||||
|
'notes' => 'The artifact can be used, but the operator should review the follow-up guidance.',
|
||||||
|
],
|
||||||
|
'required' => [
|
||||||
|
'axis' => 'operator_actionability',
|
||||||
|
'label' => 'Action required',
|
||||||
|
'color' => 'danger',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Required follow-up'],
|
||||||
|
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'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',
|
||||||
@ -586,6 +823,11 @@ public static function curatedExamples(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
||||||
|
['name' => 'Artifact exists but is not usable', 'domain' => BadgeDomain::GovernanceArtifactExistence, 'raw_value' => 'created_but_not_usable'],
|
||||||
|
['name' => 'Artifact is trustworthy', 'domain' => BadgeDomain::GovernanceArtifactContent, 'raw_value' => 'trusted'],
|
||||||
|
['name' => 'Artifact is stale', 'domain' => BadgeDomain::GovernanceArtifactFreshness, 'raw_value' => 'stale'],
|
||||||
|
['name' => 'Artifact is publishable', 'domain' => BadgeDomain::GovernanceArtifactPublicationReadiness, 'raw_value' => 'publishable'],
|
||||||
|
['name' => 'Artifact requires action', 'domain' => BadgeDomain::GovernanceArtifactActionability, 'raw_value' => 'required'],
|
||||||
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
||||||
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
||||||
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
enum OperatorSemanticAxis: string
|
enum OperatorSemanticAxis: string
|
||||||
{
|
{
|
||||||
|
case ArtifactExistence = 'artifact_existence';
|
||||||
case ExecutionLifecycle = 'execution_lifecycle';
|
case ExecutionLifecycle = 'execution_lifecycle';
|
||||||
case ExecutionOutcome = 'execution_outcome';
|
case ExecutionOutcome = 'execution_outcome';
|
||||||
case ItemResult = 'item_result';
|
case ItemResult = 'item_result';
|
||||||
@ -20,6 +21,7 @@ enum OperatorSemanticAxis: string
|
|||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
self::ArtifactExistence => 'Artifact existence',
|
||||||
self::ExecutionLifecycle => 'Execution lifecycle',
|
self::ExecutionLifecycle => 'Execution lifecycle',
|
||||||
self::ExecutionOutcome => 'Execution outcome',
|
self::ExecutionOutcome => 'Execution outcome',
|
||||||
self::ItemResult => 'Item result',
|
self::ItemResult => 'Item result',
|
||||||
@ -36,6 +38,7 @@ public function label(): string
|
|||||||
public function definition(): string
|
public function definition(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
self::ArtifactExistence => 'Whether the intended governance artifact actually exists and can be located.',
|
||||||
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
||||||
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
||||||
self::ItemResult => 'How one restore or preview item resolved.',
|
self::ItemResult => 'How one restore or preview item resolved.',
|
||||||
|
|||||||
@ -5,11 +5,13 @@
|
|||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@ -73,7 +75,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 +92,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 +306,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 +334,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 +350,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -583,4 +605,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,71 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
@ -105,4 +105,20 @@ public static function allowedSummaryKeys(): array
|
|||||||
{
|
{
|
||||||
return OperationSummaryKeys::all();
|
return OperationSummaryKeys::all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function governanceArtifactFamily(string $operationType): ?string
|
||||||
|
{
|
||||||
|
return match (trim($operationType)) {
|
||||||
|
'baseline_capture' => 'baseline_snapshot',
|
||||||
|
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
|
||||||
|
'tenant.review.compose' => 'tenant_review',
|
||||||
|
'tenant.review_pack.generate' => 'review_pack',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isGovernanceArtifactOperation(string $operationType): bool
|
||||||
|
{
|
||||||
|
return self::governanceArtifactFamily($operationType) !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,13 +5,20 @@
|
|||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantReview;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
|
||||||
final class OperationRunLinks
|
final class OperationRunLinks
|
||||||
@ -79,6 +86,14 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'baseline_capture') {
|
||||||
|
$snapshotId = data_get($context, 'result.snapshot_id');
|
||||||
|
|
||||||
|
if (is_numeric($snapshotId)) {
|
||||||
|
$links['Baseline Snapshot'] = BaselineSnapshotResource::getUrl('view', ['record' => (int) $snapshotId], panel: 'admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
@ -101,6 +116,39 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'tenant.evidence.snapshot.generate') {
|
||||||
|
$snapshot = EvidenceSnapshot::query()
|
||||||
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($snapshot instanceof EvidenceSnapshot) {
|
||||||
|
$links['Evidence Snapshot'] = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'tenant.review.compose') {
|
||||||
|
$review = TenantReview::query()
|
||||||
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($review instanceof TenantReview) {
|
||||||
|
$links['Tenant Review'] = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'tenant.review_pack.generate') {
|
||||||
|
$pack = ReviewPack::query()
|
||||||
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($pack instanceof ReviewPack) {
|
||||||
|
$links['Review Pack'] = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,6 +7,7 @@
|
|||||||
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 Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
@ -99,6 +100,15 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
|||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
$reasonEnvelope = self::reasonEnvelope($run);
|
||||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
||||||
$nextStepLabel = self::firstNextStepLabel($run);
|
$nextStepLabel = self::firstNextStepLabel($run);
|
||||||
|
$freshnessState = self::freshnessState($run);
|
||||||
|
|
||||||
|
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 $reasonGuidance ?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
||||||
return $reasonGuidance;
|
return $reasonGuidance;
|
||||||
@ -130,11 +140,29 @@ public static function surfaceFailureDetail(OperationRun $run): ?string
|
|||||||
return $reasonEnvelope->shortExplanation;
|
return $reasonEnvelope->shortExplanation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self::freshnessState($run)->isLikelyStale()) {
|
||||||
|
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
|
||||||
|
}
|
||||||
|
|
||||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||||
|
|
||||||
return self::sanitizeFailureMessage($failureMessage);
|
return self::sanitizeFailureMessage($failureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function freshnessState(OperationRun $run): OperationRunFreshnessState
|
||||||
|
{
|
||||||
|
return $run->freshnessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return match (self::freshnessState($run)) {
|
||||||
|
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||||
|
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{titleSuffix: string, body: string, status: string}
|
* @return array{titleSuffix: string, body: string, status: string}
|
||||||
*/
|
*/
|
||||||
@ -142,6 +170,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' => [
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
@ -15,6 +16,8 @@
|
|||||||
|
|
||||||
final class ReasonPresenter
|
final class ReasonPresenter
|
||||||
{
|
{
|
||||||
|
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ReasonTranslator $reasonTranslator,
|
private readonly ReasonTranslator $reasonTranslator,
|
||||||
) {}
|
) {}
|
||||||
@ -89,6 +92,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;
|
||||||
}
|
}
|
||||||
@ -134,6 +138,22 @@ public function forRbacReason(RbacReason|string|null $reasonCode, string $surfac
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function forArtifactTruth(
|
||||||
|
?string $reasonCode,
|
||||||
|
string $surface = 'detail',
|
||||||
|
array $context = [],
|
||||||
|
): ?ReasonResolutionEnvelope {
|
||||||
|
return $this->reasonTranslator->translate(
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
artifactKey: self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT,
|
||||||
|
surface: $surface,
|
||||||
|
context: $context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string
|
public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string
|
||||||
{
|
{
|
||||||
return $envelope?->diagnosticCode();
|
return $envelope?->diagnosticCode();
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
namespace App\Support\ReasonTranslation;
|
||||||
|
|
||||||
|
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;
|
||||||
@ -18,6 +20,8 @@ final class ReasonTranslator
|
|||||||
|
|
||||||
public const string RBAC_ARTIFACT = 'rbac_reason';
|
public const string RBAC_ARTIFACT = 'rbac_reason';
|
||||||
|
|
||||||
|
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = 'governance_artifact_truth_reason';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||||
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
||||||
@ -41,12 +45,16 @@ 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 && 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,
|
||||||
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||||
|
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT => $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context),
|
||||||
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||||
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
|
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
|
||||||
};
|
};
|
||||||
@ -71,4 +79,122 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||||
|
|
||||||
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
|
||||||
|
final readonly class ArtifactTruthCause
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $nextSteps
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $reasonCode,
|
||||||
|
public ?string $translationArtifact,
|
||||||
|
public ?string $operatorLabel,
|
||||||
|
public ?string $shortExplanation,
|
||||||
|
public ?string $diagnosticCode,
|
||||||
|
public array $nextSteps = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromReasonResolutionEnvelope(
|
||||||
|
?ReasonResolutionEnvelope $reason,
|
||||||
|
?string $translationArtifact = null,
|
||||||
|
): ?self {
|
||||||
|
if (! $reason instanceof ReasonResolutionEnvelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
reasonCode: $reason->internalCode,
|
||||||
|
translationArtifact: $translationArtifact,
|
||||||
|
operatorLabel: $reason->operatorLabel,
|
||||||
|
shortExplanation: $reason->shortExplanation,
|
||||||
|
diagnosticCode: $reason->diagnosticCode(),
|
||||||
|
nextSteps: array_values(array_map(
|
||||||
|
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||||
|
$reason->nextSteps,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* reasonCode: ?string,
|
||||||
|
* translationArtifact: ?string,
|
||||||
|
* operatorLabel: ?string,
|
||||||
|
* shortExplanation: ?string,
|
||||||
|
* diagnosticCode: ?string,
|
||||||
|
* nextSteps: array<int, string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reasonCode' => $this->reasonCode,
|
||||||
|
'translationArtifact' => $this->translationArtifact,
|
||||||
|
'operatorLabel' => $this->operatorLabel,
|
||||||
|
'shortExplanation' => $this->shortExplanation,
|
||||||
|
'diagnosticCode' => $this->diagnosticCode,
|
||||||
|
'nextSteps' => $this->nextSteps,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final readonly class ArtifactTruthDimension
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $axis,
|
||||||
|
public string $state,
|
||||||
|
public string $label,
|
||||||
|
public string $classification,
|
||||||
|
public ?BadgeDomain $badgeDomain = null,
|
||||||
|
public ?string $badgeState = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isPrimary(): bool
|
||||||
|
{
|
||||||
|
return $this->classification === 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDiagnostic(): bool
|
||||||
|
{
|
||||||
|
return $this->classification === 'diagnostic';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function badgeSpec(): ?BadgeSpec
|
||||||
|
{
|
||||||
|
if (! $this->badgeDomain instanceof BadgeDomain || ! is_string($this->badgeState) || trim($this->badgeState) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \App\Support\Badges\BadgeCatalog::spec($this->badgeDomain, $this->badgeState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* axis: string,
|
||||||
|
* state: string,
|
||||||
|
* label: string,
|
||||||
|
* classification: string,
|
||||||
|
* badgeDomain: ?string,
|
||||||
|
* badgeState: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'axis' => $this->axis,
|
||||||
|
'state' => $this->state,
|
||||||
|
'label' => $this->label,
|
||||||
|
'classification' => $this->classification,
|
||||||
|
'badgeDomain' => $this->badgeDomain?->value,
|
||||||
|
'badgeState' => $this->badgeState,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php
Normal file
137
app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final readonly class ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, ArtifactTruthDimension> $dimensions
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $artifactFamily,
|
||||||
|
public string $artifactKey,
|
||||||
|
public int $workspaceId,
|
||||||
|
public ?int $tenantId,
|
||||||
|
public ?string $executionOutcome,
|
||||||
|
public string $artifactExistence,
|
||||||
|
public string $contentState,
|
||||||
|
public string $freshnessState,
|
||||||
|
public ?string $publicationReadiness,
|
||||||
|
public string $supportState,
|
||||||
|
public string $actionability,
|
||||||
|
public string $primaryLabel,
|
||||||
|
public ?string $primaryExplanation,
|
||||||
|
public ?string $diagnosticLabel,
|
||||||
|
public ?string $nextActionLabel,
|
||||||
|
public ?string $nextActionUrl,
|
||||||
|
public ?int $relatedRunId,
|
||||||
|
public ?string $relatedArtifactUrl,
|
||||||
|
public array $dimensions = [],
|
||||||
|
public ?ArtifactTruthCause $reason = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function primaryDimension(): ?ArtifactTruthDimension
|
||||||
|
{
|
||||||
|
foreach ($this->dimensions as $dimension) {
|
||||||
|
if ($dimension instanceof ArtifactTruthDimension && $dimension->isPrimary()) {
|
||||||
|
return $dimension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function primaryBadgeSpec(): BadgeSpec
|
||||||
|
{
|
||||||
|
$dimension = $this->primaryDimension();
|
||||||
|
|
||||||
|
return $dimension?->badgeSpec() ?? \App\Support\Badges\BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextStepText(): string
|
||||||
|
{
|
||||||
|
if (is_string($this->nextActionLabel) && trim($this->nextActionLabel) !== '') {
|
||||||
|
return $this->nextActionLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->actionability) {
|
||||||
|
'none' => 'No action needed',
|
||||||
|
'optional' => 'Review recommended',
|
||||||
|
default => 'Action required',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* artifactFamily: string,
|
||||||
|
* artifactKey: string,
|
||||||
|
* workspaceId: int,
|
||||||
|
* tenantId: ?int,
|
||||||
|
* executionOutcome: ?string,
|
||||||
|
* artifactExistence: string,
|
||||||
|
* contentState: string,
|
||||||
|
* freshnessState: string,
|
||||||
|
* publicationReadiness: ?string,
|
||||||
|
* supportState: string,
|
||||||
|
* actionability: string,
|
||||||
|
* primaryLabel: string,
|
||||||
|
* primaryExplanation: ?string,
|
||||||
|
* diagnosticLabel: ?string,
|
||||||
|
* nextActionLabel: ?string,
|
||||||
|
* nextActionUrl: ?string,
|
||||||
|
* relatedRunId: ?int,
|
||||||
|
* relatedArtifactUrl: ?string,
|
||||||
|
* dimensions: array<int, array{
|
||||||
|
* axis: string,
|
||||||
|
* state: string,
|
||||||
|
* label: string,
|
||||||
|
* classification: string,
|
||||||
|
* badgeDomain: ?string,
|
||||||
|
* badgeState: ?string
|
||||||
|
* }>,
|
||||||
|
* reason: ?array{
|
||||||
|
* reasonCode: ?string,
|
||||||
|
* translationArtifact: ?string,
|
||||||
|
* operatorLabel: ?string,
|
||||||
|
* shortExplanation: ?string,
|
||||||
|
* diagnosticCode: ?string,
|
||||||
|
* nextSteps: array<int, string>
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'artifactFamily' => $this->artifactFamily,
|
||||||
|
'artifactKey' => $this->artifactKey,
|
||||||
|
'workspaceId' => $this->workspaceId,
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'executionOutcome' => $this->executionOutcome,
|
||||||
|
'artifactExistence' => $this->artifactExistence,
|
||||||
|
'contentState' => $this->contentState,
|
||||||
|
'freshnessState' => $this->freshnessState,
|
||||||
|
'publicationReadiness' => $this->publicationReadiness,
|
||||||
|
'supportState' => $this->supportState,
|
||||||
|
'actionability' => $this->actionability,
|
||||||
|
'primaryLabel' => $this->primaryLabel,
|
||||||
|
'primaryExplanation' => $this->primaryExplanation,
|
||||||
|
'diagnosticLabel' => $this->diagnosticLabel,
|
||||||
|
'nextActionLabel' => $this->nextActionLabel,
|
||||||
|
'nextActionUrl' => $this->nextActionUrl,
|
||||||
|
'relatedRunId' => $this->relatedRunId,
|
||||||
|
'relatedArtifactUrl' => $this->relatedArtifactUrl,
|
||||||
|
'dimensions' => array_values(array_map(
|
||||||
|
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
|
||||||
|
array_filter(
|
||||||
|
$this->dimensions,
|
||||||
|
static fn (mixed $dimension): bool => $dimension instanceof ArtifactTruthDimension,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
'reason' => $this->reason?->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,790 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\TenantReview;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
final class ArtifactTruthPresenter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$record instanceof BaselineSnapshot => $this->forBaselineSnapshot($record),
|
||||||
|
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshot($record),
|
||||||
|
$record instanceof TenantReview => $this->forTenantReview($record),
|
||||||
|
$record instanceof ReviewPack => $this->forReviewPack($record),
|
||||||
|
$record instanceof OperationRun => $this->forOperationRun($record),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$snapshot->loadMissing('baselineProfile');
|
||||||
|
|
||||||
|
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||||
|
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
||||||
|
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
||||||
|
$effectiveSnapshot = $snapshot->baselineProfile !== null
|
||||||
|
? $this->snapshotTruthResolver->resolveEffectiveSnapshot($snapshot->baselineProfile)
|
||||||
|
: null;
|
||||||
|
$isHistorical = $this->snapshotTruthResolver->isHistoricallySuperseded($snapshot, $effectiveSnapshot);
|
||||||
|
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
||||||
|
$severeGapReasons = array_filter(
|
||||||
|
$gapReasons,
|
||||||
|
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
||||||
|
ARRAY_FILTER_USE_BOTH,
|
||||||
|
);
|
||||||
|
$reasonCode = $this->snapshotTruthResolver->artifactReasonCode($snapshot, $effectiveSnapshot)
|
||||||
|
?? $this->firstReasonCode($severeGapReasons);
|
||||||
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
|
||||||
|
$artifactExistence = match (true) {
|
||||||
|
$isHistorical => 'historical_only',
|
||||||
|
$snapshot->isBuilding(), $snapshot->isIncomplete() => 'created_but_not_usable',
|
||||||
|
! $snapshot->isConsumable() => 'created_but_not_usable',
|
||||||
|
default => 'created',
|
||||||
|
};
|
||||||
|
|
||||||
|
$contentState = match ($fidelity) {
|
||||||
|
FidelityState::Full => $snapshot->isIncomplete()
|
||||||
|
? ($hasItems ? 'partial' : 'missing_input')
|
||||||
|
: ($severeGapReasons === [] ? 'trusted' : 'partial'),
|
||||||
|
FidelityState::Partial => $snapshot->isBuilding() ? 'missing_input' : 'partial',
|
||||||
|
FidelityState::ReferenceOnly => $snapshot->isBuilding() ? 'missing_input' : 'reference_only',
|
||||||
|
FidelityState::Unsupported => $snapshot->isBuilding() ? 'missing_input' : ($hasItems ? 'unsupported' : 'trusted'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (($snapshot->isBuilding() || $snapshot->isIncomplete()) && $reasonCode !== null) {
|
||||||
|
$contentState = 'missing_input';
|
||||||
|
}
|
||||||
|
|
||||||
|
$freshnessState = match (true) {
|
||||||
|
$snapshot->isBuilding() => 'unknown',
|
||||||
|
$isHistorical => 'stale',
|
||||||
|
default => 'current',
|
||||||
|
};
|
||||||
|
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
||||||
|
$actionability = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
|
$snapshot->isBuilding() => 'optional',
|
||||||
|
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||||
|
$freshnessState === 'stale' => 'optional',
|
||||||
|
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
||||||
|
default => 'required',
|
||||||
|
};
|
||||||
|
|
||||||
|
[$primaryDomain, $primaryState, $primaryExplanation, $diagnosticLabel] = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => [
|
||||||
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
'historical_only',
|
||||||
|
$reason?->shortExplanation ?? 'This snapshot remains readable for history, but a newer complete snapshot is the current baseline truth.',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label,
|
||||||
|
],
|
||||||
|
$artifactExistence === 'created_but_not_usable' => [
|
||||||
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
'created_but_not_usable',
|
||||||
|
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||||
|
],
|
||||||
|
$contentState !== 'trusted' => [
|
||||||
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
|
$contentState,
|
||||||
|
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||||
|
$supportState === 'limited_support'
|
||||||
|
? 'Support limited'
|
||||||
|
: BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
|
'trusted',
|
||||||
|
$hasItems
|
||||||
|
? 'Structured capture content is available for this baseline snapshot.'
|
||||||
|
: 'This empty baseline snapshot completed successfully and can still be used for compare.',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->makeEnvelope(
|
||||||
|
artifactFamily: 'baseline_snapshot',
|
||||||
|
artifactKey: 'baseline_snapshot:'.$snapshot->getKey(),
|
||||||
|
workspaceId: (int) $snapshot->workspace_id,
|
||||||
|
tenantId: null,
|
||||||
|
executionOutcome: null,
|
||||||
|
artifactExistence: $artifactExistence,
|
||||||
|
contentState: $contentState,
|
||||||
|
freshnessState: $freshnessState,
|
||||||
|
publicationReadiness: null,
|
||||||
|
supportState: $supportState,
|
||||||
|
actionability: $actionability,
|
||||||
|
primaryDomain: $primaryDomain,
|
||||||
|
primaryState: $primaryState,
|
||||||
|
primaryExplanation: $primaryExplanation,
|
||||||
|
diagnosticLabel: $diagnosticLabel,
|
||||||
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
|
nextActionLabel: $this->nextActionLabel(
|
||||||
|
$actionability,
|
||||||
|
$reason,
|
||||||
|
match ($actionability) {
|
||||||
|
'required' => 'Inspect the related capture diagnostics before using this snapshot',
|
||||||
|
'optional' => 'Review the capture diagnostics before comparing this snapshot',
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
nextActionUrl: null,
|
||||||
|
relatedRunId: null,
|
||||||
|
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||||
|
includePublicationDimension: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$snapshot->loadMissing('tenant');
|
||||||
|
|
||||||
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||||
|
$missingDimensions = (int) ($summary['missing_dimensions'] ?? 0);
|
||||||
|
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
|
||||||
|
$status = (string) $snapshot->status;
|
||||||
|
|
||||||
|
$artifactExistence = match ($status) {
|
||||||
|
'queued', 'generating' => 'not_created',
|
||||||
|
'expired', 'superseded' => 'historical_only',
|
||||||
|
'failed' => 'created_but_not_usable',
|
||||||
|
default => 'created',
|
||||||
|
};
|
||||||
|
|
||||||
|
$contentState = match (true) {
|
||||||
|
$artifactExistence === 'not_created' => 'missing_input',
|
||||||
|
$artifactExistence === 'historical_only' && $snapshot->completeness_state === 'missing' => 'empty',
|
||||||
|
$status === 'failed' => 'missing_input',
|
||||||
|
$snapshot->completeness_state === 'missing' => 'missing_input',
|
||||||
|
$snapshot->completeness_state === 'partial' => 'partial',
|
||||||
|
default => 'trusted',
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((int) ($summary['dimension_count'] ?? 0) === 0 && $artifactExistence !== 'not_created') {
|
||||||
|
$contentState = 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
$freshnessState = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'stale',
|
||||||
|
$snapshot->completeness_state === 'stale' || $staleDimensions > 0 => 'stale',
|
||||||
|
in_array($status, ['queued', 'generating'], true) => 'unknown',
|
||||||
|
default => 'current',
|
||||||
|
};
|
||||||
|
|
||||||
|
$actionability = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
|
in_array($status, ['queued', 'generating'], true) => 'optional',
|
||||||
|
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||||
|
$freshnessState === 'stale' => 'optional',
|
||||||
|
default => 'required',
|
||||||
|
};
|
||||||
|
|
||||||
|
$reasonCode = match (true) {
|
||||||
|
$status === 'failed' => 'evidence_generation_failed',
|
||||||
|
$missingDimensions > 0 => 'evidence_missing_dimensions',
|
||||||
|
$staleDimensions > 0 => 'evidence_stale_dimensions',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
|
||||||
|
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||||
|
$artifactExistence === 'not_created' => [
|
||||||
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
'not_created',
|
||||||
|
'The evidence generation request exists, but no tenant snapshot is available yet.',
|
||||||
|
],
|
||||||
|
$artifactExistence === 'historical_only' => [
|
||||||
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
'historical_only',
|
||||||
|
'This evidence snapshot remains available for history, but it is not the current working evidence artifact.',
|
||||||
|
],
|
||||||
|
$contentState !== 'trusted' => [
|
||||||
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
|
$contentState,
|
||||||
|
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||||
|
],
|
||||||
|
$freshnessState === 'stale' => [
|
||||||
|
BadgeDomain::GovernanceArtifactFreshness,
|
||||||
|
'stale',
|
||||||
|
$reason?->shortExplanation ?? 'The snapshot exists, but one or more evidence dimensions should be refreshed before relying on it.',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
BadgeDomain::GovernanceArtifactContent,
|
||||||
|
'trusted',
|
||||||
|
'A current evidence snapshot is available for review work.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
$nextActionUrl = $snapshot->operation_run_id
|
||||||
|
? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->makeEnvelope(
|
||||||
|
artifactFamily: 'evidence_snapshot',
|
||||||
|
artifactKey: 'evidence_snapshot:'.$snapshot->getKey(),
|
||||||
|
workspaceId: (int) $snapshot->workspace_id,
|
||||||
|
tenantId: $snapshot->tenant_id !== null ? (int) $snapshot->tenant_id : null,
|
||||||
|
executionOutcome: null,
|
||||||
|
artifactExistence: $artifactExistence,
|
||||||
|
contentState: $contentState,
|
||||||
|
freshnessState: $freshnessState,
|
||||||
|
publicationReadiness: null,
|
||||||
|
supportState: 'normal',
|
||||||
|
actionability: $actionability,
|
||||||
|
primaryDomain: $primaryDomain,
|
||||||
|
primaryState: $primaryState,
|
||||||
|
primaryExplanation: $primaryExplanation,
|
||||||
|
diagnosticLabel: $missingDimensions > 0 && $staleDimensions > 0
|
||||||
|
? sprintf('%d missing, %d stale dimensions', $missingDimensions, $staleDimensions)
|
||||||
|
: null,
|
||||||
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
|
nextActionLabel: $this->nextActionLabel(
|
||||||
|
$actionability,
|
||||||
|
$reason,
|
||||||
|
match ($actionability) {
|
||||||
|
'required' => 'Refresh evidence before using this snapshot',
|
||||||
|
'optional' => in_array($status, ['queued', 'generating'], true)
|
||||||
|
? 'Wait for evidence generation to finish'
|
||||||
|
: 'Review the evidence freshness before relying on this snapshot',
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
nextActionUrl: $nextActionUrl,
|
||||||
|
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
||||||
|
relatedArtifactUrl: $snapshot->tenant !== null
|
||||||
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
|
: null,
|
||||||
|
includePublicationDimension: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
|
|
||||||
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$publishBlockers = $review->publishBlockers();
|
||||||
|
$status = $review->statusEnum();
|
||||||
|
$completeness = $review->completenessEnum()->value;
|
||||||
|
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
||||||
|
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
||||||
|
|
||||||
|
$artifactExistence = match ($status) {
|
||||||
|
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
||||||
|
TenantReviewStatus::Failed => 'created_but_not_usable',
|
||||||
|
default => 'created',
|
||||||
|
};
|
||||||
|
|
||||||
|
$contentState = match ($completeness) {
|
||||||
|
TenantReviewCompletenessState::Complete->value => 'trusted',
|
||||||
|
TenantReviewCompletenessState::Partial->value => 'partial',
|
||||||
|
TenantReviewCompletenessState::Missing->value => 'missing_input',
|
||||||
|
TenantReviewCompletenessState::Stale->value => 'trusted',
|
||||||
|
default => 'partial',
|
||||||
|
};
|
||||||
|
|
||||||
|
$freshnessState = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'stale',
|
||||||
|
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
|
||||||
|
default => 'current',
|
||||||
|
};
|
||||||
|
|
||||||
|
$publicationReadiness = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'internal_only',
|
||||||
|
$status === TenantReviewStatus::Published => 'publishable',
|
||||||
|
$publishBlockers !== [] => 'blocked',
|
||||||
|
$status === TenantReviewStatus::Ready => 'publishable',
|
||||||
|
default => 'internal_only',
|
||||||
|
};
|
||||||
|
|
||||||
|
$actionability = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
|
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||||
|
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
|
||||||
|
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
|
||||||
|
default => 'required',
|
||||||
|
};
|
||||||
|
|
||||||
|
$reasonCode = match (true) {
|
||||||
|
$publishBlockers !== [] => 'review_publish_blocked',
|
||||||
|
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
||||||
|
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
|
||||||
|
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
|
||||||
|
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => [
|
||||||
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
'historical_only',
|
||||||
|
'This review remains available as historical evidence, but it is no longer the current review artifact.',
|
||||||
|
],
|
||||||
|
$publicationReadiness === 'blocked' => [
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
'blocked',
|
||||||
|
$publishBlockers[0] ?? $reason?->shortExplanation ?? 'This review exists, but it is blocked from publication or export.',
|
||||||
|
],
|
||||||
|
$publicationReadiness === 'internal_only' => [
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
'internal_only',
|
||||||
|
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
||||||
|
],
|
||||||
|
$freshnessState === 'stale' => [
|
||||||
|
BadgeDomain::GovernanceArtifactFreshness,
|
||||||
|
'stale',
|
||||||
|
$reason?->shortExplanation ?? 'The review exists, but one or more required sections should be refreshed before publication.',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
'publishable',
|
||||||
|
'This review is ready for publication and executive-pack export.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
$nextActionUrl = $review->operation_run_id
|
||||||
|
? OperationRunLinks::tenantlessView((int) $review->operation_run_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||||
|
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->makeEnvelope(
|
||||||
|
artifactFamily: 'tenant_review',
|
||||||
|
artifactKey: 'tenant_review:'.$review->getKey(),
|
||||||
|
workspaceId: (int) $review->workspace_id,
|
||||||
|
tenantId: $review->tenant_id !== null ? (int) $review->tenant_id : null,
|
||||||
|
executionOutcome: null,
|
||||||
|
artifactExistence: $artifactExistence,
|
||||||
|
contentState: $contentState,
|
||||||
|
freshnessState: $freshnessState,
|
||||||
|
publicationReadiness: $publicationReadiness,
|
||||||
|
supportState: 'normal',
|
||||||
|
actionability: $actionability,
|
||||||
|
primaryDomain: $primaryDomain,
|
||||||
|
primaryState: $primaryState,
|
||||||
|
primaryExplanation: $primaryExplanation,
|
||||||
|
diagnosticLabel: $contentState !== 'trusted'
|
||||||
|
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
||||||
|
: null,
|
||||||
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
|
nextActionLabel: $this->nextActionLabel(
|
||||||
|
$actionability,
|
||||||
|
$reason,
|
||||||
|
match ($actionability) {
|
||||||
|
'required' => 'Resolve the review blockers before publication',
|
||||||
|
'optional' => 'Complete the remaining review work before publication',
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
nextActionUrl: $nextActionUrl,
|
||||||
|
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
||||||
|
relatedArtifactUrl: $review->tenant !== null
|
||||||
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||||
|
: null,
|
||||||
|
includePublicationDimension: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$pack->loadMissing(['tenant', 'tenantReview']);
|
||||||
|
|
||||||
|
$summary = is_array($pack->summary) ? $pack->summary : [];
|
||||||
|
$status = (string) $pack->status;
|
||||||
|
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
||||||
|
$sourceReview = $pack->tenantReview;
|
||||||
|
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
||||||
|
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
||||||
|
|
||||||
|
$artifactExistence = match ($status) {
|
||||||
|
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
||||||
|
ReviewPackStatus::Expired->value => 'historical_only',
|
||||||
|
ReviewPackStatus::Failed->value => 'created_but_not_usable',
|
||||||
|
default => 'created',
|
||||||
|
};
|
||||||
|
|
||||||
|
$contentState = match (true) {
|
||||||
|
$artifactExistence === 'not_created' => 'missing_input',
|
||||||
|
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
||||||
|
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
||||||
|
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
|
||||||
|
default => 'trusted',
|
||||||
|
};
|
||||||
|
|
||||||
|
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
|
||||||
|
$publicationReadiness = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'internal_only',
|
||||||
|
$artifactExistence === 'not_created' => 'blocked',
|
||||||
|
$status === ReviewPackStatus::Failed->value => 'blocked',
|
||||||
|
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
|
||||||
|
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
||||||
|
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
||||||
|
};
|
||||||
|
|
||||||
|
$actionability = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
|
$publicationReadiness === 'publishable' => 'none',
|
||||||
|
$publicationReadiness === 'internal_only' => 'optional',
|
||||||
|
default => 'required',
|
||||||
|
};
|
||||||
|
|
||||||
|
$reasonCode = match (true) {
|
||||||
|
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
||||||
|
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
||||||
|
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
|
||||||
|
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
|
||||||
|
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => [
|
||||||
|
BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
'historical_only',
|
||||||
|
'This pack remains available as a historical export, but it is no longer the current stakeholder artifact.',
|
||||||
|
],
|
||||||
|
$publicationReadiness === 'blocked' => [
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
'blocked',
|
||||||
|
$sourceBlockers[0] ?? $reason?->shortExplanation ?? 'A pack file is not yet available for trustworthy stakeholder delivery.',
|
||||||
|
],
|
||||||
|
$publicationReadiness === 'internal_only' => [
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
'internal_only',
|
||||||
|
'This pack can be reviewed internally, but the source review is not currently publishable.',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
'publishable',
|
||||||
|
'This executive pack is ready for stakeholder delivery.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
$nextActionUrl = null;
|
||||||
|
|
||||||
|
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||||
|
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
||||||
|
} elseif ($pack->operation_run_id !== null) {
|
||||||
|
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->makeEnvelope(
|
||||||
|
artifactFamily: 'review_pack',
|
||||||
|
artifactKey: 'review_pack:'.$pack->getKey(),
|
||||||
|
workspaceId: (int) $pack->workspace_id,
|
||||||
|
tenantId: $pack->tenant_id !== null ? (int) $pack->tenant_id : null,
|
||||||
|
executionOutcome: null,
|
||||||
|
artifactExistence: $artifactExistence,
|
||||||
|
contentState: $contentState,
|
||||||
|
freshnessState: $freshnessState,
|
||||||
|
publicationReadiness: $publicationReadiness,
|
||||||
|
supportState: 'normal',
|
||||||
|
actionability: $actionability,
|
||||||
|
primaryDomain: $primaryDomain,
|
||||||
|
primaryState: $primaryState,
|
||||||
|
primaryExplanation: $primaryExplanation,
|
||||||
|
diagnosticLabel: $contentState !== 'trusted'
|
||||||
|
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
||||||
|
: null,
|
||||||
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
|
nextActionLabel: $this->nextActionLabel(
|
||||||
|
$actionability,
|
||||||
|
$reason,
|
||||||
|
match ($actionability) {
|
||||||
|
'required' => 'Open the source review before sharing this pack',
|
||||||
|
'optional' => 'Review the source review before sharing this pack',
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
nextActionUrl: $nextActionUrl,
|
||||||
|
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
||||||
|
relatedArtifactUrl: $pack->tenant !== null
|
||||||
|
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||||
|
: null,
|
||||||
|
includePublicationDimension: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$artifact = $this->resolveArtifactForRun($run);
|
||||||
|
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
||||||
|
|
||||||
|
if ($artifact !== null) {
|
||||||
|
$artifactEnvelope = $this->for($artifact);
|
||||||
|
|
||||||
|
if ($artifactEnvelope instanceof ArtifactTruthEnvelope) {
|
||||||
|
$diagnosticParts = array_values(array_filter([
|
||||||
|
BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label !== 'Unknown'
|
||||||
|
? BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label
|
||||||
|
: null,
|
||||||
|
$artifactEnvelope->diagnosticLabel,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return $this->makeEnvelope(
|
||||||
|
artifactFamily: 'artifact_run',
|
||||||
|
artifactKey: 'artifact_run:'.$run->getKey(),
|
||||||
|
workspaceId: (int) $run->workspace_id,
|
||||||
|
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||||
|
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
||||||
|
artifactExistence: $artifactEnvelope->artifactExistence,
|
||||||
|
contentState: $artifactEnvelope->contentState,
|
||||||
|
freshnessState: $artifactEnvelope->freshnessState,
|
||||||
|
publicationReadiness: $artifactEnvelope->publicationReadiness,
|
||||||
|
supportState: $artifactEnvelope->supportState,
|
||||||
|
actionability: $artifactEnvelope->actionability,
|
||||||
|
primaryDomain: $artifactEnvelope->primaryDimension()?->badgeDomain ?? BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
primaryState: $artifactEnvelope->primaryDimension()?->badgeState ?? $artifactEnvelope->artifactExistence,
|
||||||
|
primaryExplanation: $artifactEnvelope->primaryExplanation ?? $reason?->shortExplanation ?? 'The run finished, but the related artifact needs review.',
|
||||||
|
diagnosticLabel: $diagnosticParts === [] ? null : implode(' · ', $diagnosticParts),
|
||||||
|
reason: $artifactEnvelope->reason,
|
||||||
|
nextActionLabel: $artifactEnvelope->nextActionLabel,
|
||||||
|
nextActionUrl: $artifactEnvelope->relatedArtifactUrl ?? $artifactEnvelope->nextActionUrl,
|
||||||
|
relatedRunId: (int) $run->getKey(),
|
||||||
|
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
||||||
|
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$artifactExistence = match ((string) $run->status) {
|
||||||
|
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'not_created',
|
||||||
|
default => 'not_created',
|
||||||
|
};
|
||||||
|
$contentState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||||
|
? 'missing_input'
|
||||||
|
: 'empty';
|
||||||
|
$actionability = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||||
|
? 'required'
|
||||||
|
: 'optional';
|
||||||
|
$primaryState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||||
|
? 'created_but_not_usable'
|
||||||
|
: 'not_created';
|
||||||
|
|
||||||
|
return $this->makeEnvelope(
|
||||||
|
artifactFamily: 'artifact_run',
|
||||||
|
artifactKey: 'artifact_run:'.$run->getKey(),
|
||||||
|
workspaceId: (int) $run->workspace_id,
|
||||||
|
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||||
|
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
||||||
|
artifactExistence: $artifactExistence,
|
||||||
|
contentState: $contentState,
|
||||||
|
freshnessState: 'unknown',
|
||||||
|
publicationReadiness: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||||
|
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review'
|
||||||
|
? 'blocked'
|
||||||
|
: null,
|
||||||
|
supportState: 'normal',
|
||||||
|
actionability: $actionability,
|
||||||
|
primaryDomain: BadgeDomain::GovernanceArtifactExistence,
|
||||||
|
primaryState: $primaryState,
|
||||||
|
primaryExplanation: $reason?->shortExplanation ?? match ((string) $run->status) {
|
||||||
|
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'The artifact-producing run is still in progress, so no artifact is available yet.',
|
||||||
|
default => 'The run finished without a usable artifact result.',
|
||||||
|
},
|
||||||
|
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
||||||
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
|
nextActionLabel: $this->nextActionLabel(
|
||||||
|
$actionability,
|
||||||
|
$reason,
|
||||||
|
$actionability === 'required'
|
||||||
|
? 'Inspect the blocked run details before retrying'
|
||||||
|
: 'Wait for the artifact-producing run to finish',
|
||||||
|
),
|
||||||
|
nextActionUrl: null,
|
||||||
|
relatedRunId: (int) $run->getKey(),
|
||||||
|
relatedArtifactUrl: null,
|
||||||
|
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||||
|
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|EvidenceSnapshot|TenantReview|ReviewPack|null
|
||||||
|
{
|
||||||
|
return match (OperationCatalog::governanceArtifactFamily((string) $run->type)) {
|
||||||
|
'baseline_snapshot' => $run->relatedArtifactId() !== null
|
||||||
|
? BaselineSnapshot::query()->with('baselineProfile')->find($run->relatedArtifactId())
|
||||||
|
: null,
|
||||||
|
'evidence_snapshot' => EvidenceSnapshot::query()->with('tenant')->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||||
|
'tenant_review' => TenantReview::query()->with(['tenant', 'currentExportReviewPack'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||||
|
'review_pack' => ReviewPack::query()->with(['tenant', 'tenantReview'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function contentExplanation(string $contentState): string
|
||||||
|
{
|
||||||
|
return match ($contentState) {
|
||||||
|
'partial' => 'The artifact exists, but the captured content is incomplete for the primary operator task.',
|
||||||
|
'missing_input' => 'The artifact is blocked by missing upstream inputs or failed capture prerequisites.',
|
||||||
|
'metadata_only' => 'Only metadata was captured for this artifact. Use diagnostics for context, not as the primary truth signal.',
|
||||||
|
'reference_only' => 'Only reference-level placeholders were captured for this artifact.',
|
||||||
|
'empty' => 'The artifact row exists, but it does not contain usable captured content.',
|
||||||
|
'unsupported' => 'Structured support is limited for this artifact family, so the current rendering should be treated as diagnostic only.',
|
||||||
|
default => 'The artifact content is available for the intended workflow.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $reasons
|
||||||
|
*/
|
||||||
|
private function firstReasonCode(array $reasons): ?string
|
||||||
|
{
|
||||||
|
foreach ($reasons as $reason => $count) {
|
||||||
|
if ((int) $count > 0 && is_string($reason) && trim($reason) !== '') {
|
||||||
|
return trim($reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextActionLabel(
|
||||||
|
string $actionability,
|
||||||
|
?ReasonResolutionEnvelope $reason,
|
||||||
|
?string $fallback = null,
|
||||||
|
): ?string {
|
||||||
|
if ($actionability === 'none') {
|
||||||
|
return 'No action needed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($fallback) && trim($fallback) !== '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reason instanceof ReasonResolutionEnvelope && $reason->firstNextStep() !== null) {
|
||||||
|
return $reason->firstNextStep()?->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEnvelope(
|
||||||
|
string $artifactFamily,
|
||||||
|
string $artifactKey,
|
||||||
|
int $workspaceId,
|
||||||
|
?int $tenantId,
|
||||||
|
?string $executionOutcome,
|
||||||
|
string $artifactExistence,
|
||||||
|
string $contentState,
|
||||||
|
string $freshnessState,
|
||||||
|
?string $publicationReadiness,
|
||||||
|
string $supportState,
|
||||||
|
string $actionability,
|
||||||
|
BadgeDomain $primaryDomain,
|
||||||
|
string $primaryState,
|
||||||
|
?string $primaryExplanation,
|
||||||
|
?string $diagnosticLabel,
|
||||||
|
?ArtifactTruthCause $reason,
|
||||||
|
?string $nextActionLabel,
|
||||||
|
?string $nextActionUrl,
|
||||||
|
?int $relatedRunId,
|
||||||
|
?string $relatedArtifactUrl,
|
||||||
|
bool $includePublicationDimension,
|
||||||
|
): ArtifactTruthEnvelope {
|
||||||
|
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
||||||
|
$dimensions = [
|
||||||
|
$this->dimension(BadgeDomain::GovernanceArtifactExistence, $artifactExistence, 'artifact_existence', $primaryDomain === BadgeDomain::GovernanceArtifactExistence ? 'primary' : 'diagnostic'),
|
||||||
|
$this->dimension(BadgeDomain::GovernanceArtifactContent, $contentState, 'content_fidelity', $primaryDomain === BadgeDomain::GovernanceArtifactContent ? 'primary' : 'diagnostic'),
|
||||||
|
$this->dimension(BadgeDomain::GovernanceArtifactFreshness, $freshnessState, 'data_freshness', $primaryDomain === BadgeDomain::GovernanceArtifactFreshness ? 'primary' : 'diagnostic'),
|
||||||
|
$this->dimension(BadgeDomain::GovernanceArtifactActionability, $actionability, 'operator_actionability', 'diagnostic'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($includePublicationDimension && $publicationReadiness !== null) {
|
||||||
|
$dimensions[] = $this->dimension(
|
||||||
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
|
$publicationReadiness,
|
||||||
|
'publication_readiness',
|
||||||
|
$primaryDomain === BadgeDomain::GovernanceArtifactPublicationReadiness ? 'primary' : 'diagnostic',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($executionOutcome !== null && trim($executionOutcome) !== '') {
|
||||||
|
$dimensions[] = $this->dimension(BadgeDomain::OperationRunOutcome, $executionOutcome, 'execution_outcome', 'diagnostic');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($supportState === 'limited_support') {
|
||||||
|
$dimensions[] = new ArtifactTruthDimension(
|
||||||
|
axis: 'support_maturity',
|
||||||
|
state: 'limited_support',
|
||||||
|
label: 'Support limited',
|
||||||
|
classification: 'diagnostic',
|
||||||
|
badgeDomain: BadgeDomain::GovernanceArtifactContent,
|
||||||
|
badgeState: 'unsupported',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArtifactTruthEnvelope(
|
||||||
|
artifactFamily: $artifactFamily,
|
||||||
|
artifactKey: $artifactKey,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
executionOutcome: $executionOutcome,
|
||||||
|
artifactExistence: $artifactExistence,
|
||||||
|
contentState: $contentState,
|
||||||
|
freshnessState: $freshnessState,
|
||||||
|
publicationReadiness: $publicationReadiness,
|
||||||
|
supportState: $supportState,
|
||||||
|
actionability: $actionability,
|
||||||
|
primaryLabel: $primarySpec->label,
|
||||||
|
primaryExplanation: $primaryExplanation,
|
||||||
|
diagnosticLabel: $diagnosticLabel,
|
||||||
|
nextActionLabel: $nextActionLabel,
|
||||||
|
nextActionUrl: $nextActionUrl,
|
||||||
|
relatedRunId: $relatedRunId,
|
||||||
|
relatedArtifactUrl: $relatedArtifactUrl,
|
||||||
|
dimensions: array_values($dimensions),
|
||||||
|
reason: $reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dimension(
|
||||||
|
BadgeDomain $domain,
|
||||||
|
string $state,
|
||||||
|
string $axis,
|
||||||
|
string $classification,
|
||||||
|
): ArtifactTruthDimension {
|
||||||
|
return new ArtifactTruthDimension(
|
||||||
|
axis: $axis,
|
||||||
|
state: $state,
|
||||||
|
label: BadgeCatalog::spec($domain, $state)->label,
|
||||||
|
classification: $classification,
|
||||||
|
badgeDomain: $domain,
|
||||||
|
badgeState: $state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Product Roadmap
|
|||||||
> Strategic thematic blocks and release trajectory.
|
> Strategic thematic blocks and release trajectory.
|
||||||
> This is the "big picture" — not individual specs.
|
> This is the "big picture" — not individual specs.
|
||||||
|
|
||||||
**Last updated**: 2026-03-21
|
**Last updated**: 2026-03-23
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ ### Governance & Architecture Hardening
|
|||||||
|
|
||||||
**Active specs**: 144
|
**Active specs**: 144
|
||||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
||||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Provider Dispatch Gate Unification (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||||
|
|
||||||
### UI & Product Maturity Polish
|
### UI & Product Maturity Polish
|
||||||
|
|||||||
@ -3,9 +3,9 @@ # Spec Candidates
|
|||||||
> Concrete future specs waiting for prioritization.
|
> Concrete future specs waiting for prioritization.
|
||||||
> Each entry has enough structure to become a real spec when the time comes.
|
> Each entry has enough structure to become a real spec when the time comes.
|
||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, OperationRun humanization candidate, and absorbed extension targets updated)
|
**Last reviewed**: 2026-03-23 (added Operator Explanation Layer candidate; added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -27,28 +27,23 @@ ## Inbox
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Promoted to Spec
|
||||||
|
|
||||||
|
> Historical ledger for candidates that are no longer open. Keep them here so prioritization stays clean without losing decision history.
|
||||||
|
|
||||||
|
- Queued Execution Reauthorization and Scope Continuity → Spec 149 (`queued-execution-reauthorization`)
|
||||||
|
- Livewire Context Locking and Trusted-State Reduction → Spec 152 (`livewire-context-locking`)
|
||||||
|
- Evidence Domain Foundation → Spec 153 (`evidence-domain-foundation`)
|
||||||
|
- Operator Outcome Taxonomy and Cross-Domain State Separation → Spec 156 (`operator-outcome-taxonomy`)
|
||||||
|
- Operator Reason Code Translation and Humanization Contract → Spec 157 (`reason-code-translation`)
|
||||||
|
- Governance Artifact Truthful Outcomes & Fidelity Semantics → Spec 158 (`artifact-truth-semantics`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Qualified
|
## Qualified
|
||||||
|
|
||||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||||
|
|
||||||
### Queued Execution Reauthorization and Scope Continuity
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: architecture audit 2026-03-15
|
|
||||||
- **Problem**: Queued work still relies too heavily on dispatch-time actor and tenant state. Execution-time scope continuity and capability revalidation are not yet hardened as a canonical backend contract.
|
|
||||||
- **Why it matters**: This is a backend trust-gap on the mutation path. It creates the class of failure where a UI action was valid at dispatch time but the queued execution is no longer legitimate when it runs.
|
|
||||||
- **Proposed direction**: Define execution-time reauthorization, tenant operability rechecks, denial semantics, and audit visibility as a dedicated spec instead of scattering local `authorize()` patches.
|
|
||||||
- **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Livewire Context Locking and Trusted-State Reduction
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: architecture audit 2026-03-15
|
|
||||||
- **Problem**: Complex Livewire and Filament flows still expose ownership-relevant context in public component state without one explicit repo-wide hardening standard.
|
|
||||||
- **Why it matters**: This is a trust-boundary problem. Even without a known exploit, mutable client-visible identifiers and workflow context make future authorization and isolation mistakes more likely.
|
|
||||||
- **Proposed direction**: Define a reusable hardening pattern for locked identifiers, server-derived workflow truth, and forged-state regression tests on tier-1 component families.
|
|
||||||
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
|
|
||||||
- **Priority**: medium
|
|
||||||
|
|
||||||
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
||||||
@ -112,77 +107,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
|||||||
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
||||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Operator Outcome Taxonomy and Cross-Domain State Separation
|
|
||||||
- **Type**: foundation / hardening
|
|
||||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis
|
|
||||||
- **Problem**: TenantPilot uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") across at least 8 independent meaning axes that are systematically conflated. The product does not separate execution outcome from data completeness, evidence depth from product support maturity, governance deviation from publication readiness, data freshness from operator actionability. This produces a cross-product operator-trust problem: approximately 60% of warning-colored badges communicate something that is NOT a governance problem. Examples:
|
|
||||||
- Baseline snapshots show "Unsupported" (gray badge) and "Gaps present" (yellow badge) for policy types where the product simply uses a standard renderer — this is a product maturity fact, not a data quality failure, but it reads as a governance concern.
|
|
||||||
- Evidence completeness shows "Missing" (red/danger) when no findings exist yet — zero findings is a valid empty state, not missing evidence, but a new tenant permanently displays red badges.
|
|
||||||
- Restore runs show "Partial" (yellow) at both run and item level with different meanings — operators cannot determine scope of partial success or whether the tenant is in a consistent state.
|
|
||||||
- `OperationRunOutcome::PartiallySucceeded` provides no item-level breakdown — "99 of 100 succeeded" and "1 of 100 succeeded" are visually identical.
|
|
||||||
- "Blocked" appears across 4+ domains (operations, verification, restore, execution) without cause-specific explanation or next-action guidance.
|
|
||||||
- "Stale" is colored gray (passive/archived) when it actually represents data that requires operator attention (freshness issue, should be yellow/orange).
|
|
||||||
- Product support tier (e.g. fallback renderer vs. dedicated renderer) is surfaced on operator-facing badges where it should be diagnostics-only.
|
|
||||||
- **Why it matters now**: This is not cosmetic polish — it is a governance credibility problem. TenantPilot's core value proposition is trustworthy tenant governance and review. When 60% of warning badges are false alarms, operators are trained to ignore all warnings, which then masks the badges that represent real governance gaps. Every new domain (Entra Role Governance, Enterprise App Governance, Evidence Domain) will reproduce this conflation pattern unless a shared taxonomy is established first. The semantic-clarity audit classified three of the top five findings as P0 (actively damages operator trust). The problem is systemic and cross-domain — it cannot be solved by individual surface fixes without a shared foundation. The existing badge infrastructure (`BadgeCatalog`, `BadgeRenderer`, 43 badge domains, ~500 case values) is architecturally sound; the taxonomy feeding it is structurally wrong.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- **Define mandatory state-axis separation**: establish at minimum 8 independent axes that must never be flattened into a single badge or enum: execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability. Each axis has its own vocabulary, its own badge domain, and its own color rules.
|
|
||||||
- **Cross-domain term dictionary**: produce a canonical vocabulary where each term has exactly one meaning across the entire product. Replace the 12 overloaded terms with axis-specific alternatives (e.g. "Partially succeeded" → item-breakdown-aware messaging; "Missing" → "Not collected" / "Not generated" / "Not granted" depending on axis; "Gaps" → categorized coverage notes with cause separation; "Unsupported" → "Standard rendering" moved to diagnostics only; "Stale" → freshness axis with correct severity color).
|
|
||||||
- **Color-severity decision rules**: codify when red/yellow/blue/green/gray are appropriate. Red = execution failure, governance violation, data loss risk. Yellow = operator action recommended, approaching threshold, mixed outcome. Blue = in-progress, informational. Green = succeeded, complete. Gray = archived, not applicable. Never use yellow for product maturity facts. Never use gray for freshness issues. Never use red for valid-empty states.
|
|
||||||
- **Diagnostic vs. primary classification**: every piece of state information must be classified as either primary (operator-facing badge/summary) or diagnostic (expandable/secondary technical detail). Product support tier, raw reason codes, Graph API error codes, internal IDs, and renderer metadata are diagnostic-only. Execution outcome, governance status, data freshness, and operator next-actions are primary.
|
|
||||||
- **Mandatory next-action rule**: every non-green, non-gray state must include either an inline explanation of what happened and whether action is needed, a link to a resolution path, or an explicit "No action needed — this is expected" indicator. States that fail this rule are treated as incomplete operator surfaces.
|
|
||||||
- **Shared reference document**: produce `docs/ui/operator-semantic-taxonomy.md` (or equivalent) that all domain specs, badge mappers, and new surface implementations reference. This becomes the cross-domain truth source for operator-facing state presentation.
|
|
||||||
- **Key domain decisions to encode**:
|
|
||||||
- Product support maturity (renderer tier, capture mode) is NEVER operator-alarming — it belongs in diagnostics
|
|
||||||
- Valid empty states (zero findings, zero operations, no evidence yet) are NEVER "Missing" (red) — they are "Not yet collected" (neutral) or "Empty" (informational)
|
|
||||||
- Freshness is a separate axis from completeness — stale data requires action (yellow/orange), not archival (gray)
|
|
||||||
- "Partial" must always be qualified: partial execution (N of M items), partial coverage (which dimensions), partial depth (which items) — never bare "Partial" without context
|
|
||||||
- "Blocked" must always specify cause and next action
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: state-axis separation rules, term dictionary, color-severity rules, diagnostic/primary classification, next-action policy, enum/badge restructuring guidance, shared reference document, domain adoption sequence recommendations
|
|
||||||
- **Out of scope**: individual domain adoption (baseline cleanup, evidence reclassification, restore semantic cleanup, etc. are separate domain follow-up specs that consume this foundation), badge rendering infrastructure changes (BadgeCatalog/BadgeRenderer are architecturally sound — the taxonomy they consume is the problem), visual design system or theme work, new component development, operation naming vocabulary (tracked separately as "Operations Naming Harmonization")
|
|
||||||
- **Why this should be one coherent candidate rather than fragmented domain fixes**: The semantic-clarity audit proves the problem is structural, not local. The same 12 overloaded terms leak into every domain independently. Fixing baselines without a shared taxonomy produces a different vocabulary than fixing evidence, which produces a different vocabulary than fixing operations. Domain-by-domain cleanup without a shared foundation guarantees vocabulary drift between domains. This foundation spec defines rules; domain specs apply them. The foundation is small and decisive (it produces a reference document and restructuring guidelines); the domain adoption specs do the actual refactoring.
|
|
||||||
- **Affected workflow families / surfaces**: Operations (all run types), Baselines (snapshots, profiles, compare), Evidence (snapshots, completeness), Findings (governance validity, diff messages), Reviews / Review Packs (completeness, freshness, publication readiness), Restore (run status, item results, preview decisions), Inventory (KPI badges, coverage, snapshot mode), Onboarding / Verification (report status, check status), Alerts / Notifications (delivery status, failure messages)
|
|
||||||
- **Dependencies**: None — this is foundational work that other candidates consume. The badge infrastructure (`BadgeCatalog`, `BadgeRenderer`) is stable and does not need changes — only the taxonomy it serves.
|
|
||||||
- **Related specs / candidates**: Baseline Capture Truthful Outcomes (consumes this taxonomy for baseline-specific reason codes), Operator Presentation & Lifecycle Action Hardening (complementary — rendering enforcement), Operations Naming Harmonization (complementary — operation type vocabulary), Surface Signal-to-Noise Optimization (complementary — visual weight hierarchy), Admin Visual Language Canon (broader visual convention codification), semantic-clarity-audit.md (source audit)
|
|
||||||
- **Strategic sequencing**: This is the recommended FIRST candidate in the operator-truth initiative sequence. The Reason Code Translation candidate depends on this taxonomy to define human-readable label targets. The Provider Dispatch Gate candidate benefits from shared outcome vocabulary for preflight results. Without this foundation, both downstream candidates will invent local vocabularies.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Operator Reason Code Translation and Humanization Contract
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis, cross-domain reason code inventory
|
|
||||||
- **Problem**: TenantPilot has 6 distinct reason-code artifacts across 4 different structural patterns (final class with string constants, backed enum with `->message()`, backed enum without `->message()`, spec-level taxonomy) spread across provider, baseline, execution, operability, RBAC, and verification domains. These reason codes are the backend's primary mechanism for explaining why an operation was blocked, denied, degraded, or failed. But the product lacks a consistent translation/humanization contract for surfacing these codes to operators:
|
|
||||||
- `ProviderReasonCodes` (24 codes) and `BaselineReasonCodes` (7 codes) are raw string constants with **no human-readable translation** — they leak directly into run detail contexts, notifications, and banners as technical fragments like `RateLimited`, `ProviderAuthFailed`, `ConsentNotGranted`, `CredentialsInvalid`.
|
|
||||||
- `TenantOperabilityReasonCode` (10 cases) and `RbacReason` (10 cases) are backed enums with **no `->message()` method** — they can reach operator-facing surfaces as raw enum values without translation.
|
|
||||||
- `BaselineCompareReasonCode` (5 cases) and `ExecutionDenialReasonCode` (9 cases) have inline `->message()` methods with hardcoded English strings — better, but inconsistent with the rest.
|
|
||||||
- `RunFailureSanitizer` already performs ad-hoc normalization across reason taxonomies using heuristic string-matching and its own `REASON_*` constants, proving the need for a systematic approach.
|
|
||||||
- `ProviderNextStepsRegistry` maps some provider reason codes to link-only remediation steps — but this is limited to provider-domain codes and provides navigation links, not human-readable explanations.
|
|
||||||
- Notification payloads (`OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`) include sanitized but still technical reason strings — operators receive "Execution was blocked. Rate limited." without retry guidance or contextual explanation.
|
|
||||||
- Run summary counts expose raw internal keys (`errors_recorded`, `report_deduped`, `posture_score`) in operator-facing summary lines via `SummaryCountsNormalizer`.
|
|
||||||
- **Why it matters now**: Reason codes are the backend's richest source of "why did this happen?" truth. The backend frequently already knows the cause, the prerequisite, and the next step — but this knowledge reaches operators as raw technical fragments because there is no systematic translation layer. As TenantPilot adds more provider domains, more operation types, and more governance workflows, every new reason code that lacks a human-readable translation reproduces the same operator-trust degradation. Some parts of the product already translate codes well (`BaselineCompareReasonCode::message()`, `RunbookReason::options()`), proving the pattern is viable. The gap is not the pattern — it is its inconsistent adoption across all 6+ reason-code families.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- **Reason code humanization contract**: every reason-code artifact (whether `final class` constants or backed enums) must provide a `->label()` or equivalent method that returns a human-readable, operator-appropriate string. Raw string constants that cannot provide methods must be wrapped in or migrated to enums that can.
|
|
||||||
- **Translation target vocabulary**: human-readable labels must use the vocabulary defined by the Operator Outcome Taxonomy. Internal codes remain stable for machines/logs/tests; operator-facing surfaces exclusively use translated labels. The translation contract is the bridge between internal precision and external clarity.
|
|
||||||
- **Structured reason resolution**: reason code translation should return not just a label but a structured resolution envelope containing: (1) human-readable label, (2) optional short explanation, (3) optional next-action link/text, (4) severity classification (retryable transient vs. permanent configuration vs. prerequisite missing). This envelope replaces the current pattern of ad-hoc string formatting in `RunFailureSanitizer`, notification builders, and presenter classes.
|
|
||||||
- **Cross-domain registry or convention**: either a central `ReasonCodeTranslator` service that dispatches to domain-specific translators, or a mandatory `Translatable` interface/trait that all reason-code artifacts must implement. Prefer the latter (each domain owns its translations) over a central monolith, but enforce the contract architecturally.
|
|
||||||
- **Summary count humanization**: `SummaryCountsNormalizer` (or its successor) must map internal metric keys to operator-readable labels. `errors_recorded` → "Errors", `report_deduped` → "Reports deduplicated", etc. Raw internal keys must never reach operator-facing summary lines.
|
|
||||||
- **Next-steps enrichment**: expand `ProviderNextStepsRegistry` pattern to all reason-code families — not just provider codes. Every operator-visible reason code that implies a prerequisite or recoverable condition should include actionable next-step guidance (link, instruction, or "contact support" fallback).
|
|
||||||
- **Notification payload cleanup**: notification builders (`OperationUxPresenter`, terminal notifications) must consume translated labels, not raw reason strings. Failure messages must include cause + retryability + next action, not just a sanitized error string.
|
|
||||||
- **Key decisions to encode**:
|
|
||||||
- Internal codes remain stable and must not be renamed for cosmetic reasons — they are machine contracts used in logs, tests, and audit events
|
|
||||||
- Operator-facing surfaces exclusively use translated labels — raw codes move to diagnostic/secondary detail areas
|
|
||||||
- Every reason code must be classifiable as retryable-transient, permanent-configuration, or prerequisite-missing — this classification drives notification tone and next-action guidance
|
|
||||||
- `RunFailureSanitizer` should be superseded by the structured translation contract, not extended with more heuristic string-matching
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: reason code humanization contract, translation interface/trait, structured resolution envelope, migration plan for existing 6 artifacts, summary count humanization, notification payload cleanup, next-steps enrichment across all reason families
|
|
||||||
- **Out of scope**: creating new reason codes (domain specs own that), changing the semantic meaning of existing codes, badge infrastructure changes (badges consume translated labels — the rendering infrastructure is stable), operation naming vocabulary (tracked separately), individual domain-specific notification redesign beyond label substitution
|
|
||||||
- **Affected workflow families / surfaces**: Operations (run detail, summary, notifications), Provider (connection health, blocked notifications, next-steps banners), Baselines (compare skip reasons, capture precondition reasons), Restore (run result messages, check severity explanations), Verification (check status explanations), RBAC (health check reasons), Onboarding (lifecycle denial reasons), Findings (diff unavailable messages, governance validity labels), System Console (triage, failure details)
|
|
||||||
- **Why this should be one coherent candidate rather than fragmented per-domain fixes**: The 6 reason-code artifacts share the same structural gap (no consistent humanization contract), and per-domain fixes would each invent a different translation pattern. The contract must be defined once so that all domains implement it consistently. A per-domain approach would produce 6 different label formats, 6 different next-step patterns, and no shared resolution envelope — exactly the inconsistency this candidate eliminates. The implementation touches each domain's reason-code artifact, but the contract that governs all of them must be a single decision.
|
|
||||||
- **Dependencies**: Operator Outcome Taxonomy (provides the target vocabulary that reason code labels translate into). Soft dependency — translation work can begin with pragmatic labels before the full taxonomy is ratified, but labels should converge with the taxonomy once available.
|
|
||||||
- **Related specs / candidates**: Operator Outcome Taxonomy and Cross-Domain State Separation (provides vocabulary target), Operator Presentation & Lifecycle Action Hardening (provides rendering enforcement), Baseline Capture Truthful Outcomes (consumes baseline-specific reason code translations), Provider Connection Resolution Normalization (provides backend connection plumbing that gate results reference), Operations Naming Harmonization (complementary — operation type labels vs. reason code labels)
|
|
||||||
- **Strategic sequencing**: Recommended SECOND in the operator-truth initiative sequence, after the Outcome Taxonomy. Can begin in parallel if pragmatic interim labels are acceptable, but final label convergence depends on the taxonomy.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
@ -214,21 +138,160 @@ ### Provider-Backed Action Preflight and Dispatch Gate Unification
|
|||||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
||||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
||||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
||||||
- **Strategic sequencing**: Recommended THIRD in the operator-truth initiative sequence. Benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation. However, the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with the other two candidates.
|
- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Governance Operator Outcome Compression
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
|
||||||
|
- **Vehicle**: new standalone candidate
|
||||||
|
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
|
||||||
|
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
|
||||||
|
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
|
||||||
|
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
|
||||||
|
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
|
||||||
|
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
|
||||||
|
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
|
||||||
|
- **Primary adoption surfaces**:
|
||||||
|
- Baseline snapshot lists and detail pages
|
||||||
|
- Evidence snapshot lists and detail pages
|
||||||
|
- Evidence overview
|
||||||
|
- Tenant review lists and detail pages
|
||||||
|
- Review register
|
||||||
|
- Review pack lists and detail pages
|
||||||
|
- Shared governance detail templates and artifact-truth presenter surfaces
|
||||||
|
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
|
||||||
|
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
|
||||||
|
- **Core product principles to encode**:
|
||||||
|
- One primary operator statement per artifact on scan surfaces
|
||||||
|
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
|
||||||
|
- Diagnostics are second-layer, not the default operator language
|
||||||
|
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
|
||||||
|
- Lists are scan surfaces, not diagnosis surfaces
|
||||||
|
- **Candidate requirements**:
|
||||||
|
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
|
||||||
|
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
|
||||||
|
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
|
||||||
|
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
|
||||||
|
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
|
||||||
|
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
|
||||||
|
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
|
||||||
|
- **Acceptance points**:
|
||||||
|
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
|
||||||
|
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
|
||||||
|
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
|
||||||
|
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
|
||||||
|
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
|
||||||
|
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
|
||||||
|
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
|
||||||
|
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
|
||||||
|
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
|
||||||
|
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Humanized Diagnostic Summaries for Governance Operations
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
||||||
|
- **Vehicle**: new standalone candidate
|
||||||
|
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
|
||||||
|
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
|
||||||
|
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
|
||||||
|
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
|
||||||
|
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
|
||||||
|
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
|
||||||
|
- Keep raw JSON and low-level context fully available, but explicitly secondary
|
||||||
|
- **Primary adoption surfaces**:
|
||||||
|
- Canonical Monitoring run-detail pages for governance operation types
|
||||||
|
- Shared tenantless canonical run viewers and run-detail templates
|
||||||
|
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
|
||||||
|
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
|
||||||
|
- **Acceptance points**:
|
||||||
|
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
|
||||||
|
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
|
||||||
|
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
|
||||||
|
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
||||||
|
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
||||||
|
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
||||||
|
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### 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
|
- **Priority**: high
|
||||||
|
|
||||||
> **Operator Truth Initiative — Sequencing Note**
|
> **Operator Truth Initiative — Sequencing Note**
|
||||||
>
|
>
|
||||||
> The three candidates above (Operator Outcome Taxonomy, Reason Code Translation, Provider Dispatch Gate Unification) form a coherent cross-product initiative addressing the systemic gap between backend truth richness and operator-facing truth quality. They are sequenced as a dependency chain with parallelization opportunities:
|
> 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. **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 both upstream candidates but has significant backend scope (gate extension + scope-busy enforcement) that can proceed independently.
|
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||||
|
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
|
||||||
|
> 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 and gate unification are both P1-level (strongly confusing, should be fixed soon). The taxonomy is also the smallest and most decisive deliverable — it produces a reference that all other candidates consume. Shipping the taxonomy first prevents the other two candidates from making locally correct but globally inconsistent vocabulary choices. The gate unification has the largest implementation surface (~20 services) but much of its backend work (extending `ProviderOperationStartGate` scope, adding connection locking, dedup enforcement) can proceed in parallel once the taxonomy establishes the shared vocabulary for gate result presentation.
|
> **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 boundaries. The taxonomy is a cross-cutting decision document. Reason code translation touches 6+ reason-code artifacts and notification builders. Gate unification touches ~20 services, the gate class, Filament action handlers, and notification templates. Merging them would create an unshippable monolith. Keeping them as a sequenced initiative preserves independent delivery while ensuring vocabulary convergence.
|
> **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.
|
||||||
|
|
||||||
### Baseline Snapshot Fidelity Semantics
|
### Baseline Snapshot Fidelity Semantics
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
@ -269,18 +332,6 @@ ### Exception / Risk-Acceptance Workflow for Findings
|
|||||||
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Evidence Domain Foundation
|
|
||||||
- **Type**: feature
|
|
||||||
- **Source**: HANDOVER gap, R2 theme completion
|
|
||||||
- **Vehicle note**: Promoted into existing Spec 153. Do not create a second evidence-domain candidate for semantic cleanup; extend Spec 153 when evidence completeness / freshness semantics need to be corrected.
|
|
||||||
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
|
|
||||||
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
|
|
||||||
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
|
|
||||||
- **Explicit non-goals**: Not a presentation or reporting layer — this candidate owns data curation, completeness tracking, artifact storage, and immutable snapshots. Executive summaries, framework-oriented readiness views, management-ready outputs, and stakeholder-facing packaging belong to the Compliance Readiness & Executive Review Packs candidate, which consumes this foundation. Not a replacement for Spec 109's export packaging. Not a generic BI or data warehouse initiative.
|
|
||||||
- **Boundary with Compliance Readiness**: Evidence Domain Foundation = lower-level data assembly (what artifacts exist, are they complete, are they immutable). Compliance Readiness = upper-level presentation (how to arrange evidence into framework-oriented, stakeholder-facing deliverables). This candidate is a prerequisite; Compliance Readiness is a downstream consumer.
|
|
||||||
- **Dependencies**: Review pack export (109), permission posture (104/105)
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Compliance Readiness & Executive Review Packs
|
### Compliance Readiness & Executive Review Packs
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
||||||
|
|||||||
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';
|
||||||
|
|||||||
@ -77,9 +77,9 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
|
|||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
{{ $fact['label'] ?? 'Fact' }}
|
{{ $fact['label'] ?? 'Fact' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
@if ($displayValue !== null)
|
@if ($displayValue !== null)
|
||||||
<span>{{ $displayValue }}</span>
|
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($badge !== null)
|
@if ($badge !== null)
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
||||||
>
|
>
|
||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||||
@elseif ($items !== [])
|
@elseif ($items !== [])
|
||||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
|
|||||||
@ -19,9 +19,9 @@
|
|||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
{{ $item['label'] ?? 'Detail' }}
|
{{ $item['label'] ?? 'Detail' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
@if ($displayValue !== null)
|
@if ($displayValue !== null)
|
||||||
<span>{{ $displayValue }}</span>
|
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($badge !== null)
|
@if ($badge !== null)
|
||||||
|
|||||||
@ -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' }}
|
@if ($action !== null && filled($action['url'] ?? null))
|
||||||
</div>
|
<x-slot name="headerEnd">
|
||||||
|
|
||||||
@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))
|
|
||||||
<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,16 +21,16 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
|||||||
>
|
>
|
||||||
{{ $action['label'] }}
|
{{ $action['label'] }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
</x-slot>
|
||||||
</div>
|
@endif
|
||||||
|
|
||||||
<div class="mt-4">
|
<div>
|
||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
@include($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])
|
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
||||||
@elseif ($items !== [])
|
@elseif ($items !== [])
|
||||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
||||||
@elseif ($emptyState !== null)
|
@elseif ($emptyState !== null)
|
||||||
@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>
|
||||||
|
|||||||
@ -20,10 +20,10 @@
|
|||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
@if ($entries !== [])
|
@if ($entries !== [])
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||||
@endif
|
@endif
|
||||||
@elseif ($emptyState !== null)
|
@elseif ($emptyState !== null)
|
||||||
<div @class(['mt-4' => $entries !== []])>
|
<div @class(['mt-4' => $entries !== []])>
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
@php
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
|
||||||
|
$resolvedState = isset($getState) ? $getState() : ($artifactTruthState ?? ($state ?? null));
|
||||||
|
$state = is_array($resolvedState) ? $resolvedState : [];
|
||||||
|
$dimensions = collect(is_array($state['dimensions'] ?? null) ? $state['dimensions'] : []);
|
||||||
|
|
||||||
|
$primary = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['classification'] ?? null) === 'primary');
|
||||||
|
$existence = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'artifact_existence');
|
||||||
|
$freshness = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'data_freshness');
|
||||||
|
$publication = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'publication_readiness');
|
||||||
|
$actionability = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'operator_actionability');
|
||||||
|
|
||||||
|
$specFor = static function (mixed $dimension): ?\App\Support\Badges\BadgeSpec {
|
||||||
|
if (! is_array($dimension)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($dimension['badgeDomain'] ?? null) || ! is_string($dimension['badgeState'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::from($dimension['badgeDomain']), $dimension['badgeState']);
|
||||||
|
};
|
||||||
|
|
||||||
|
$primarySpec = $specFor($primary);
|
||||||
|
$existenceSpec = $specFor($existence);
|
||||||
|
$freshnessSpec = $specFor($freshness);
|
||||||
|
$publicationSpec = $specFor($publication);
|
||||||
|
$actionabilitySpec = $specFor($actionability);
|
||||||
|
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
||||||
|
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<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="flex flex-wrap items-start gap-2">
|
||||||
|
@if ($primarySpec)
|
||||||
|
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
||||||
|
{{ $primarySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($actionabilitySpec)
|
||||||
|
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
|
||||||
|
{{ $actionabilitySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
||||||
|
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $state['primaryExplanation'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Diagnostic: {{ $state['diagnosticLabel'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
@if ($existenceSpec)
|
||||||
|
<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">Artifact exists</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
|
||||||
|
{{ $existenceSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($freshnessSpec)
|
||||||
|
<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">Freshness</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||||
|
{{ $freshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($publicationSpec)
|
||||||
|
<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">Publication</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
|
||||||
|
{{ $publicationSpec->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">
|
||||||
|
<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">
|
||||||
|
{{ $state['nextActionLabel'] ?? 'No action needed' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if ($nextSteps !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||||
|
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
@foreach ($nextSteps as $step)
|
||||||
|
@continue(! is_string($step) || trim($step) === '')
|
||||||
|
|
||||||
|
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
|
||||||
|
{{ $step }}
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@ -16,133 +16,4 @@
|
|||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
|
|
||||||
@php
|
|
||||||
$selectedAudit = $this->selectedAuditLog();
|
|
||||||
$selectedAuditLink = $this->selectedAuditLink();
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($selectedAudit)
|
|
||||||
<x-filament::section
|
|
||||||
:heading="$selectedAudit->summaryText()"
|
|
||||||
:description="$selectedAudit->recorded_at?->toDayDateTimeString()"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
@endif
|
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@ -1,8 +1,3 @@
|
|||||||
@php
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@if ($rows === [])
|
@if ($rows === [])
|
||||||
@ -21,28 +16,36 @@
|
|||||||
<thead class="bg-gray-50 text-left text-gray-600">
|
<thead class="bg-gray-50 text-left text-gray-600">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">Tenant</th>
|
<th class="px-4 py-3 font-medium">Tenant</th>
|
||||||
<th class="px-4 py-3 font-medium">Completeness</th>
|
<th class="px-4 py-3 font-medium">Artifact truth</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Freshness</th>
|
||||||
<th class="px-4 py-3 font-medium">Generated</th>
|
<th class="px-4 py-3 font-medium">Generated</th>
|
||||||
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
||||||
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Next step</th>
|
||||||
<th class="px-4 py-3 font-medium">Action</th>
|
<th class="px-4 py-3 font-medium">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
||||||
@foreach ($rows as $row)
|
@foreach ($rows as $row)
|
||||||
@php
|
|
||||||
$completenessSpec = BadgeRenderer::spec(BadgeDomain::EvidenceCompleteness, $row['completeness_state'] ?? null);
|
|
||||||
@endphp
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<x-filament::badge :color="$completenessSpec->color" :icon="$completenessSpec->icon" size="sm">
|
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
|
||||||
{{ $completenessSpec->label }}
|
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
|
||||||
|
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
|
||||||
|
{{ data_get($row, 'freshness.label', 'Unknown') }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
||||||
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
||||||
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
||||||
|
<td class="px-4 py-3">{{ $row['next_step'] ?? 'No action needed' }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -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'"
|
||||||
@ -38,5 +40,12 @@
|
|||||||
</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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user