Compare commits
1 Commits
dev
...
156-operat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11b28e04 |
11
.github/agents/copilot-instructions.md
vendored
11
.github/agents/copilot-instructions.md
vendored
@ -97,11 +97,6 @@ ## Active Technologies
|
|||||||
- 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` (155-tenant-review-layer)
|
- 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` (155-tenant-review-layer)
|
||||||
- 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)
|
|
||||||
- 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)
|
||||||
|
|
||||||
@ -121,8 +116,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -19,10 +18,8 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||||
|
|
||||||
public function handle(
|
public function handle(OperationRunService $operationRunService): int
|
||||||
OperationRunService $operationRunService,
|
{
|
||||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
|
||||||
): int {
|
|
||||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
@ -99,9 +96,31 @@ public function handle(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operationRun->status === 'running') {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'backup_schedule.stalled',
|
||||||
|
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($change !== null) {
|
|
||||||
$reconciled++;
|
$reconciled++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class TenantpilotReconcileOperationRuns extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'tenantpilot:operation-runs:reconcile
|
|
||||||
{--type=* : Limit reconciliation to one or more covered operation types}
|
|
||||||
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
|
||||||
{--workspace=* : Limit reconciliation to workspace ids}
|
|
||||||
{--limit=100 : Maximum number of active runs to inspect}
|
|
||||||
{--dry-run : Report the changes without writing them}';
|
|
||||||
|
|
||||||
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
|
||||||
|
|
||||||
public function handle(
|
|
||||||
OperationLifecycleReconciler $reconciler,
|
|
||||||
OperationLifecyclePolicy $policy,
|
|
||||||
): int {
|
|
||||||
$types = array_values(array_filter(
|
|
||||||
(array) $this->option('type'),
|
|
||||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
|
||||||
));
|
|
||||||
$workspaceIds = array_values(array_filter(
|
|
||||||
array_map(
|
|
||||||
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
|
||||||
(array) $this->option('workspace'),
|
|
||||||
),
|
|
||||||
static fn (int $workspaceId): bool => $workspaceId > 0,
|
|
||||||
));
|
|
||||||
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
|
||||||
|
|
||||||
if ($types === []) {
|
|
||||||
$types = $policy->coveredTypeNames();
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $reconciler->reconcile([
|
|
||||||
'types' => $types,
|
|
||||||
'tenant_ids' => $tenantIds,
|
|
||||||
'workspace_ids' => $workspaceIds,
|
|
||||||
'limit' => max(1, (int) $this->option('limit')),
|
|
||||||
'dry_run' => $dryRun,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rows = collect($result['changes'] ?? [])
|
|
||||||
->map(static function (array $change): array {
|
|
||||||
return [
|
|
||||||
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
|
||||||
'Type' => (string) ($change['type'] ?? '—'),
|
|
||||||
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
|
||||||
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($rows !== []) {
|
|
||||||
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Inspected %d run(s); reconciled %d; skipped %d.',
|
|
||||||
(int) ($result['candidates'] ?? 0),
|
|
||||||
(int) ($result['reconciled'] ?? 0),
|
|
||||||
(int) ($result['skipped'] ?? 0),
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->comment('Dry-run: no changes written.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $tenantIdentifiers
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
|
||||||
{
|
|
||||||
if ($tenantIdentifiers === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = [];
|
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
|
||||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$tenantIds[] = (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($tenantIds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -307,22 +307,9 @@ private function compareNowAction(): Action
|
|||||||
$result = $service->startCompare($tenant, $user);
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
||||||
|
|
||||||
$message = match ($reasonCode) {
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
|
||||||
default => 'Reason: '.$reasonCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
->body($message)
|
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,6 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -43,6 +42,8 @@ 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;
|
||||||
@ -81,15 +82,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
if ($requestedEventId !== null) {
|
if ($this->selectedAuditLogId !== null) {
|
||||||
$this->resolveAuditLog($requestedEventId);
|
$this->selectedAuditLog();
|
||||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,10 +98,31 @@ public function mount(): void
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
scopeActionName: 'operate_hub_scope_audit_log',
|
||||||
returnActionName: 'operate_hub_return_audit_log',
|
returnActionName: 'operate_hub_return_audit_log',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
||||||
|
$actions[] = Action::make('clear_selected_audit_event')
|
||||||
|
->label('Close details')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->clearSelectedAuditLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
$relatedLink = $this->selectedAuditLink();
|
||||||
|
|
||||||
|
if (is_array($relatedLink)) {
|
||||||
|
$actions[] = Action::make('open_selected_audit_target')
|
||||||
|
->label($relatedLink['label'])
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url($relatedLink['url']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -174,16 +195,9 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->slideOver()
|
->action(function (AuditLogModel $record): void {
|
||||||
->stickyModalHeader()
|
$this->selectedAuditLogId = (int) $record->getKey();
|
||||||
->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')
|
||||||
@ -195,11 +209,48 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
|
$this->selectedAuditLogId = null;
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function clearSelectedAuditLog(): void
|
||||||
|
{
|
||||||
|
$this->selectedAuditLogId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedAuditLog(): ?AuditLogModel
|
||||||
|
{
|
||||||
|
if (! is_numeric($this->selectedAuditLogId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->auditBaseQuery()
|
||||||
|
->whereKey((int) $this->selectedAuditLogId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
public function selectedAuditLink(): ?array
|
||||||
|
{
|
||||||
|
$record = $this->selectedAuditLog();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, Tenant>
|
||||||
*/
|
*/
|
||||||
@ -272,27 +323,6 @@ private function auditBaseQuery(): Builder
|
|||||||
->latestFirst();
|
->latestFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveAuditLog(int $auditLogId): AuditLogModel
|
|
||||||
{
|
|
||||||
$record = $this->auditBaseQuery()
|
|
||||||
->whereKey($auditLogId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof AuditLogModel) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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,13 +8,10 @@
|
|||||||
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;
|
||||||
@ -90,9 +87,6 @@ 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,
|
||||||
@ -101,21 +95,7 @@ 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)),
|
||||||
'artifact_truth' => [
|
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||||
'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,7 +13,6 @@
|
|||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -166,68 +165,6 @@ public function table(Table $table): Table
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{likely_stale:int,reconciled:int}
|
|
||||||
*/
|
|
||||||
public function lifecycleVisibilitySummary(): array
|
|
||||||
{
|
|
||||||
$baseQuery = $this->scopedSummaryQuery();
|
|
||||||
|
|
||||||
if (! $baseQuery instanceof Builder) {
|
|
||||||
return [
|
|
||||||
'likely_stale' => 0,
|
|
||||||
'reconciled' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled = (clone $baseQuery)
|
|
||||||
->whereNotNull('context->reconciliation->reconciled_at')
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$policy = app(OperationLifecyclePolicy::class);
|
|
||||||
$likelyStale = (clone $baseQuery)
|
|
||||||
->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])
|
|
||||||
->where(function (Builder $query) use ($policy): void {
|
|
||||||
foreach ($policy->coveredTypeNames() as $type) {
|
|
||||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
||||||
$typeQuery
|
|
||||||
->where('type', $type)
|
|
||||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
||||||
$stateQuery
|
|
||||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
||||||
$queuedQuery
|
|
||||||
->where('status', OperationRunStatus::Queued->value)
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
||||||
})
|
|
||||||
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
||||||
$runningQuery
|
|
||||||
->where('status', OperationRunStatus::Running->value)
|
|
||||||
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
|
||||||
$startedAtQuery
|
|
||||||
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
||||||
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
||||||
$fallbackQuery
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'likely_stale' => $likelyStale,
|
|
||||||
'reconciled' => $reconciled,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@ -250,26 +187,4 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
default => $query,
|
default => $query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function scopedSummaryQuery(): ?Builder
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! $workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->when(
|
|
||||||
is_numeric($tenantFilter),
|
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
@ -170,53 +169,24 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
$reasonCode = data_get($context, 'reason_code');
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
|
||||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
];
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||||
|
$message = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.';
|
||||||
|
$guidance = OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => implode(' ', $lines),
|
'body' => sprintf('%s Reason code: %s. %s', $message, $reasonCode, $guidance),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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,7 +22,6 @@
|
|||||||
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;
|
||||||
@ -115,15 +114,6 @@ 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()
|
||||||
@ -133,29 +123,15 @@ 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('publication_truth')
|
TextColumn::make('summary.publish_blockers')
|
||||||
->label('Publication')
|
->label('Publish blockers')
|
||||||
->badge()
|
->formatStateUsing(static function (mixed $state): string {
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
if (! is_array($state) || $state === []) {
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
return '0';
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
}
|
||||||
)->label)
|
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
return (string) count($state);
|
||||||
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')
|
||||||
|
|||||||
@ -2882,12 +2882,9 @@ public function startVerification(): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -6,28 +6,19 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -297,32 +288,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->placeholder('None'),
|
->placeholder('None'),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make('Baseline truth')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('current_snapshot_truth')
|
|
||||||
->label('Current snapshot')
|
|
||||||
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
|
||||||
TextEntry::make('latest_attempted_snapshot_truth')
|
|
||||||
->label('Latest attempt')
|
|
||||||
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
|
||||||
TextEntry::make('compare_readiness')
|
|
||||||
->label('Compare readiness')
|
|
||||||
->badge()
|
|
||||||
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
|
||||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
|
||||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
|
||||||
TextEntry::make('baseline_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
->label('Created by')
|
->label('Created by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
|
TextEntry::make('activeSnapshot.captured_at')
|
||||||
|
->label('Last snapshot')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('No snapshot yet'),
|
||||||
TextEntry::make('created_at')
|
TextEntry::make('created_at')
|
||||||
->dateTime(),
|
->dateTime(),
|
||||||
TextEntry::make('updated_at')
|
TextEntry::make('updated_at')
|
||||||
@ -381,27 +355,10 @@ public static function table(Table $table): Table
|
|||||||
TextColumn::make('tenant_assignments_count')
|
TextColumn::make('tenant_assignments_count')
|
||||||
->label('Assigned tenants')
|
->label('Assigned tenants')
|
||||||
->counts('tenantAssignments'),
|
->counts('tenantAssignments'),
|
||||||
TextColumn::make('current_snapshot_truth')
|
TextColumn::make('activeSnapshot.captured_at')
|
||||||
->label('Current snapshot')
|
->label('Last snapshot')
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
->dateTime()
|
||||||
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
->placeholder('No snapshot'),
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('latest_attempted_snapshot_truth')
|
|
||||||
->label('Latest attempt')
|
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
|
||||||
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('compare_readiness')
|
|
||||||
->label('Compare readiness')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
|
||||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
|
||||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('baseline_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -588,167 +545,4 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
$snapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
|
||||||
return 'No complete snapshot';
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::snapshotReference($snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
$snapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
|
||||||
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $snapshot->captured_at?->toDayDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
|
||||||
return 'No capture attempts yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
|
||||||
return 'Matches current snapshot';
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::snapshotReference($latestAttempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
|
||||||
return 'No newer attempt is pending.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $latestAttempt->captured_at?->toDayDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareReadinessLabel(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareReadinessColor(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
|
||||||
null => 'success',
|
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
|
||||||
default => 'warning',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
|
||||||
null => 'heroicon-m-check-badge',
|
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
|
||||||
default => 'heroicon-m-exclamation-triangle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function profileNextStep(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
$status = $profile->status instanceof BaselineProfileStatus
|
|
||||||
? $profile->status
|
|
||||||
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
|
||||||
|
|
||||||
if ($status !== BaselineProfileStatus::Active) {
|
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
|
||||||
$reasonCode = $resolution['reason_code'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
|
||||||
return trim($reasonCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! self::hasEligibleCompareTarget($profile)) {
|
|
||||||
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$reasonCode = self::compareAvailabilityReason($profile);
|
|
||||||
|
|
||||||
if (! is_string($reasonCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
|
||||||
{
|
|
||||||
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
|
||||||
|
|
||||||
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = BaselineTenantAssignment::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->pluck('tenant_id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return Tenant::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->whereIn('id', $tenantIds)
|
|
||||||
->get(['id'])
|
|
||||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,7 +183,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||||
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
||||||
|
|
||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
|||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -256,11 +256,7 @@ private function compareNowAction(): Action
|
|||||||
$message = match ($reasonCode) {
|
$message = match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
||||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -399,12 +395,4 @@ private function hasManageCapability(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function profileHasConsumableSnapshot(): bool
|
|
||||||
{
|
|
||||||
/** @var BaselineProfile $profile */
|
|
||||||
$profile = $this->getRecord();
|
|
||||||
|
|
||||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,10 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
@ -23,8 +21,6 @@
|
|||||||
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;
|
||||||
@ -172,39 +168,17 @@ 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('artifact_next_step')
|
TextColumn::make('snapshot_state')
|
||||||
->label('Next step')
|
->label('State')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
->badge()
|
||||||
->wrap(),
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
@ -214,10 +188,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Baseline')
|
->label('Baseline')
|
||||||
->options(static::baselineProfileOptions())
|
->options(static::baselineProfileOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('lifecycle_state')
|
SelectFilter::make('snapshot_state')
|
||||||
->label('Lifecycle')
|
->label('State')
|
||||||
->options(static::lifecycleOptions())
|
->options(static::snapshotStateOptions())
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@ -278,9 +252,9 @@ private static function baselineProfileOptions(): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function lifecycleOptions(): array
|
private static function snapshotStateOptions(): array
|
||||||
{
|
{
|
||||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveWorkspace(): ?Workspace
|
public static function resolveWorkspace(): ?Workspace
|
||||||
@ -354,18 +328,24 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
|||||||
return self::gapsCount($snapshot) > 0;
|
return self::gapsCount($snapshot) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||||
{
|
{
|
||||||
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
return self::gapSpec($snapshot)->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||||
{
|
{
|
||||||
if (! is_string($value) || trim($value) === '') {
|
if (! is_string($value) || trim($value) === '') {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->where('lifecycle_state', trim($value));
|
$gapCountExpression = self::gapCountExpression($query);
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||||
|
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapCountExpression(Builder $query): string
|
private static function gapCountExpression(Builder $query): string
|
||||||
@ -384,56 +364,4 @@ 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,8 +35,6 @@ public function mount(int|string $record): void
|
|||||||
$snapshot = $this->getRecord();
|
$snapshot = $this->getRecord();
|
||||||
|
|
||||||
if ($snapshot instanceof BaselineSnapshot) {
|
if ($snapshot instanceof BaselineSnapshot) {
|
||||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
|
||||||
|
|
||||||
$relatedContext = app(RelatedNavigationResolver::class)
|
$relatedContext = app(RelatedNavigationResolver::class)
|
||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,6 @@
|
|||||||
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;
|
||||||
@ -135,15 +133,6 @@ 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')
|
||||||
@ -225,15 +214,6 @@ 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()
|
||||||
@ -245,10 +225,6 @@ 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')
|
||||||
@ -612,11 +588,6 @@ 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) {
|
||||||
|
|||||||
@ -25,14 +25,12 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
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;
|
||||||
@ -128,11 +126,10 @@ public static function table(Table $table): Table
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
@ -155,10 +152,10 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -254,24 +251,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
{
|
{
|
||||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
|
|
||||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||||
$targetScope = static::targetScopeDisplay($record);
|
$targetScope = static::targetScopeDisplay($record);
|
||||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: null;
|
||||||
$artifactTruth = $record->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(
|
||||||
@ -301,15 +287,6 @@ 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',
|
||||||
@ -327,9 +304,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
items: array_values(array_filter([
|
items: array_values(array_filter([
|
||||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||||
$artifactTruth !== null
|
|
||||||
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
|
|
||||||
: null,
|
|
||||||
$referencedTenantLifecycle !== null
|
$referencedTenantLifecycle !== null
|
||||||
? $factory->keyFact(
|
? $factory->keyFact(
|
||||||
'Tenant lifecycle',
|
'Tenant lifecycle',
|
||||||
@ -348,21 +322,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$referencedTenantLifecycle?->contextNote !== null
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
: null,
|
: null,
|
||||||
static::freshnessLabel($record) !== null
|
|
||||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
|
||||||
: null,
|
|
||||||
static::reconciliationHeadline($record) !== null
|
|
||||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
|
||||||
: null,
|
|
||||||
static::reconciledAtLabel($record) !== null
|
|
||||||
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
|
||||||
: null,
|
|
||||||
static::reconciliationSourceLabel($record) !== null
|
|
||||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
|
||||||
: null,
|
|
||||||
$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,
|
||||||
@ -429,19 +388,6 @@ 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);
|
||||||
@ -523,13 +469,8 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->operatorLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||||
?? data_get($context, 'reason_code')
|
?? data_get($context, 'reason_code')
|
||||||
?? data_get($record->failure_summary, '0.reason_code');
|
?? data_get($record->failure_summary, '0.reason_code');
|
||||||
@ -543,12 +484,6 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = data_get($record->failure_summary, '0.message');
|
$message = data_get($record->failure_summary, '0.message');
|
||||||
|
|
||||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||||
@ -752,82 +687,6 @@ private static function contextPayload(OperationRun $record): array
|
|||||||
return $context;
|
return $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{status:string,freshness_state:string}
|
|
||||||
*/
|
|
||||||
private static function statusBadgeState(OperationRun $record): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'status' => (string) $record->status,
|
|
||||||
'freshness_state' => $record->freshnessState()->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{outcome:string,status:string,freshness_state:string}
|
|
||||||
*/
|
|
||||||
private static function outcomeBadgeState(OperationRun $record): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'outcome' => (string) $record->outcome,
|
|
||||||
'status' => (string) $record->status,
|
|
||||||
'freshness_state' => $record->freshnessState()->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function freshnessLabel(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
return match ($record->freshnessState()->value) {
|
|
||||||
'fresh_active' => 'Fresh activity',
|
|
||||||
'likely_stale' => 'Likely stale',
|
|
||||||
'reconciled_failed' => 'Automatically reconciled',
|
|
||||||
'terminal_normal' => 'Terminal truth confirmed',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reconciliationHeadline(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
if (! $record->isLifecycleReconciled()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reconciledAtLabel(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
|
|
||||||
|
|
||||||
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reconciliationSourceLabel(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$source = data_get($record->reconciliation(), 'source');
|
|
||||||
|
|
||||||
if (! is_string($source) || trim($source) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (trim($source)) {
|
|
||||||
'failed_callback' => 'Direct failed() bridge',
|
|
||||||
'scheduled_reconciler' => 'Scheduled reconciler',
|
|
||||||
'adapter_reconciler' => 'Adapter reconciler',
|
|
||||||
default => ucfirst(str_replace('_', ' ', trim($source))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function reconciliationPayload(OperationRun $record): array
|
|
||||||
{
|
|
||||||
$reconciliation = $record->reconciliation();
|
|
||||||
|
|
||||||
return $reconciliation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function formatDetailTimestamp(mixed $value): string
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
{
|
{
|
||||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||||
|
|||||||
@ -824,12 +824,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -924,12 +921,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -1021,12 +1015,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
|
|||||||
@ -278,12 +278,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -650,12 +647,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -764,12 +758,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
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;
|
||||||
@ -20,14 +19,11 @@
|
|||||||
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;
|
||||||
@ -115,15 +111,6 @@ 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')
|
||||||
@ -251,15 +238,6 @@ 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(),
|
||||||
@ -279,29 +257,6 @@ 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()
|
||||||
@ -397,11 +352,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -608,12 +608,9 @@ public static function table(Table $table): Table
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -911,20 +908,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||||
Section::make('RBAC Details')
|
Section::make('RBAC Details')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason_label')
|
|
||||||
->label('Reason')
|
|
||||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
|
||||||
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
|
||||||
->visible(fn (?string $state): bool => filled($state)),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
|
|
||||||
->label('Explanation')
|
|
||||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
|
||||||
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
|
||||||
->visible(fn (?string $state): bool => filled($state))
|
|
||||||
->columnSpanFull(),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason')
|
Infolists\Components\TextEntry::make('rbac_status_reason')
|
||||||
->label('Diagnostic code')
|
->label('Reason'),
|
||||||
->copyable(),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||||
->label('Role definition ID')
|
->label('Role definition ID')
|
||||||
->copyable(),
|
->copyable(),
|
||||||
|
|||||||
@ -178,12 +178,9 @@ protected function getHeaderActions(): array
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -28,8 +28,6 @@
|
|||||||
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;
|
||||||
@ -145,15 +143,6 @@ 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')
|
||||||
@ -250,15 +239,6 @@ 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()
|
||||||
@ -271,32 +251,9 @@ 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(),
|
||||||
@ -604,9 +561,4 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
'links' => [],
|
'links' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,8 +22,6 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'state' => 'no_assignment',
|
|
||||||
'message' => null,
|
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
'findingsCount' => 0,
|
'findingsCount' => 0,
|
||||||
'highCount' => 0,
|
'highCount' => 0,
|
||||||
@ -45,8 +43,6 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'state' => $stats->state,
|
|
||||||
'message' => $stats->message,
|
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
'findingsCount' => $stats->findingsCount ?? 0,
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||||
|
|||||||
@ -44,10 +44,8 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
'shouldShow' => $hasWarnings && $runUrl !== null,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'state' => $stats->state,
|
|
||||||
'message' => $stats->message,
|
|
||||||
'coverageStatus' => $coverageStatus,
|
'coverageStatus' => $coverageStatus,
|
||||||
'fidelity' => $stats->fidelity,
|
'fidelity' => $stats->fidelity,
|
||||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||||
|
|||||||
@ -134,12 +134,9 @@ public function startVerification(StartVerification $verification): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -12,18 +11,11 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public int $bulkRunId = 0;
|
public int $bulkRunId = 0;
|
||||||
|
|
||||||
@ -76,6 +68,32 @@ public function handle(OperationRunService $runs): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function failed(Throwable $e): void
|
||||||
|
{
|
||||||
|
$run = $this->operationRun;
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
||||||
|
$run = OperationRun::query()->find($this->bulkRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$run,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
failures: [[
|
||||||
|
'code' => 'bulk_job.failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveOperationRun(): OperationRun
|
private function resolveOperationRun(): OperationRun
|
||||||
{
|
{
|
||||||
if ($this->operationRun instanceof OperationRun) {
|
if ($this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -16,15 +15,7 @@
|
|||||||
|
|
||||||
class BulkTenantSyncJob implements ShouldQueue
|
class BulkTenantSyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 180;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -13,7 +12,6 @@
|
|||||||
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;
|
||||||
@ -22,9 +20,7 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -36,19 +32,10 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
@ -73,12 +60,10 @@ 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) {
|
||||||
@ -198,12 +183,7 @@ public function handle(
|
|||||||
gaps: $captureGaps,
|
gaps: $captureGaps,
|
||||||
);
|
);
|
||||||
|
|
||||||
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
$items = $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);
|
||||||
|
|
||||||
@ -220,17 +200,16 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$snapshotResult = $this->captureSnapshotArtifact(
|
$snapshot = $this->findOrCreateSnapshot(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
$items,
|
$items,
|
||||||
$snapshotSummary,
|
$snapshotSummary,
|
||||||
);
|
);
|
||||||
|
|
||||||
$snapshot = $snapshotResult['snapshot'];
|
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
|
||||||
|
|
||||||
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
if ($profile->status === BaselineProfileStatus::Active) {
|
||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +250,6 @@ public function handle(
|
|||||||
'snapshot_identity_hash' => $identityHash,
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'items_captured' => $snapshotItems['items_count'],
|
'items_captured' => $snapshotItems['items_count'],
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -522,151 +500,29 @@ private function buildSnapshotItems(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function findOrCreateSnapshot(
|
||||||
* @param array<int, array<string, mixed>> $snapshotItems
|
|
||||||
* @param array<string, mixed> $summaryJsonb
|
|
||||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
|
||||||
*/
|
|
||||||
private function captureSnapshotArtifact(
|
|
||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
string $identityHash,
|
string $identityHash,
|
||||||
array $snapshotItems,
|
array $snapshotItems,
|
||||||
array $summaryJsonb,
|
array $summaryJsonb,
|
||||||
): array {
|
): BaselineSnapshot {
|
||||||
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
|
||||||
|
|
||||||
if ($existing instanceof BaselineSnapshot) {
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $existing,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: false,
|
|
||||||
expectedItems: count($snapshotItems),
|
|
||||||
persistedItems: count($snapshotItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'snapshot' => $existing,
|
|
||||||
'was_new_snapshot' => false,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$expectedItems = count($snapshotItems);
|
|
||||||
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: $expectedItems,
|
|
||||||
persistedItems: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
|
||||||
|
|
||||||
if ($persistedItems !== $expectedItems) {
|
|
||||||
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot->markComplete($identityHash, [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
'persisted_items' => $persistedItems,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => $expectedItems === 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot->refresh();
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: $expectedItems,
|
|
||||||
persistedItems: $persistedItems,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'snapshot' => $snapshot,
|
|
||||||
'was_new_snapshot' => true,
|
|
||||||
];
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
$persistedItems = (int) BaselineSnapshotItem::query()
|
|
||||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$reasonCode = $exception instanceof RuntimeException
|
|
||||||
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
|
||||||
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
|
||||||
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
|
||||||
|
|
||||||
$snapshot->markIncomplete($reasonCode, [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
'persisted_items' => $persistedItems,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => $expectedItems === 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot->refresh();
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: $expectedItems,
|
|
||||||
persistedItems: $persistedItems,
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
$existing = BaselineSnapshot::query()
|
$existing = BaselineSnapshot::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->where('baseline_profile_id', $profile->getKey())
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
->where('snapshot_identity_hash', $identityHash)
|
->where('snapshot_identity_hash', $identityHash)
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $existing instanceof BaselineSnapshot ? $existing : null;
|
if ($existing instanceof BaselineSnapshot) {
|
||||||
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$snapshot = BaselineSnapshot::create([
|
||||||
* @param array<string, mixed> $summaryJsonb
|
|
||||||
*/
|
|
||||||
private function createBuildingSnapshot(
|
|
||||||
BaselineProfile $profile,
|
|
||||||
string $identityHash,
|
|
||||||
array $summaryJsonb,
|
|
||||||
int $expectedItems,
|
|
||||||
): BaselineSnapshot {
|
|
||||||
return BaselineSnapshot::create([
|
|
||||||
'workspace_id' => (int) $profile->workspace_id,
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
|
||||||
'summary_jsonb' => $summaryJsonb,
|
'summary_jsonb' => $summaryJsonb,
|
||||||
'completion_meta_jsonb' => [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
'persisted_items' => 0,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => $expectedItems === 0,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array<string, mixed>> $snapshotItems
|
|
||||||
*/
|
|
||||||
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
|
|
||||||
{
|
|
||||||
$persistedItems = 0;
|
|
||||||
|
|
||||||
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||||
$rows = array_map(
|
$rows = array_map(
|
||||||
@ -685,56 +541,9 @@ private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapsho
|
|||||||
);
|
);
|
||||||
|
|
||||||
BaselineSnapshotItem::insert($rows);
|
BaselineSnapshotItem::insert($rows);
|
||||||
$persistedItems += count($rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $persistedItems;
|
return $snapshot;
|
||||||
}
|
|
||||||
|
|
||||||
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return hash(
|
|
||||||
'sha256',
|
|
||||||
implode('|', [
|
|
||||||
'building',
|
|
||||||
(string) $profile->getKey(),
|
|
||||||
(string) $this->operationRun->getKey(),
|
|
||||||
(string) microtime(true),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function rememberSnapshotOnRun(
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
string $identityHash,
|
|
||||||
bool $wasNewSnapshot,
|
|
||||||
int $expectedItems,
|
|
||||||
int $persistedItems,
|
|
||||||
?string $reasonCode = null,
|
|
||||||
): void {
|
|
||||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
|
||||||
$context['result'] = array_merge(
|
|
||||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
|
||||||
[
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_identity_hash' => $identityHash,
|
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
|
||||||
'items_captured' => $persistedItems,
|
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
|
||||||
$context['reason_code'] = $reasonCode;
|
|
||||||
$context['result']['snapshot_reason_code'] = $reasonCode;
|
|
||||||
} else {
|
|
||||||
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $context]);
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -19,7 +18,6 @@
|
|||||||
use App\Services\Baselines\BaselineAutoCloseService;
|
use App\Services\Baselines\BaselineAutoCloseService;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||||
@ -39,7 +37,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
@ -57,15 +54,7 @@
|
|||||||
|
|
||||||
class CompareBaselineToTenantJob implements ShouldQueue
|
class CompareBaselineToTenantJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
@ -95,7 +84,6 @@ public function handle(
|
|||||||
?SettingsResolver $settingsResolver = null,
|
?SettingsResolver $settingsResolver = null,
|
||||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
|
||||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
@ -104,7 +92,6 @@ public function handle(
|
|||||||
$settingsResolver ??= app(SettingsResolver::class);
|
$settingsResolver ??= app(SettingsResolver::class);
|
||||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
|
||||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
@ -291,51 +278,12 @@ public function handle(
|
|||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey($snapshotId)
|
->whereKey($snapshotId)
|
||||||
->first();
|
->first(['id', 'captured_at']);
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
|
|
||||||
|
|
||||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
|
||||||
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
|
|
||||||
? (string) $snapshotResolution['reason_code']
|
|
||||||
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
|
||||||
|
|
||||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$context['baseline_compare'] = array_merge(
|
|
||||||
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
|
||||||
[
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
|
|
||||||
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$context['result'] = array_merge(
|
|
||||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
|
||||||
[
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$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;
|
||||||
@ -1056,17 +1004,6 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
|||||||
return $run instanceof OperationRun ? $run : null;
|
return $run instanceof OperationRun ? $run : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function snapshotBlockedMessage(string $reasonCode): string
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
|
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
|
|
||||||
default => 'No consumable baseline snapshot is currently available for compare.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare baseline items vs current inventory and produce drift results.
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -18,13 +17,8 @@
|
|||||||
|
|
||||||
class ComposeTenantReviewJob implements ShouldQueue
|
class ComposeTenantReviewJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantReviewId,
|
public int $tenantReviewId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs\Concerns;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
trait BridgesFailedOperationRun
|
|
||||||
{
|
|
||||||
public function failed(Throwable $exception): void
|
|
||||||
{
|
|
||||||
$operationRun = $this->failedBridgeOperationRun();
|
|
||||||
|
|
||||||
if (! $operationRun instanceof OperationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function failedBridgeOperationRun(): ?OperationRun
|
|
||||||
{
|
|
||||||
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
|
|
||||||
return $this->operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
|
|
||||||
return $this->run;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidateIds = [];
|
|
||||||
|
|
||||||
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
|
|
||||||
if (! property_exists($this, $property)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = $this->{$property};
|
|
||||||
|
|
||||||
if (is_numeric($value) && (int) $value > 0) {
|
|
||||||
$candidateIds[] = (int) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
|
|
||||||
$operationRun = OperationRun::query()->find($candidateId);
|
|
||||||
|
|
||||||
if ($operationRun instanceof OperationRun) {
|
|
||||||
return $operationRun;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,10 +20,6 @@ class EntraGroupSyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -25,10 +25,6 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 420;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -19,10 +19,6 @@ class GenerateEvidenceSnapshotJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $snapshotId,
|
public int $snapshotId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -28,10 +28,6 @@ class GenerateReviewPackJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $reviewPackId,
|
public int $reviewPackId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -40,10 +40,6 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility-only legacy field.
|
* Compatibility-only legacy field.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -25,15 +24,7 @@
|
|||||||
|
|
||||||
class RunInventorySyncJob implements ShouldQueue
|
class RunInventorySyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -22,15 +21,7 @@
|
|||||||
|
|
||||||
class SyncPoliciesJob implements ShouldQueue
|
class SyncPoliciesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 180;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,6 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -122,37 +121,6 @@ public function snapshots(): HasMany
|
|||||||
return $this->hasMany(BaselineSnapshot::class);
|
return $this->hasMany(BaselineSnapshot::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolveCurrentConsumableSnapshot(): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
$activeSnapshot = $this->relationLoaded('activeSnapshot')
|
|
||||||
? $this->getRelation('activeSnapshot')
|
|
||||||
: $this->activeSnapshot()->first();
|
|
||||||
|
|
||||||
if ($activeSnapshot instanceof BaselineSnapshot && $activeSnapshot->isConsumable()) {
|
|
||||||
return $activeSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->snapshots()
|
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolveLatestAttemptedSnapshot(): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return $this->snapshots()
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasConsumableSnapshot(): bool
|
|
||||||
{
|
|
||||||
return $this->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantAssignments(): HasMany
|
public function tenantAssignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BaselineTenantAssignment::class);
|
return $this->hasMany(BaselineTenantAssignment::class);
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class BaselineSnapshot extends Model
|
class BaselineSnapshot extends Model
|
||||||
{
|
{
|
||||||
@ -19,20 +13,10 @@ class BaselineSnapshot extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
/**
|
protected $casts = [
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
|
||||||
'summary_jsonb' => 'array',
|
'summary_jsonb' => 'array',
|
||||||
'completion_meta_jsonb' => 'array',
|
|
||||||
'captured_at' => 'datetime',
|
'captured_at' => 'datetime',
|
||||||
'completed_at' => 'datetime',
|
|
||||||
'failed_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
@ -48,100 +32,4 @@ public function items(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(BaselineSnapshotItem::class);
|
return $this->hasMany(BaselineSnapshotItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeConsumable(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeLatestConsumable(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->consumable()
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isConsumable(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBuilding(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Building;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isComplete(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isIncomplete(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Incomplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markBuilding(array $completionMeta = []): void
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building,
|
|
||||||
'completed_at' => null,
|
|
||||||
'failed_at' => null,
|
|
||||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markComplete(string $identityHash, array $completionMeta = []): void
|
|
||||||
{
|
|
||||||
if ($this->isIncomplete()) {
|
|
||||||
throw new RuntimeException('Incomplete baseline snapshots cannot transition back to complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->forceFill([
|
|
||||||
'snapshot_identity_hash' => $identityHash,
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete,
|
|
||||||
'completed_at' => now(),
|
|
||||||
'failed_at' => null,
|
|
||||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markIncomplete(?string $reasonCode = null, array $completionMeta = []): void
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete,
|
|
||||||
'completed_at' => null,
|
|
||||||
'failed_at' => now(),
|
|
||||||
'completion_meta_jsonb' => $this->mergedCompletionMeta(array_filter([
|
|
||||||
'finalization_reason_code' => $reasonCode ?? BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
|
||||||
...$completionMeta,
|
|
||||||
], static fn (mixed $value): bool => $value !== null)),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lifecycleState(): BaselineSnapshotLifecycleState
|
|
||||||
{
|
|
||||||
if ($this->lifecycle_state instanceof BaselineSnapshotLifecycleState) {
|
|
||||||
return $this->lifecycle_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($this->lifecycle_state) && BaselineSnapshotLifecycleState::tryFrom($this->lifecycle_state) instanceof BaselineSnapshotLifecycleState) {
|
|
||||||
return BaselineSnapshotLifecycleState::from($this->lifecycle_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BaselineSnapshotLifecycleState::Incomplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $completionMeta
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function mergedCompletionMeta(array $completionMeta): array
|
|
||||||
{
|
|
||||||
$existing = is_array($this->completion_meta_jsonb) ? $this->completion_meta_jsonb : [];
|
|
||||||
|
|
||||||
return array_replace($existing, $completionMeta);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -129,63 +127,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
@ -45,14 +44,6 @@ public function toDatabase(object $notifiable): array
|
|||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $notification->getDatabaseMessage();
|
return $notification->getDatabaseMessage();
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
|
||||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||||
->requiredCapabilityForRun($run);
|
->requiredCapabilityForType((string) $run->type);
|
||||||
|
|
||||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -28,7 +28,6 @@
|
|||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
@ -203,7 +202,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Filament\System\Pages\Dashboard;
|
use App\Filament\System\Pages\Dashboard;
|
||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -61,6 +60,6 @@ public function panel(Panel $panel): Panel
|
|||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
])
|
])
|
||||||
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
->viteTheme('resources/css/filament/system/theme.css');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
@ -113,7 +112,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -152,23 +151,25 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
|||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$runs->updateRunWithReconciliation(
|
$runs->updateRun(
|
||||||
run: $run,
|
$run,
|
||||||
status: $opStatus,
|
status: $opStatus,
|
||||||
outcome: $opOutcome,
|
outcome: $opOutcome,
|
||||||
summaryCounts: $summaryCounts,
|
summaryCounts: $summaryCounts,
|
||||||
failures: $failures,
|
failures: $failures,
|
||||||
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
||||||
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
|
|
||||||
source: 'adapter_reconciler',
|
|
||||||
evidence: [
|
|
||||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
|
||||||
'restore_status' => $restoreStatus?->value,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
|
|
||||||
|
$updatedContext = is_array($run->context) ? $run->context : [];
|
||||||
|
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
||||||
|
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
||||||
|
$reconciliation['reason'] = 'adapter_out_of_sync';
|
||||||
|
|
||||||
|
$updatedContext['reconciliation'] = $reconciliation;
|
||||||
|
|
||||||
|
$run->context = $updatedContext;
|
||||||
|
|
||||||
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||||
$run->started_at = $restoreRun->started_at;
|
$run->started_at = $restoreRun->started_at;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,6 @@ 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,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,36 +49,29 @@ public function startCompare(
|
|||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||||
}
|
}
|
||||||
|
|
||||||
$precondition = $this->validatePreconditions($profile);
|
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
||||||
|
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
return ['ok' => false, 'reason_code' => $precondition];
|
return ['ok' => false, 'reason_code' => $precondition];
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectedSnapshot = null;
|
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
||||||
|
|
||||||
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
if ($snapshotId > 0) {
|
||||||
$selectedSnapshot = BaselineSnapshot::query()
|
$snapshot = BaselineSnapshot::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey((int) $baselineSnapshotId)
|
->whereKey($snapshotId)
|
||||||
->first();
|
->first(['id']);
|
||||||
|
|
||||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
return ['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,
|
||||||
);
|
);
|
||||||
@ -121,7 +113,7 @@ public function startCompare(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
@ -131,6 +123,10 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
|||||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
<?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 : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Baselines;
|
|
||||||
|
|
||||||
use App\Models\BaselineProfile;
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
|
|
||||||
final class BaselineSnapshotTruthResolver
|
|
||||||
{
|
|
||||||
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return BaselineSnapshot::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return BaselineSnapshot::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* ok: bool,
|
|
||||||
* snapshot: ?BaselineSnapshot,
|
|
||||||
* effective_snapshot: ?BaselineSnapshot,
|
|
||||||
* latest_attempted_snapshot: ?BaselineSnapshot,
|
|
||||||
* reason_code: ?string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
|
|
||||||
{
|
|
||||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
|
||||||
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
|
|
||||||
|
|
||||||
if ($explicitSnapshot instanceof BaselineSnapshot) {
|
|
||||||
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|
|
||||||
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'snapshot' => null,
|
|
||||||
'effective_snapshot' => $effectiveSnapshot,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ok' => $reasonCode === null,
|
|
||||||
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
|
|
||||||
'effective_snapshot' => $effectiveSnapshot,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($effectiveSnapshot instanceof BaselineSnapshot) {
|
|
||||||
return [
|
|
||||||
'ok' => true,
|
|
||||||
'snapshot' => $effectiveSnapshot,
|
|
||||||
'effective_snapshot' => $effectiveSnapshot,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'snapshot' => null,
|
|
||||||
'effective_snapshot' => null,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
|
|
||||||
{
|
|
||||||
$effectiveSnapshot ??= BaselineSnapshot::query()
|
|
||||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
|
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $snapshot->isConsumable()
|
|
||||||
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
|
|
||||||
{
|
|
||||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
|
||||||
$snapshot->loadMissing('baselineProfile');
|
|
||||||
|
|
||||||
$profile = $snapshot->baselineProfile;
|
|
||||||
|
|
||||||
if ($profile instanceof BaselineProfile) {
|
|
||||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($snapshot->isBuilding()) {
|
|
||||||
return BaselineReasonCodes::SNAPSHOT_BUILDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($snapshot->isIncomplete()) {
|
|
||||||
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
|
|
||||||
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reasonCode) && trim($reasonCode) !== ''
|
|
||||||
? trim($reasonCode)
|
|
||||||
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
|
|
||||||
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function compareBlockedReasonForSnapshot(
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
?BaselineSnapshot $effectiveSnapshot,
|
|
||||||
bool $explicitSelection,
|
|
||||||
): ?string {
|
|
||||||
if ($snapshot->isBuilding()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($snapshot->isIncomplete()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $snapshot->isConsumable()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
|
|
||||||
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
|
|
||||||
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,8 +14,6 @@
|
|||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use 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;
|
||||||
@ -100,21 +98,13 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
{
|
{
|
||||||
$rendered = $this->present($snapshot);
|
$rendered = $this->present($snapshot);
|
||||||
$factory = new EnterpriseDetailSectionFactory;
|
$factory = new EnterpriseDetailSectionFactory;
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
|
||||||
|
|
||||||
$truthBadge = $factory->statusBadge(
|
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
||||||
$truth->primaryBadgeSpec()->label,
|
$stateBadge = $factory->statusBadge(
|
||||||
$truth->primaryBadgeSpec()->color,
|
$stateSpec->label,
|
||||||
$truth->primaryBadgeSpec()->icon,
|
$stateSpec->color,
|
||||||
$truth->primaryBadgeSpec()->iconColor,
|
$stateSpec->icon,
|
||||||
);
|
$stateSpec->iconColor,
|
||||||
|
|
||||||
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
|
||||||
$lifecycleBadge = $factory->statusBadge(
|
|
||||||
$lifecycleSpec->label,
|
|
||||||
$lifecycleSpec->color,
|
|
||||||
$lifecycleSpec->icon,
|
|
||||||
$lifecycleSpec->iconColor,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||||
@ -129,36 +119,21 @@ 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: [$truthBadge, $lifecycleBadge, $fidelityBadge],
|
statusBadges: [$stateBadge, $fidelityBadge],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
|
||||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||||
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||||
$factory->keyFact('Captured items', $capturedItemCount),
|
$factory->keyFact('Captured items', $capturedItemCount),
|
||||||
],
|
],
|
||||||
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->addSection(
|
||||||
$factory->viewSection(
|
|
||||||
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',
|
||||||
@ -190,21 +165,11 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
->addSupportingCard(
|
->addSupportingCard(
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
title: 'Snapshot truth',
|
title: 'Snapshot status',
|
||||||
items: [
|
|
||||||
$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(
|
||||||
@ -212,8 +177,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
title: 'Capture timing',
|
title: 'Capture timing',
|
||||||
items: [
|
items: [
|
||||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||||
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
|
|
||||||
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
|
|
||||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -365,33 +328,6 @@ private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, color: string, icon: string, iconColor: string}
|
|
||||||
*/
|
|
||||||
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
|
||||||
{
|
|
||||||
return match ($truth->artifactExistence) {
|
|
||||||
'historical_only' => [
|
|
||||||
'label' => 'Historical trace',
|
|
||||||
'color' => 'gray',
|
|
||||||
'icon' => 'heroicon-m-clock',
|
|
||||||
'iconColor' => 'gray',
|
|
||||||
],
|
|
||||||
'created_but_not_usable' => [
|
|
||||||
'label' => 'Not compare input',
|
|
||||||
'color' => 'warning',
|
|
||||||
'icon' => 'heroicon-m-exclamation-triangle',
|
|
||||||
'iconColor' => 'warning',
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
'label' => 'Current baseline',
|
|
||||||
'color' => 'success',
|
|
||||||
'icon' => 'heroicon-m-check-badge',
|
|
||||||
'iconColor' => 'success',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function typeLabel(string $policyType): string
|
private function typeLabel(string $policyType): string
|
||||||
{
|
{
|
||||||
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
||||||
|
|||||||
@ -23,12 +23,7 @@ public function __construct(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||||
* status: string,
|
|
||||||
* reason: ?string,
|
|
||||||
* used_artifacts: bool,
|
|
||||||
* reason_translation: array<string, mixed>|null
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
public function check(Tenant $tenant): array
|
public function check(Tenant $tenant): array
|
||||||
{
|
{
|
||||||
@ -110,19 +105,10 @@ public function check(Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||||
* status: string,
|
|
||||||
* reason: ?string,
|
|
||||||
* used_artifacts: bool,
|
|
||||||
* reason_translation: array<string, mixed>|null
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
|
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
|
||||||
{
|
{
|
||||||
$reasonTranslation = is_string($reason) && $reason !== ''
|
|
||||||
? RbacReason::tryFrom($reason)?->toReasonResolutionEnvelope('detail')->toArray()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$tenant->update([
|
$tenant->update([
|
||||||
'rbac_status' => $status,
|
'rbac_status' => $status,
|
||||||
'rbac_status_reason' => $reason,
|
'rbac_status_reason' => $reason,
|
||||||
@ -133,7 +119,6 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'used_artifacts' => $usedArtifacts,
|
'used_artifacts' => $usedArtifacts,
|
||||||
'reason_translation' => $reasonTranslation,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,18 +17,11 @@
|
|||||||
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;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@ -41,7 +34,6 @@ class OperationRunService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditRecorder $auditRecorder,
|
private readonly AuditRecorder $auditRecorder,
|
||||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||||
private readonly ReasonTranslator $reasonTranslator,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
||||||
@ -63,45 +55,15 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
|||||||
|
|
||||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||||
{
|
{
|
||||||
return $this->forceFailNonTerminalRun(
|
return $this->updateRun(
|
||||||
$run,
|
$run,
|
||||||
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
message: $message,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
source: 'scheduled_reconciler',
|
failures: [
|
||||||
evidence: [
|
[
|
||||||
'status' => OperationRunStatus::Queued->value,
|
'code' => 'run.stale_queued',
|
||||||
'created_at' => $run->created_at?->toIso8601String(),
|
'message' => $message,
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
|
|
||||||
{
|
|
||||||
if ($run->status !== OperationRunStatus::Running->value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$startedAt = $run->started_at ?? $run->created_at;
|
|
||||||
|
|
||||||
if ($startedAt === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function failStaleRunningRun(
|
|
||||||
OperationRun $run,
|
|
||||||
string $message = 'Run stopped reporting progress and was marked failed.',
|
|
||||||
): OperationRun {
|
|
||||||
return $this->forceFailNonTerminalRun(
|
|
||||||
$run,
|
|
||||||
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
|
|
||||||
message: $message,
|
|
||||||
source: 'scheduled_reconciler',
|
|
||||||
evidence: [
|
|
||||||
'status' => OperationRunStatus::Running->value,
|
|
||||||
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -525,16 +487,6 @@ public function updateRun(
|
|||||||
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
$updatedContext = $this->withReasonTranslationContext(
|
|
||||||
run: $run,
|
|
||||||
context: is_array($run->context) ? $run->context : [],
|
|
||||||
failures: is_array($updateData['failure_summary'] ?? null) ? $updateData['failure_summary'] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($updatedContext !== null) {
|
|
||||||
$updateData['context'] = $updatedContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
||||||
$updateData['started_at'] = now();
|
$updateData['started_at'] = now();
|
||||||
}
|
}
|
||||||
@ -752,136 +704,6 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $evidence
|
|
||||||
* @param array<string, mixed> $summaryCounts
|
|
||||||
*/
|
|
||||||
public function forceFailNonTerminalRun(
|
|
||||||
OperationRun $run,
|
|
||||||
string $reasonCode,
|
|
||||||
string $message,
|
|
||||||
string $source = 'scheduled_reconciler',
|
|
||||||
array $evidence = [],
|
|
||||||
array $summaryCounts = [],
|
|
||||||
): OperationRun {
|
|
||||||
return $this->updateRunWithReconciliation(
|
|
||||||
run: $run,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
summaryCounts: $summaryCounts,
|
|
||||||
failures: [[
|
|
||||||
'code' => $reasonCode,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'message' => $message,
|
|
||||||
]],
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
reasonMessage: $message,
|
|
||||||
source: $source,
|
|
||||||
evidence: $evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function bridgeFailedJobFailure(
|
|
||||||
OperationRun $run,
|
|
||||||
Throwable $exception,
|
|
||||||
string $source = 'failed_callback',
|
|
||||||
): OperationRun {
|
|
||||||
$reason = $this->bridgeReasonForThrowable($exception);
|
|
||||||
$message = $reason->defaultMessage();
|
|
||||||
$exceptionMessage = $this->sanitizeMessage($exception->getMessage());
|
|
||||||
|
|
||||||
if ($exceptionMessage !== '') {
|
|
||||||
$message = $exceptionMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->forceFailNonTerminalRun(
|
|
||||||
$run,
|
|
||||||
reasonCode: $reason->value,
|
|
||||||
message: $message,
|
|
||||||
source: $source,
|
|
||||||
evidence: [
|
|
||||||
'exception_class' => $exception::class,
|
|
||||||
'bridge_source' => $source,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $summaryCounts
|
|
||||||
* @param array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}> $failures
|
|
||||||
* @param array<string, mixed> $evidence
|
|
||||||
*/
|
|
||||||
public function updateRunWithReconciliation(
|
|
||||||
OperationRun $run,
|
|
||||||
string $status,
|
|
||||||
string $outcome,
|
|
||||||
array $summaryCounts,
|
|
||||||
array $failures,
|
|
||||||
string $reasonCode,
|
|
||||||
string $reasonMessage,
|
|
||||||
string $source = 'scheduled_reconciler',
|
|
||||||
array $evidence = [],
|
|
||||||
): OperationRun {
|
|
||||||
/** @var OperationRun $updated */
|
|
||||||
$updated = DB::transaction(function () use (
|
|
||||||
$run,
|
|
||||||
$status,
|
|
||||||
$outcome,
|
|
||||||
$summaryCounts,
|
|
||||||
$failures,
|
|
||||||
$reasonCode,
|
|
||||||
$reasonMessage,
|
|
||||||
$source,
|
|
||||||
$evidence,
|
|
||||||
): OperationRun {
|
|
||||||
$locked = OperationRun::query()
|
|
||||||
->whereKey($run->getKey())
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $locked instanceof OperationRun) {
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $locked->status === OperationRunStatus::Completed->value) {
|
|
||||||
return $locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($locked->context) ? $locked->context : [];
|
|
||||||
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
|
||||||
$context['reconciliation'] = $this->reconciliationMetadata(
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
reasonMessage: $reasonMessage,
|
|
||||||
source: $source,
|
|
||||||
evidence: $evidence,
|
|
||||||
);
|
|
||||||
|
|
||||||
$translatedContext = $this->withReasonTranslationContext(
|
|
||||||
run: $locked,
|
|
||||||
context: $context,
|
|
||||||
failures: $failures,
|
|
||||||
);
|
|
||||||
|
|
||||||
$locked->update([
|
|
||||||
'context' => $translatedContext ?? $context,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$locked->refresh();
|
|
||||||
|
|
||||||
return $this->updateRun(
|
|
||||||
$locked,
|
|
||||||
status: $status,
|
|
||||||
outcome: $outcome,
|
|
||||||
summaryCounts: $summaryCounts,
|
|
||||||
failures: $failures,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$updated->refresh();
|
|
||||||
|
|
||||||
return $updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
||||||
*
|
*
|
||||||
@ -899,13 +721,6 @@ public function finalizeBlockedRun(
|
|||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$context['reason_code'] = $reasonCode;
|
$context['reason_code'] = $reasonCode;
|
||||||
$context['next_steps'] = $nextSteps;
|
$context['next_steps'] = $nextSteps;
|
||||||
$context = $this->withReasonTranslationContext(
|
|
||||||
run: $run,
|
|
||||||
context: $context,
|
|
||||||
failures: [[
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
]],
|
|
||||||
) ?? $context;
|
|
||||||
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
|
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
|
||||||
|
|
||||||
$run->update([
|
$run->update([
|
||||||
@ -1128,115 +943,12 @@ protected function sanitizeNextSteps(array $nextSteps): array
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private function withReasonTranslationContext(OperationRun $run, array $context, array $failures): ?array
|
|
||||||
{
|
|
||||||
$reasonCode = $this->resolveReasonCode($context, $failures);
|
|
||||||
|
|
||||||
if ($reasonCode === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasExplicitContextReason = is_string(data_get($context, 'execution_legitimacy.reason_code'))
|
|
||||||
|| is_string(data_get($context, 'reason_code'));
|
|
||||||
|
|
||||||
if (! $hasExplicitContextReason && ! $this->isDirectlyTranslatableReason($reasonCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$translation = $this->reasonTranslator->translate($reasonCode, surface: 'notification', context: $context);
|
|
||||||
|
|
||||||
if (! $translation instanceof ReasonResolutionEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
|
||||||
|
|
||||||
if ($translation->nextSteps === [] && $legacyNextSteps !== []) {
|
|
||||||
$translation = $translation->withNextSteps($legacyNextSteps);
|
|
||||||
}
|
|
||||||
|
|
||||||
$context['reason_translation'] = $translation->toArray();
|
|
||||||
|
|
||||||
if ($translation->toLegacyNextSteps() !== [] && empty($context['next_steps'])) {
|
|
||||||
$context['next_steps'] = $translation->toLegacyNextSteps();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
|
|
||||||
*/
|
|
||||||
private function resolveReasonCode(array $context, array $failures): ?string
|
|
||||||
{
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
|
||||||
?? data_get($context, 'reason_code')
|
|
||||||
?? data_get($failures, '0.reason_code');
|
|
||||||
|
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($reasonCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isDirectlyTranslatableReason(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
if ($reasonCode === ProviderReasonCodes::UnknownError) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
|
||||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
|
||||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
|
||||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
|
||||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $evidence
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function reconciliationMetadata(
|
|
||||||
string $reasonCode,
|
|
||||||
string $reasonMessage,
|
|
||||||
string $source,
|
|
||||||
array $evidence,
|
|
||||||
): array {
|
|
||||||
return [
|
|
||||||
'reconciled_at' => now()->toIso8601String(),
|
|
||||||
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
|
||||||
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
|
||||||
'reason_message' => $this->sanitizeMessage($reasonMessage),
|
|
||||||
'source' => $this->sanitizeFailureCode($source),
|
|
||||||
'evidence' => $evidence,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
|
|
||||||
{
|
|
||||||
$className = strtolower(class_basename($exception));
|
|
||||||
|
|
||||||
if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
|
|
||||||
return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return LifecycleReconciliationReason::QueueFailureBridge;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function writeTerminalAudit(OperationRun $run): void
|
private function writeTerminalAudit(OperationRun $run): void
|
||||||
{
|
{
|
||||||
$tenant = $run->tenant;
|
$tenant = $run->tenant;
|
||||||
$workspace = $run->workspace;
|
$workspace = $run->workspace;
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
||||||
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||||
|
|
||||||
$action = match ($run->outcome) {
|
$action = match ($run->outcome) {
|
||||||
@ -1266,7 +978,6 @@ private function writeTerminalAudit(OperationRun $run): void
|
|||||||
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
||||||
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
||||||
'blocked_by' => $context['blocked_by'] ?? null,
|
'blocked_by' => $context['blocked_by'] ?? null,
|
||||||
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Operations;
|
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class OperationLifecyclePolicyValidator
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly OperationLifecyclePolicy $policy,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* valid:bool,
|
|
||||||
* errors:array<int, string>,
|
|
||||||
* definitions:array<string, array<string, mixed>>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function validate(): array
|
|
||||||
{
|
|
||||||
$errors = [];
|
|
||||||
$definitions = [];
|
|
||||||
|
|
||||||
foreach ($this->policy->coveredTypeNames() as $operationType) {
|
|
||||||
$definition = $this->policy->definition($operationType);
|
|
||||||
|
|
||||||
if ($definition === null) {
|
|
||||||
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$definitions[$operationType] = $definition;
|
|
||||||
$jobClass = $this->policy->jobClass($operationType);
|
|
||||||
|
|
||||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
|
||||||
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeout = $this->jobTimeoutSeconds($operationType);
|
|
||||||
|
|
||||||
if (! is_int($timeout) || $timeout <= 0) {
|
|
||||||
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->jobFailsOnTimeout($operationType)) {
|
|
||||||
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
|
|
||||||
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
|
|
||||||
}
|
|
||||||
|
|
||||||
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
|
|
||||||
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
|
|
||||||
|
|
||||||
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
|
|
||||||
$errors[] = sprintf(
|
|
||||||
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
|
|
||||||
$operationType,
|
|
||||||
$timeout,
|
|
||||||
$retryAfter,
|
|
||||||
$safetyMargin,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
|
|
||||||
|
|
||||||
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
|
|
||||||
$errors[] = sprintf(
|
|
||||||
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
|
|
||||||
$operationType,
|
|
||||||
$expectedMaxRuntime,
|
|
||||||
$retryAfter,
|
|
||||||
$safetyMargin,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'valid' => $errors === [],
|
|
||||||
'errors' => $errors,
|
|
||||||
'definitions' => $definitions,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assertValid(): void
|
|
||||||
{
|
|
||||||
$result = $this->validate();
|
|
||||||
|
|
||||||
if (($result['valid'] ?? false) === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jobTimeoutSeconds(string $operationType): ?int
|
|
||||||
{
|
|
||||||
$jobClass = $this->policy->jobClass($operationType);
|
|
||||||
|
|
||||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
|
|
||||||
|
|
||||||
return is_numeric($timeout) ? (int) $timeout : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jobFailsOnTimeout(string $operationType): bool
|
|
||||||
{
|
|
||||||
$jobClass = $this->policy->jobClass($operationType);
|
|
||||||
|
|
||||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jobUsesDirectFailedBridge(string $operationType): bool
|
|
||||||
{
|
|
||||||
$jobClass = $this->policy->jobClass($operationType);
|
|
||||||
|
|
||||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Operations;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
final class OperationLifecycleReconciler
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly OperationLifecyclePolicy $policy,
|
|
||||||
private readonly OperationRunService $operationRunService,
|
|
||||||
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* types?: array<int, string>,
|
|
||||||
* tenant_ids?: array<int, int>,
|
|
||||||
* workspace_ids?: array<int, int>,
|
|
||||||
* limit?: int,
|
|
||||||
* dry_run?: bool
|
|
||||||
* } $options
|
|
||||||
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
|
|
||||||
*/
|
|
||||||
public function reconcile(array $options = []): array
|
|
||||||
{
|
|
||||||
$types = array_values(array_filter(
|
|
||||||
$options['types'] ?? $this->policy->coveredTypeNames(),
|
|
||||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
|
||||||
));
|
|
||||||
$tenantIds = array_values(array_filter(
|
|
||||||
$options['tenant_ids'] ?? [],
|
|
||||||
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
|
|
||||||
));
|
|
||||||
$workspaceIds = array_values(array_filter(
|
|
||||||
$options['workspace_ids'] ?? [],
|
|
||||||
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
|
|
||||||
));
|
|
||||||
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
|
|
||||||
$dryRun = (bool) ($options['dry_run'] ?? false);
|
|
||||||
|
|
||||||
$runs = OperationRun::query()
|
|
||||||
->with(['tenant', 'user'])
|
|
||||||
->whereIn('type', $types)
|
|
||||||
->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])
|
|
||||||
->when(
|
|
||||||
$tenantIds !== [],
|
|
||||||
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$workspaceIds !== [],
|
|
||||||
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
|
|
||||||
)
|
|
||||||
->orderBy('id')
|
|
||||||
->limit($limit)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$changes = [];
|
|
||||||
$reconciled = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
|
|
||||||
foreach ($runs as $run) {
|
|
||||||
$change = $this->reconcileRun($run, $dryRun);
|
|
||||||
|
|
||||||
if ($change === null) {
|
|
||||||
$skipped++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$changes[] = $change;
|
|
||||||
|
|
||||||
if (($change['applied'] ?? false) === true) {
|
|
||||||
$reconciled++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'candidates' => $runs->count(),
|
|
||||||
'reconciled' => $reconciled,
|
|
||||||
'skipped' => $skipped,
|
|
||||||
'changes' => $changes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
|
|
||||||
{
|
|
||||||
$assessment = $this->assessment($run);
|
|
||||||
|
|
||||||
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$before = [
|
|
||||||
'status' => (string) $run->status,
|
|
||||||
'outcome' => (string) $run->outcome,
|
|
||||||
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
|
|
||||||
];
|
|
||||||
$after = [
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
return [
|
|
||||||
'applied' => false,
|
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
|
||||||
'type' => (string) $run->type,
|
|
||||||
'before' => $before,
|
|
||||||
'after' => $after,
|
|
||||||
'reason_code' => $assessment['reason_code'],
|
|
||||||
'reason_message' => $assessment['reason_message'],
|
|
||||||
'evidence' => $assessment['evidence'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $this->operationRunService->forceFailNonTerminalRun(
|
|
||||||
run: $run,
|
|
||||||
reasonCode: (string) $assessment['reason_code'],
|
|
||||||
message: (string) $assessment['reason_message'],
|
|
||||||
source: 'scheduled_reconciler',
|
|
||||||
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'applied' => true,
|
|
||||||
'operation_run_id' => (int) $updated->getKey(),
|
|
||||||
'type' => (string) $updated->type,
|
|
||||||
'before' => $before,
|
|
||||||
'after' => $after,
|
|
||||||
'reason_code' => $assessment['reason_code'],
|
|
||||||
'reason_message' => $assessment['reason_message'],
|
|
||||||
'evidence' => $assessment['evidence'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
|
|
||||||
*/
|
|
||||||
public function assessment(OperationRun $run): ?array
|
|
||||||
{
|
|
||||||
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
|
|
||||||
|
|
||||||
if (! $freshnessState->isLikelyStale()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reason = (string) $run->status === OperationRunStatus::Queued->value
|
|
||||||
? LifecycleReconciliationReason::StaleQueued
|
|
||||||
: LifecycleReconciliationReason::StaleRunning;
|
|
||||||
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
|
|
||||||
? $run->created_at
|
|
||||||
: ($run->started_at ?? $run->created_at);
|
|
||||||
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
|
|
||||||
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
|
|
||||||
: $this->policy->runningStaleAfterSeconds((string) $run->type);
|
|
||||||
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'should_reconcile' => true,
|
|
||||||
'reason_code' => $reason->value,
|
|
||||||
'reason_message' => $reason->defaultMessage(),
|
|
||||||
'evidence' => [
|
|
||||||
'evaluated_at' => now()->toIso8601String(),
|
|
||||||
'freshness_state' => $freshnessState->value,
|
|
||||||
'threshold_seconds' => $thresholdSeconds,
|
|
||||||
'reference_time' => $referenceTime?->toIso8601String(),
|
|
||||||
'status' => (string) $run->status,
|
|
||||||
'execution_legitimacy' => $legitimacy,
|
|
||||||
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
|
|
||||||
? 'direct_and_scheduled'
|
|
||||||
: 'scheduled_only',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecycle;
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
use App\Support\Tenants\TenantOperabilityContext;
|
use App\Support\Tenants\TenantOperabilityContext;
|
||||||
@ -218,11 +217,6 @@ public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
|||||||
)->allowed;
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function presentReason(TenantOperabilityOutcome $outcome): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return $outcome->reasonCode?->toReasonResolutionEnvelope('detail');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tenant> $tenants
|
* @param Collection<int, Tenant> $tenants
|
||||||
* @return Collection<int, Tenant>
|
* @return Collection<int, Tenant>
|
||||||
|
|||||||
@ -15,12 +15,6 @@ 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,12 +6,6 @@ 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';
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
|
|
||||||
final class BaselineSnapshotLifecycleBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
BaselineSnapshotLifecycleState::Building->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-arrow-path'),
|
|
||||||
BaselineSnapshotLifecycleState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-check-circle'),
|
|
||||||
BaselineSnapshotLifecycleState::Incomplete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-x-circle'),
|
|
||||||
'superseded' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-clock'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
} ?? BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
|
||||||
|
|
||||||
final class 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
|
||||||
|
|
||||||
final class 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
|
||||||
|
|
||||||
final class 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
|
||||||
|
|
||||||
final class 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
|
||||||
|
|
||||||
final class 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,46 +8,12 @@
|
|||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
|
|
||||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
public function spec(mixed $value): BadgeSpec
|
public function spec(mixed $value): BadgeSpec
|
||||||
{
|
{
|
||||||
$state = null;
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
|
|
||||||
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
|
||||||
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
|
||||||
|
|
||||||
if ($outcome === null) {
|
|
||||||
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
|
|
||||||
$outcome = OperationRunOutcome::Failed->value;
|
|
||||||
} elseif (
|
|
||||||
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|
|
||||||
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
|
||||||
) {
|
|
||||||
$outcome = OperationRunOutcome::Pending->value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($outcome === OperationRunOutcome::Failed->value
|
|
||||||
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
|
|
||||||
) {
|
|
||||||
return new BadgeSpec(
|
|
||||||
label: 'Reconciled failed',
|
|
||||||
color: 'danger',
|
|
||||||
icon: 'heroicon-m-arrow-path-rounded-square',
|
|
||||||
iconColor: 'danger',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$state = $outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
$state ??= BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||||
|
|||||||
@ -8,33 +8,12 @@
|
|||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
|
|
||||||
final class OperationRunStatusBadge implements BadgeMapper
|
final class OperationRunStatusBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
public function spec(mixed $value): BadgeSpec
|
public function spec(mixed $value): BadgeSpec
|
||||||
{
|
{
|
||||||
$state = null;
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
|
||||||
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
|
||||||
|
|
||||||
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
|
||||||
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
|
|
||||||
) {
|
|
||||||
return new BadgeSpec(
|
|
||||||
label: 'Likely stale',
|
|
||||||
color: 'warning',
|
|
||||||
icon: 'heroicon-m-exclamation-triangle',
|
|
||||||
iconColor: 'warning',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$state = $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
$state ??= BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||||
|
|||||||
@ -21,243 +21,6 @@ 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',
|
||||||
@ -823,11 +586,6 @@ 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,7 +6,6 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -21,7 +20,6 @@ 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',
|
||||||
@ -38,7 +36,6 @@ 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,13 +5,11 @@
|
|||||||
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;
|
||||||
@ -75,11 +73,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
|
|
||||||
$profileName = (string) $profile->name;
|
$profileName = (string) $profile->name;
|
||||||
$profileId = (int) $profile->getKey();
|
$profileId = (int) $profile->getKey();
|
||||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
|
||||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
|
||||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
|
||||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
|
||||||
|
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
@ -92,21 +86,12 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||||
|
|
||||||
if ($snapshotId === null) {
|
if ($snapshotId === null) {
|
||||||
return new self(
|
return self::empty(
|
||||||
state: 'no_snapshot',
|
'no_snapshot',
|
||||||
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||||
profileName: $profileName,
|
profileName: $profileName,
|
||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: null,
|
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
operationRunId: null,
|
|
||||||
findingsCount: null,
|
|
||||||
severityCounts: [],
|
|
||||||
lastComparedHuman: null,
|
|
||||||
lastComparedIso: null,
|
|
||||||
failureReason: null,
|
|
||||||
reasonCode: $snapshotReasonCode,
|
|
||||||
reasonMessage: $snapshotReasonMessage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,11 +291,6 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
}
|
}
|
||||||
|
|
||||||
$profile = $assignment->baselineProfile;
|
$profile = $assignment->baselineProfile;
|
||||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
|
||||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
|
||||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
|
||||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
|
||||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
|
||||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$severityRows = Finding::query()
|
$severityRows = Finding::query()
|
||||||
@ -334,11 +314,11 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
|
state: $totalFindings > 0 ? 'ready' : 'idle',
|
||||||
message: $snapshotId === null ? $snapshotReasonMessage : null,
|
message: null,
|
||||||
profileName: (string) $profile->name,
|
profileName: (string) $profile->name,
|
||||||
profileId: (int) $profile->getKey(),
|
profileId: (int) $profile->getKey(),
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||||
duplicateNamePoliciesCount: null,
|
duplicateNamePoliciesCount: null,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
@ -350,8 +330,6 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
||||||
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
reasonCode: $snapshotReasonCode,
|
|
||||||
reasonMessage: $snapshotReasonMessage,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -605,15 +583,4 @@ private static function empty(
|
|||||||
failureReason: null,
|
failureReason: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function missingSnapshotMessage(?string $reasonCode): ?string
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
|
||||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,71 +18,13 @@ final class BaselineReasonCodes
|
|||||||
|
|
||||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
||||||
|
|
||||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_SUPERSEDED = 'baseline.snapshot.superseded';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_CAPTURE_FAILED = 'baseline.snapshot.capture_failed';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_COMPLETION_PROOF_FAILED = 'baseline.snapshot.completion_proof_failed';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_LEGACY_NO_PROOF = 'baseline.snapshot.legacy_no_proof';
|
|
||||||
|
|
||||||
public const string SNAPSHOT_LEGACY_CONTRADICTORY = 'baseline.snapshot.legacy_contradictory';
|
|
||||||
|
|
||||||
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
|
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
|
||||||
|
|
||||||
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
|
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
|
||||||
|
|
||||||
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
|
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
|
||||||
|
|
||||||
public const string COMPARE_NO_CONSUMABLE_SNAPSHOT = 'baseline.compare.no_consumable_snapshot';
|
|
||||||
|
|
||||||
public const string COMPARE_NO_ELIGIBLE_TARGET = 'baseline.compare.no_eligible_target';
|
|
||||||
|
|
||||||
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
||||||
|
|
||||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
||||||
|
|
||||||
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
|
|
||||||
|
|
||||||
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
|
|
||||||
|
|
||||||
public const string COMPARE_SNAPSHOT_SUPERSEDED = 'baseline.compare.snapshot_superseded';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function all(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
|
||||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
|
||||||
self::CAPTURE_ROLLOUT_DISABLED,
|
|
||||||
self::SNAPSHOT_BUILDING,
|
|
||||||
self::SNAPSHOT_INCOMPLETE,
|
|
||||||
self::SNAPSHOT_SUPERSEDED,
|
|
||||||
self::SNAPSHOT_CAPTURE_FAILED,
|
|
||||||
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
|
||||||
self::SNAPSHOT_LEGACY_NO_PROOF,
|
|
||||||
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
|
||||||
self::COMPARE_NO_ASSIGNMENT,
|
|
||||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
|
||||||
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
|
||||||
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
|
||||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
|
||||||
self::COMPARE_INVALID_SNAPSHOT,
|
|
||||||
self::COMPARE_ROLLOUT_DISABLED,
|
|
||||||
self::COMPARE_SNAPSHOT_BUILDING,
|
|
||||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
|
||||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isKnown(?string $reasonCode): bool
|
|
||||||
{
|
|
||||||
return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Baselines;
|
|
||||||
|
|
||||||
enum BaselineSnapshotLifecycleState: string
|
|
||||||
{
|
|
||||||
case Building = 'building';
|
|
||||||
case Complete = 'complete';
|
|
||||||
case Incomplete = 'incomplete';
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Building => 'Building',
|
|
||||||
self::Complete => 'Complete',
|
|
||||||
self::Incomplete => 'Incomplete',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isConsumable(): bool
|
|
||||||
{
|
|
||||||
return $this === self::Complete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isTerminal(): bool
|
|
||||||
{
|
|
||||||
return in_array($this, [self::Complete, self::Incomplete], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function values(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (self $state): string => $state->value,
|
|
||||||
self::cases(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Filament;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Vite;
|
|
||||||
|
|
||||||
class PanelThemeAsset
|
|
||||||
{
|
|
||||||
public static function resolve(string $entry): string
|
|
||||||
{
|
|
||||||
$manifest = public_path('build/manifest.json');
|
|
||||||
|
|
||||||
if (! is_file($manifest)) {
|
|
||||||
return Vite::asset($entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, array{file?: string}>|null $decoded */
|
|
||||||
$decoded = json_decode((string) file_get_contents($manifest), true);
|
|
||||||
$file = $decoded[$entry]['file'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($file) || $file === '') {
|
|
||||||
return Vite::asset($entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return asset('build/'.$file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -378,7 +378,7 @@ private function resolveBaselineProfileRule(NavigationMatrixRule $rule, Baseline
|
|||||||
return match ($rule->relationKey) {
|
return match ($rule->relationKey) {
|
||||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||||
rule: $rule,
|
rule: $rule,
|
||||||
snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
|
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
|
||||||
workspaceId: (int) $profile->workspace_id,
|
workspaceId: (int) $profile->workspace_id,
|
||||||
),
|
),
|
||||||
default => null,
|
default => null,
|
||||||
|
|||||||
@ -105,20 +105,4 @@ 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,20 +5,13 @@
|
|||||||
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
|
||||||
@ -86,14 +79,6 @@ 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);
|
||||||
|
|
||||||
@ -116,39 +101,6 @@ 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 !== '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Operations;
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum ExecutionDenialReasonCode: string
|
enum ExecutionDenialReasonCode: string
|
||||||
{
|
{
|
||||||
case WorkspaceMismatch = 'workspace_mismatch';
|
case WorkspaceMismatch = 'workspace_mismatch';
|
||||||
@ -46,85 +43,4 @@ public function message(): string
|
|||||||
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'Workspace context changed',
|
|
||||||
self::TenantNotEntitled => 'Tenant access removed',
|
|
||||||
self::MissingCapability => 'Permission required',
|
|
||||||
self::TenantNotOperable => 'Tenant not ready',
|
|
||||||
self::TenantMissing => 'Tenant record unavailable',
|
|
||||||
self::InitiatorMissing => 'Initiator no longer available',
|
|
||||||
self::InitiatorNotEntitled => 'Initiator lost tenant access',
|
|
||||||
self::ProviderConnectionInvalid => 'Provider connection needs review',
|
|
||||||
self::WriteGateBlocked => 'Write protection blocked execution',
|
|
||||||
self::ExecutionPrerequisiteInvalid => 'Execution prerequisite changed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'The queued run no longer matches the current workspace scope.',
|
|
||||||
self::TenantNotEntitled => 'The queued tenant is no longer entitled for this run.',
|
|
||||||
self::MissingCapability => 'The initiating actor no longer has the capability required for this queued run.',
|
|
||||||
self::TenantNotOperable => 'The target tenant is not currently operable for this action.',
|
|
||||||
self::TenantMissing => 'The target tenant could not be resolved when execution resumed.',
|
|
||||||
self::InitiatorMissing => 'The initiating actor could not be resolved when execution resumed.',
|
|
||||||
self::InitiatorNotEntitled => 'The initiating actor is no longer entitled to the target tenant.',
|
|
||||||
self::ProviderConnectionInvalid => 'The queued provider connection is no longer valid for this scope.',
|
|
||||||
self::WriteGateBlocked => 'Current write hardening refuses execution for this tenant until the gate is satisfied.',
|
|
||||||
self::ExecutionPrerequisiteInvalid => 'The queued execution prerequisites are no longer satisfied.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::TenantNotOperable => 'retryable_transient',
|
|
||||||
self::ProviderConnectionInvalid, self::WriteGateBlocked, self::ExecutionPrerequisiteInvalid => 'prerequisite_missing',
|
|
||||||
default => 'permanent_configuration',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::MissingCapability, self::TenantNotEntitled, self::InitiatorNotEntitled, self::WorkspaceMismatch => [
|
|
||||||
NextStepOption::instruction('Review workspace or tenant access before retrying.', scope: 'workspace'),
|
|
||||||
],
|
|
||||||
self::TenantNotOperable, self::ExecutionPrerequisiteInvalid => [
|
|
||||||
NextStepOption::instruction('Review tenant readiness before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::ProviderConnectionInvalid => [
|
|
||||||
NextStepOption::instruction('Review the provider connection before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::WriteGateBlocked => [
|
|
||||||
NextStepOption::instruction('Review the write gate state before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::TenantMissing, self::InitiatorMissing => [
|
|
||||||
NextStepOption::instruction('Requeue the operation from a current tenant context.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $this->value,
|
|
||||||
operatorLabel: $this->operatorLabel(),
|
|
||||||
shortExplanation: $this->shortExplanation(),
|
|
||||||
actionability: $this->actionability(),
|
|
||||||
nextSteps: $this->nextSteps(),
|
|
||||||
showNoActionNeeded: false,
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Operations;
|
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum LifecycleReconciliationReason: string
|
|
||||||
{
|
|
||||||
case StaleQueued = 'run.stale_queued';
|
|
||||||
case StaleRunning = 'run.stale_running';
|
|
||||||
case InfrastructureTimeoutOrAbandonment = 'run.infrastructure_timeout_or_abandonment';
|
|
||||||
case QueueFailureBridge = 'run.queue_failure_bridge';
|
|
||||||
case AdapterOutOfSync = 'run.adapter_out_of_sync';
|
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::StaleQueued => 'Run never started',
|
|
||||||
self::StaleRunning => 'Run stopped reporting progress',
|
|
||||||
self::InfrastructureTimeoutOrAbandonment => 'Infrastructure ended the run',
|
|
||||||
self::QueueFailureBridge => 'Queue failure was reconciled',
|
|
||||||
self::AdapterOutOfSync => 'Lifecycle was reconciled from related records',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::StaleQueued => 'The run stayed queued past its lifecycle window and was marked failed.',
|
|
||||||
self::StaleRunning => 'The run stayed active past its lifecycle window and was marked failed.',
|
|
||||||
self::InfrastructureTimeoutOrAbandonment => 'Queue infrastructure ended the job before normal completion could update the run.',
|
|
||||||
self::QueueFailureBridge => 'The platform bridged a queue failure back to the owning run and marked it failed.',
|
|
||||||
self::AdapterOutOfSync => 'A related restore record reached terminal truth before the operation run was updated.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::AdapterOutOfSync => 'non_actionable',
|
|
||||||
default => 'retryable_transient',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::AdapterOutOfSync => [
|
|
||||||
NextStepOption::instruction('Review the related restore record before deciding whether to run the workflow again.'),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::instruction('Review worker health and logs before retrying this operation.'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function defaultMessage(): string
|
|
||||||
{
|
|
||||||
return $this->shortExplanation();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $this->value,
|
|
||||||
operatorLabel: $this->operatorLabel(),
|
|
||||||
shortExplanation: $this->shortExplanation(),
|
|
||||||
actionability: $this->actionability(),
|
|
||||||
nextSteps: $this->nextSteps(),
|
|
||||||
showNoActionNeeded: false,
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Operations;
|
|
||||||
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
final class OperationLifecyclePolicy
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, array{
|
|
||||||
* job_class?: class-string,
|
|
||||||
* queued_stale_after_seconds?: int,
|
|
||||||
* running_stale_after_seconds?: int,
|
|
||||||
* expected_max_runtime_seconds?: int,
|
|
||||||
* direct_failed_bridge?: bool,
|
|
||||||
* scheduled_reconciliation?: bool
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function coveredTypes(): array
|
|
||||||
{
|
|
||||||
$coveredTypes = config('tenantpilot.operations.lifecycle.covered_types', []);
|
|
||||||
|
|
||||||
return is_array($coveredTypes) ? $coveredTypes : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* job_class?: class-string,
|
|
||||||
* queued_stale_after_seconds:int,
|
|
||||||
* running_stale_after_seconds:int,
|
|
||||||
* expected_max_runtime_seconds:?int,
|
|
||||||
* direct_failed_bridge:bool,
|
|
||||||
* scheduled_reconciliation:bool
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function definition(string $operationType): ?array
|
|
||||||
{
|
|
||||||
$operationType = trim($operationType);
|
|
||||||
|
|
||||||
if ($operationType === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$definition = $this->coveredTypes()[$operationType] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($definition)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'job_class' => is_string($definition['job_class'] ?? null) ? $definition['job_class'] : null,
|
|
||||||
'queued_stale_after_seconds' => max(1, (int) ($definition['queued_stale_after_seconds'] ?? 300)),
|
|
||||||
'running_stale_after_seconds' => max(1, (int) ($definition['running_stale_after_seconds'] ?? 900)),
|
|
||||||
'expected_max_runtime_seconds' => is_numeric($definition['expected_max_runtime_seconds'] ?? null)
|
|
||||||
? max(1, (int) $definition['expected_max_runtime_seconds'])
|
|
||||||
: null,
|
|
||||||
'direct_failed_bridge' => (bool) ($definition['direct_failed_bridge'] ?? false),
|
|
||||||
'scheduled_reconciliation' => (bool) ($definition['scheduled_reconciliation'] ?? true),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function supports(string $operationType): bool
|
|
||||||
{
|
|
||||||
return $this->definition($operationType) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function coveredTypeNames(): array
|
|
||||||
{
|
|
||||||
return array_values(array_keys($this->coveredTypes()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function queuedStaleAfterSeconds(string $operationType): int
|
|
||||||
{
|
|
||||||
return (int) ($this->definition($operationType)['queued_stale_after_seconds'] ?? 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runningStaleAfterSeconds(string $operationType): int
|
|
||||||
{
|
|
||||||
return (int) ($this->definition($operationType)['running_stale_after_seconds'] ?? 900);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function expectedMaxRuntimeSeconds(string $operationType): ?int
|
|
||||||
{
|
|
||||||
$expectedMaxRuntimeSeconds = $this->definition($operationType)['expected_max_runtime_seconds'] ?? null;
|
|
||||||
|
|
||||||
return is_int($expectedMaxRuntimeSeconds) ? $expectedMaxRuntimeSeconds : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function requiresDirectFailedBridge(string $operationType): bool
|
|
||||||
{
|
|
||||||
return (bool) ($this->definition($operationType)['direct_failed_bridge'] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function supportsScheduledReconciliation(string $operationType): bool
|
|
||||||
{
|
|
||||||
return (bool) ($this->definition($operationType)['scheduled_reconciliation'] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reconciliationBatchLimit(): int
|
|
||||||
{
|
|
||||||
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.batch_limit', 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reconciliationScheduleMinutes(): int
|
|
||||||
{
|
|
||||||
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.schedule_minutes', 5));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function retryAfterSafetyMarginSeconds(): int
|
|
||||||
{
|
|
||||||
return max(1, (int) config('queue.lifecycle_invariants.retry_after_safety_margin', 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function queueConnection(string $operationType): ?string
|
|
||||||
{
|
|
||||||
$jobClass = $this->jobClass($operationType);
|
|
||||||
|
|
||||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$connection = Arr::get(get_class_vars($jobClass), 'connection');
|
|
||||||
|
|
||||||
return is_string($connection) && trim($connection) !== '' ? trim($connection) : config('queue.default');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function queueRetryAfterSeconds(?string $connection = null): ?int
|
|
||||||
{
|
|
||||||
$connection = is_string($connection) && trim($connection) !== '' ? trim($connection) : (string) config('queue.default', 'database');
|
|
||||||
$retryAfter = config("queue.connections.{$connection}.retry_after");
|
|
||||||
|
|
||||||
if (is_numeric($retryAfter)) {
|
|
||||||
return max(1, (int) $retryAfter);
|
|
||||||
}
|
|
||||||
|
|
||||||
$databaseRetryAfter = config('queue.connections.database.retry_after');
|
|
||||||
|
|
||||||
return is_numeric($databaseRetryAfter) ? max(1, (int) $databaseRetryAfter) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jobClass(string $operationType): ?string
|
|
||||||
{
|
|
||||||
$jobClass = $this->definition($operationType)['job_class'] ?? null;
|
|
||||||
|
|
||||||
return is_string($jobClass) && $jobClass !== '' ? $jobClass : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,16 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Support\Operations;
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
|
||||||
final class OperationRunCapabilityResolver
|
final class OperationRunCapabilityResolver
|
||||||
{
|
{
|
||||||
public function requiredCapabilityForRun(OperationRun $run): ?string
|
|
||||||
{
|
|
||||||
return $this->requiredCapabilityForType((string) $run->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function requiredCapabilityForType(string $operationType): ?string
|
public function requiredCapabilityForType(string $operationType): ?string
|
||||||
{
|
{
|
||||||
$operationType = trim($operationType);
|
$operationType = trim($operationType);
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Operations;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
|
|
||||||
enum OperationRunFreshnessState: string
|
|
||||||
{
|
|
||||||
case FreshActive = 'fresh_active';
|
|
||||||
case LikelyStale = 'likely_stale';
|
|
||||||
case ReconciledFailed = 'reconciled_failed';
|
|
||||||
case TerminalNormal = 'terminal_normal';
|
|
||||||
case Unknown = 'unknown';
|
|
||||||
|
|
||||||
public static function forRun(OperationRun $run, ?OperationLifecyclePolicy $policy = null): self
|
|
||||||
{
|
|
||||||
$policy ??= app(OperationLifecyclePolicy::class);
|
|
||||||
|
|
||||||
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
|
||||||
return $run->isLifecycleReconciled() ? self::ReconciledFailed : self::TerminalNormal;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $policy->supports((string) $run->type)) {
|
|
||||||
return self::Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
|
||||||
if ($run->started_at !== null || $run->created_at === null) {
|
|
||||||
return self::Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $run->created_at->lte(now()->subSeconds($policy->queuedStaleAfterSeconds((string) $run->type)))
|
|
||||||
? self::LikelyStale
|
|
||||||
: self::FreshActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $run->status === OperationRunStatus::Running->value) {
|
|
||||||
$startedAt = $run->started_at ?? $run->created_at;
|
|
||||||
|
|
||||||
if ($startedAt === null) {
|
|
||||||
return self::Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $startedAt->lte(now()->subSeconds($policy->runningStaleAfterSeconds((string) $run->type)))
|
|
||||||
? self::LikelyStale
|
|
||||||
: self::FreshActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isFreshActive(): bool
|
|
||||||
{
|
|
||||||
return $this === self::FreshActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isLikelyStale(): bool
|
|
||||||
{
|
|
||||||
return $this === self::LikelyStale;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isReconciledFailed(): bool
|
|
||||||
{
|
|
||||||
return $this === self::ReconciledFailed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,8 +7,6 @@
|
|||||||
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\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
|
||||||
@ -97,22 +95,7 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
|
|||||||
public static function surfaceGuidance(OperationRun $run): ?string
|
public static function surfaceGuidance(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
|
||||||
$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) {
|
|
||||||
return $reasonGuidance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($uxStatus) {
|
return match ($uxStatus) {
|
||||||
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
||||||
@ -134,51 +117,17 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
|||||||
|
|
||||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
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}
|
||||||
*/
|
*/
|
||||||
private static function terminalPresentation(OperationRun $run): array
|
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);
|
|
||||||
$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' => [
|
||||||
@ -193,12 +142,12 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'titleSuffix' => 'blocked by prerequisite',
|
'titleSuffix' => 'blocked by prerequisite',
|
||||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
'body' => 'Blocked by prerequisite.',
|
||||||
'status' => 'warning',
|
'status' => 'warning',
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
'titleSuffix' => 'execution failed',
|
'titleSuffix' => 'execution failed',
|
||||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
'body' => 'Execution failed.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -255,9 +204,4 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
|
|||||||
|
|
||||||
return $failureMessage !== '' ? $failureMessage : null;
|
return $failureMessage !== '' ? $failureMessage : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
final class RunDurationInsights
|
final class RunDurationInsights
|
||||||
@ -119,10 +118,6 @@ public static function expectedHuman(OperationRun $run): ?string
|
|||||||
|
|
||||||
public static function stuckGuidance(OperationRun $run): ?string
|
public static function stuckGuidance(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
if ($run->freshnessState() === OperationRunFreshnessState::LikelyStale) {
|
|
||||||
return 'Past the lifecycle window. Review worker health and logs before retrying.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
|
|
||||||
if (! in_array($uxStatus, ['queued', 'running'], true)) {
|
if (! in_array($uxStatus, ['queued', 'running'], true)) {
|
||||||
|
|||||||
@ -3,9 +3,7 @@
|
|||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
use App\Services\Intune\SecretClassificationService;
|
use App\Services\Intune\SecretClassificationService;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
final class RunFailureSanitizer
|
final class RunFailureSanitizer
|
||||||
@ -40,12 +38,16 @@ public static function sanitizeCode(string $code): string
|
|||||||
public static function normalizeReasonCode(string $candidate): string
|
public static function normalizeReasonCode(string $candidate): string
|
||||||
{
|
{
|
||||||
$candidate = strtolower(trim($candidate));
|
$candidate = strtolower(trim($candidate));
|
||||||
|
$executionDenialReasonCodes = array_map(
|
||||||
|
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
|
||||||
|
ExecutionDenialReasonCode::cases(),
|
||||||
|
);
|
||||||
|
|
||||||
if ($candidate === '') {
|
if ($candidate === '') {
|
||||||
return ProviderReasonCodes::UnknownError;
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,11 +85,11 @@ public static function normalizeReasonCode(string $candidate): string
|
|||||||
default => $candidate,
|
default => $candidate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristic normalization for ad-hoc inputs is bounded fallback behavior only.
|
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
||||||
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
||||||
return ProviderReasonCodes::RateLimited;
|
return ProviderReasonCodes::RateLimited;
|
||||||
}
|
}
|
||||||
@ -119,30 +121,6 @@ public static function normalizeReasonCode(string $candidate): string
|
|||||||
return ProviderReasonCodes::UnknownError;
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function isStructuredOperatorReasonCode(string $candidate): bool
|
|
||||||
{
|
|
||||||
$candidate = strtolower(trim($candidate));
|
|
||||||
|
|
||||||
if ($candidate === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$executionDenialReasonCodes = array_map(
|
|
||||||
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
|
|
||||||
ExecutionDenialReasonCode::cases(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$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
|
||||||
{
|
{
|
||||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||||
|
|||||||
@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
|
||||||
|
|
||||||
final class SummaryCountsNormalizer
|
final class SummaryCountsNormalizer
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@ -86,22 +84,6 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
|||||||
*/
|
*/
|
||||||
public static function label(string $key): string
|
public static function label(string $key): string
|
||||||
{
|
{
|
||||||
$reasonCode = null;
|
|
||||||
|
|
||||||
if (str_starts_with($key, 'reason_')) {
|
|
||||||
$reasonCode = substr($key, strlen('reason_'));
|
|
||||||
} elseif (str_starts_with($key, 'blocked_reason_')) {
|
|
||||||
$reasonCode = substr($key, strlen('blocked_reason_'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
|
||||||
$translation = app(ReasonTranslator::class)->translate($reasonCode, surface: 'summary_line');
|
|
||||||
|
|
||||||
if ($translation !== null) {
|
|
||||||
return 'Reason: '.$translation->operatorLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($key) {
|
return match ($key) {
|
||||||
'total' => 'Total',
|
'total' => 'Total',
|
||||||
'processed' => 'Processed',
|
'processed' => 'Processed',
|
||||||
|
|||||||
@ -2,23 +2,91 @@
|
|||||||
|
|
||||||
namespace App\Support\Providers;
|
namespace App\Support\Providers;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
|
||||||
final class ProviderNextStepsRegistry
|
final class ProviderNextStepsRegistry
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly ReasonPresenter $reasonPresenter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{label: string, url: string}>
|
* @return array<int, array{label: string, url: string}>
|
||||||
*/
|
*/
|
||||||
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
|
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
|
||||||
{
|
{
|
||||||
$envelope = $this->reasonPresenter->forProviderReason($tenant, $reasonCode, $connection, 'helper_copy');
|
return match ($reasonCode) {
|
||||||
|
ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
return $envelope?->toLegacyNextSteps() ?? [];
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
ProviderReasonCodes::TenantTargetMismatch,
|
||||||
|
ProviderReasonCodes::PlatformIdentityMissing,
|
||||||
|
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||||
|
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
||||||
|
[
|
||||||
|
'label' => $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Review effective app details',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
|
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
||||||
|
[
|
||||||
|
'label' => $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::ProviderCredentialMissing,
|
||||||
|
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||||
|
ProviderReasonCodes::ProviderConsentFailed,
|
||||||
|
ProviderReasonCodes::ProviderConsentRevoked,
|
||||||
|
ProviderReasonCodes::ProviderAuthFailed,
|
||||||
|
ProviderReasonCodes::ProviderConsentMissing => [
|
||||||
|
[
|
||||||
|
'label' => 'Grant admin consent',
|
||||||
|
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => $connection instanceof ProviderConnection
|
||||||
|
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
||||||
|
: 'Manage Provider Connections',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
|
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||||
|
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
||||||
|
[
|
||||||
|
'label' => 'Open Required Permissions',
|
||||||
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::NetworkUnreachable,
|
||||||
|
ProviderReasonCodes::RateLimited,
|
||||||
|
ProviderReasonCodes::UnknownError => [
|
||||||
|
[
|
||||||
|
'label' => 'Review Provider Connection',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
[
|
||||||
|
'label' => 'Manage Provider Connections',
|
||||||
|
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,364 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Providers;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
final class ProviderReasonTranslator implements TranslatesReasonCode
|
|
||||||
{
|
|
||||||
public const string ARTIFACT_KEY = 'provider_reason_codes';
|
|
||||||
|
|
||||||
public function artifactKey(): string
|
|
||||||
{
|
|
||||||
return self::ARTIFACT_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canTranslate(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
return ProviderReasonCodes::isKnown(trim($reasonCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$reasonCode = trim($reasonCode);
|
|
||||||
|
|
||||||
if ($reasonCode === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedCode = ProviderReasonCodes::isKnown($reasonCode)
|
|
||||||
? $reasonCode
|
|
||||||
: ProviderReasonCodes::UnknownError;
|
|
||||||
$tenant = $context['tenant'] ?? null;
|
|
||||||
$connection = $context['connection'] ?? null;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
$nextSteps = $this->fallbackNextSteps($normalizedCode);
|
|
||||||
} else {
|
|
||||||
$nextSteps = $this->nextStepsFor($tenant, $normalizedCode, $connection instanceof ProviderConnection ? $connection : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($normalizedCode) {
|
|
||||||
ProviderReasonCodes::ProviderConnectionMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Provider connection required',
|
|
||||||
shortExplanation: 'This tenant does not have a usable provider connection for Microsoft operations.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Provider connection needs review',
|
|
||||||
shortExplanation: 'The selected provider connection is incomplete or no longer valid for this workflow.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderCredentialMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Credentials missing',
|
|
||||||
shortExplanation: 'The provider connection is missing the credentials required to authenticate.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderCredentialInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Credentials need review',
|
|
||||||
shortExplanation: 'Stored provider credentials are no longer valid for the selected provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConnectionTypeInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Connection type unsupported',
|
|
||||||
shortExplanation: 'The selected provider connection type cannot be used for this workflow.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::PlatformIdentityMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Platform identity missing',
|
|
||||||
shortExplanation: 'The platform provider connection is missing the app identity details required to continue.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::PlatformIdentityIncomplete => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Platform identity incomplete',
|
|
||||||
shortExplanation: 'The platform provider connection needs more app identity details before it can continue.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::DedicatedCredentialMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Dedicated credentials required',
|
|
||||||
shortExplanation: 'This dedicated provider connection cannot continue until dedicated credentials are configured.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::DedicatedCredentialInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Dedicated credentials need review',
|
|
||||||
shortExplanation: 'The dedicated credentials are no longer valid for this provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Admin consent required',
|
|
||||||
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConsentFailed => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Admin consent check failed',
|
|
||||||
shortExplanation: 'TenantPilot could not confirm admin consent for this provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConsentRevoked => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Admin consent revoked',
|
|
||||||
shortExplanation: 'Previously granted admin consent is no longer valid for this provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConnectionReviewRequired => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Connection classification needs review',
|
|
||||||
shortExplanation: 'TenantPilot needs you to confirm how this provider connection should be used.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Provider authentication failed',
|
|
||||||
shortExplanation: 'The provider connection could not authenticate with the stored credentials.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderPermissionMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Permissions missing',
|
|
||||||
shortExplanation: 'The provider app is missing required Microsoft Graph permissions.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderPermissionDenied => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Permission denied',
|
|
||||||
shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Permission refresh failed',
|
|
||||||
shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.',
|
|
||||||
actionability: 'retryable_transient',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC permission missing',
|
|
||||||
shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::TenantTargetMismatch => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Connection targets a different tenant',
|
|
||||||
shortExplanation: 'The selected provider connection points to a different Microsoft tenant than the current scope.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::NetworkUnreachable => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Microsoft Graph temporarily unreachable',
|
|
||||||
shortExplanation: 'TenantPilot could not reach Microsoft Graph or the provider dependency.',
|
|
||||||
actionability: 'retryable_transient',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::RateLimited => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Request rate limited',
|
|
||||||
shortExplanation: 'Microsoft Graph asked TenantPilot to slow down before retrying.',
|
|
||||||
actionability: 'retryable_transient',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacNotConfigured => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC not configured',
|
|
||||||
shortExplanation: 'Intune RBAC has not been configured for this tenant yet.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacUnhealthy => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC health degraded',
|
|
||||||
shortExplanation: 'The latest Intune RBAC health check found a blocking issue.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacStale => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC check is stale',
|
|
||||||
shortExplanation: 'The latest Intune RBAC health check is too old to trust for write operations.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
default => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: str_starts_with($normalizedCode, 'ext.')
|
|
||||||
? 'Provider configuration needs review'
|
|
||||||
: 'Provider check needs review',
|
|
||||||
shortExplanation: 'TenantPilot recorded a provider error that does not yet have a domain-specific translation.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, NextStepOption> $nextSteps
|
|
||||||
*/
|
|
||||||
private function envelope(
|
|
||||||
string $reasonCode,
|
|
||||||
string $operatorLabel,
|
|
||||||
string $shortExplanation,
|
|
||||||
string $actionability,
|
|
||||||
array $nextSteps,
|
|
||||||
): ReasonResolutionEnvelope {
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $reasonCode,
|
|
||||||
operatorLabel: $operatorLabel,
|
|
||||||
shortExplanation: $shortExplanation,
|
|
||||||
actionability: $actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: false,
|
|
||||||
diagnosticCodeLabel: $reasonCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
private function fallbackNextSteps(string $reasonCode): array
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
ProviderReasonCodes::NetworkUnreachable, ProviderReasonCodes::RateLimited => [
|
|
||||||
NextStepOption::instruction('Retry after the provider dependency recovers.'),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::UnknownError => [
|
|
||||||
NextStepOption::instruction('Review the provider connection and retry once the cause is understood.'),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::instruction('Review the provider connection before retrying.'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
private function nextStepsFor(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $reasonCode,
|
|
||||||
?ProviderConnection $connection = null,
|
|
||||||
): array {
|
|
||||||
return match ($reasonCode) {
|
|
||||||
ProviderReasonCodes::ProviderConnectionMissing,
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
||||||
ProviderReasonCodes::TenantTargetMismatch,
|
|
||||||
ProviderReasonCodes::PlatformIdentityMissing,
|
|
||||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
|
||||||
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Review effective app details',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
|
||||||
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::ProviderCredentialMissing,
|
|
||||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
|
||||||
ProviderReasonCodes::ProviderConsentFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentRevoked,
|
|
||||||
ProviderReasonCodes::ProviderAuthFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Grant admin consent',
|
|
||||||
destination: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
|
||||||
),
|
|
||||||
NextStepOption::link(
|
|
||||||
label: $connection instanceof ProviderConnection
|
|
||||||
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
|
||||||
: 'Manage Provider Connections',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
ProviderReasonCodes::ProviderPermissionDenied,
|
|
||||||
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
||||||
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Open Required Permissions',
|
|
||||||
destination: RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::IntuneRbacNotConfigured,
|
|
||||||
ProviderReasonCodes::IntuneRbacUnhealthy,
|
|
||||||
ProviderReasonCodes::IntuneRbacStale => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Review provider connections',
|
|
||||||
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::NetworkUnreachable,
|
|
||||||
ProviderReasonCodes::RateLimited => [
|
|
||||||
NextStepOption::instruction('Retry after the provider dependency recovers.', scope: 'tenant'),
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Review provider connection',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Manage Provider Connections',
|
|
||||||
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum RbacReason: string
|
enum RbacReason: string
|
||||||
{
|
{
|
||||||
case MissingArtifacts = 'missing_artifacts';
|
case MissingArtifacts = 'missing_artifacts';
|
||||||
@ -17,81 +14,4 @@ enum RbacReason: string
|
|||||||
case CanaryFailed = 'canary_failed';
|
case CanaryFailed = 'canary_failed';
|
||||||
case ManualAssignmentRequired = 'manual_assignment_required';
|
case ManualAssignmentRequired = 'manual_assignment_required';
|
||||||
case UnsupportedApi = 'unsupported_api';
|
case UnsupportedApi = 'unsupported_api';
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::MissingArtifacts => 'RBAC setup incomplete',
|
|
||||||
self::ServicePrincipalMissing => 'Service principal missing',
|
|
||||||
self::GroupMissing => 'RBAC group missing',
|
|
||||||
self::ServicePrincipalNotMember => 'Service principal not in RBAC group',
|
|
||||||
self::AssignmentMissing => 'RBAC assignment missing',
|
|
||||||
self::RoleMismatch => 'RBAC role mismatch',
|
|
||||||
self::ScopeMismatch => 'RBAC scope mismatch',
|
|
||||||
self::CanaryFailed => 'RBAC validation needs review',
|
|
||||||
self::ManualAssignmentRequired => 'Manual role assignment required',
|
|
||||||
self::UnsupportedApi => 'RBAC API unsupported',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::MissingArtifacts => 'TenantPilot could not find the RBAC artifacts required for this tenant.',
|
|
||||||
self::ServicePrincipalMissing => 'The provider app service principal could not be resolved in Microsoft Graph.',
|
|
||||||
self::GroupMissing => 'The configured Intune RBAC group could not be found.',
|
|
||||||
self::ServicePrincipalNotMember => 'The provider app service principal is not currently a member of the configured RBAC group.',
|
|
||||||
self::AssignmentMissing => 'No matching Intune RBAC assignment could be confirmed for this tenant.',
|
|
||||||
self::RoleMismatch => 'The existing Intune RBAC assignment uses a different role than expected.',
|
|
||||||
self::ScopeMismatch => 'The existing Intune RBAC assignment targets a different scope than expected.',
|
|
||||||
self::CanaryFailed => 'The RBAC canary checks reported a mismatch after setup completed.',
|
|
||||||
self::ManualAssignmentRequired => 'This tenant requires a manual Intune RBAC role assignment outside the automated API path.',
|
|
||||||
self::UnsupportedApi => 'This account type does not support the required Intune RBAC API path.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::CanaryFailed => 'retryable_transient',
|
|
||||||
self::ManualAssignmentRequired => 'prerequisite_missing',
|
|
||||||
self::UnsupportedApi => 'non_actionable',
|
|
||||||
default => 'prerequisite_missing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::UnsupportedApi => [],
|
|
||||||
self::ManualAssignmentRequired => [
|
|
||||||
NextStepOption::instruction('Complete the Intune role assignment manually, then refresh RBAC status.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::CanaryFailed => [
|
|
||||||
NextStepOption::instruction('Review the RBAC canary checks and rerun the health check.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::instruction('Review the RBAC setup and refresh the tenant RBAC status.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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: $this->actionability() === 'non_actionable',
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation\Contracts;
|
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
interface TranslatesReasonCode
|
|
||||||
{
|
|
||||||
public function artifactKey(): string;
|
|
||||||
|
|
||||||
public function canTranslate(string $reasonCode): bool;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope;
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
final class FallbackReasonTranslator implements TranslatesReasonCode
|
|
||||||
{
|
|
||||||
public const string ARTIFACT_KEY = 'fallback_reason_code';
|
|
||||||
|
|
||||||
public function artifactKey(): string
|
|
||||||
{
|
|
||||||
return self::ARTIFACT_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canTranslate(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
return trim($reasonCode) !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$normalizedCode = trim($reasonCode);
|
|
||||||
|
|
||||||
if ($normalizedCode === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actionability = $this->actionabilityFor($normalizedCode);
|
|
||||||
$nextSteps = $this->fallbackNextStepsFor($actionability);
|
|
||||||
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $normalizedCode,
|
|
||||||
operatorLabel: $this->operatorLabelFor($normalizedCode),
|
|
||||||
shortExplanation: $this->shortExplanationFor($actionability),
|
|
||||||
actionability: $actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: $actionability === 'non_actionable',
|
|
||||||
diagnosticCodeLabel: $normalizedCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function operatorLabelFor(string $reasonCode): string
|
|
||||||
{
|
|
||||||
return Str::headline(str_replace(['.', '-'], '_', $reasonCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function actionabilityFor(string $reasonCode): string
|
|
||||||
{
|
|
||||||
$reasonCode = strtolower($reasonCode);
|
|
||||||
|
|
||||||
if (str_contains($reasonCode, 'timeout')
|
|
||||||
|| str_contains($reasonCode, 'throttle')
|
|
||||||
|| str_contains($reasonCode, 'rate')
|
|
||||||
|| str_contains($reasonCode, 'network')
|
|
||||||
|| str_contains($reasonCode, 'unreachable')
|
|
||||||
|| str_contains($reasonCode, 'transient')
|
|
||||||
|| str_contains($reasonCode, 'retry')
|
|
||||||
) {
|
|
||||||
return 'retryable_transient';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($reasonCode, 'missing')
|
|
||||||
|| str_contains($reasonCode, 'required')
|
|
||||||
|| str_contains($reasonCode, 'consent')
|
|
||||||
|| str_contains($reasonCode, 'stale')
|
|
||||||
|| str_contains($reasonCode, 'prerequisite')
|
|
||||||
|| str_contains($reasonCode, 'invalid')
|
|
||||||
) {
|
|
||||||
return 'prerequisite_missing';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($reasonCode, 'already_')
|
|
||||||
|| str_contains($reasonCode, 'not_applicable')
|
|
||||||
|| str_contains($reasonCode, 'no_action')
|
|
||||||
|| str_contains($reasonCode, 'info')
|
|
||||||
) {
|
|
||||||
return 'non_actionable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'permanent_configuration';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function shortExplanationFor(string $actionability): string
|
|
||||||
{
|
|
||||||
return match ($actionability) {
|
|
||||||
'retryable_transient' => 'TenantPilot recorded a transient dependency issue. Retry after the dependency recovers.',
|
|
||||||
'prerequisite_missing' => 'TenantPilot recorded a missing or invalid prerequisite for this workflow.',
|
|
||||||
'non_actionable' => 'TenantPilot recorded this state for visibility only. No operator action is required.',
|
|
||||||
default => 'TenantPilot recorded an access, scope, or configuration issue that needs review before retrying.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
private function fallbackNextStepsFor(string $actionability): array
|
|
||||||
{
|
|
||||||
return match ($actionability) {
|
|
||||||
'retryable_transient' => [NextStepOption::instruction('Retry after the dependency recovers.')],
|
|
||||||
'prerequisite_missing' => [NextStepOption::instruction('Review the recorded prerequisite before retrying.')],
|
|
||||||
'non_actionable' => [],
|
|
||||||
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final readonly class NextStepOption
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $label,
|
|
||||||
public string $kind,
|
|
||||||
public ?string $destination = null,
|
|
||||||
public bool $authorizationRequired = false,
|
|
||||||
public string $scope = 'none',
|
|
||||||
) {
|
|
||||||
$label = trim($this->label);
|
|
||||||
$kind = trim($this->kind);
|
|
||||||
$scope = trim($this->scope);
|
|
||||||
|
|
||||||
if ($label === '') {
|
|
||||||
throw new InvalidArgumentException('Next-step labels must not be empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($kind, ['link', 'instruction', 'diagnostic_only'], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported next-step kind: '.$kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($scope, ['tenant', 'workspace', 'system', 'none'], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported next-step scope: '.$scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($kind === 'link' && trim((string) $this->destination) === '') {
|
|
||||||
throw new InvalidArgumentException('Link next steps require a destination.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function link(
|
|
||||||
string $label,
|
|
||||||
string $destination,
|
|
||||||
bool $authorizationRequired = true,
|
|
||||||
string $scope = 'tenant',
|
|
||||||
): self {
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: 'link',
|
|
||||||
destination: $destination,
|
|
||||||
authorizationRequired: $authorizationRequired,
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function instruction(string $label, string $scope = 'none'): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: 'instruction',
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function diagnosticOnly(string $label): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: 'diagnostic_only',
|
|
||||||
scope: 'none',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function fromArray(array $data): ?self
|
|
||||||
{
|
|
||||||
$label = is_string($data['label'] ?? null) ? trim((string) $data['label']) : '';
|
|
||||||
$kind = is_string($data['kind'] ?? null)
|
|
||||||
? trim((string) $data['kind'])
|
|
||||||
: ((is_string($data['url'] ?? null) || is_string($data['destination'] ?? null)) ? 'link' : 'instruction');
|
|
||||||
$destination = is_string($data['destination'] ?? null)
|
|
||||||
? trim((string) $data['destination'])
|
|
||||||
: (is_string($data['url'] ?? null) ? trim((string) $data['url']) : null);
|
|
||||||
$authorizationRequired = (bool) ($data['authorization_required'] ?? $data['authorizationRequired'] ?? false);
|
|
||||||
$scope = is_string($data['scope'] ?? null) ? trim((string) $data['scope']) : 'none';
|
|
||||||
|
|
||||||
if ($label === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: $kind,
|
|
||||||
destination: $destination !== '' ? $destination : null,
|
|
||||||
authorizationRequired: $authorizationRequired,
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array<string, mixed>> $items
|
|
||||||
* @return array<int, self>
|
|
||||||
*/
|
|
||||||
public static function collect(array $items): array
|
|
||||||
{
|
|
||||||
$options = [];
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if (! is_array($item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$option = self::fromArray($item);
|
|
||||||
|
|
||||||
if ($option instanceof self) {
|
|
||||||
$options[] = $option;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* destination: ?string,
|
|
||||||
* authorization_required: bool,
|
|
||||||
* scope: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'label' => $this->label,
|
|
||||||
'kind' => $this->kind,
|
|
||||||
'destination' => $this->destination,
|
|
||||||
'authorization_required' => $this->authorizationRequired,
|
|
||||||
'scope' => $this->scope,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}
|
|
||||||
*/
|
|
||||||
public function toLegacyArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'label' => $this->label,
|
|
||||||
'url' => (string) $this->destination,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
|
|
||||||
final class ReasonPresenter
|
|
||||||
{
|
|
||||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ReasonTranslator $reasonTranslator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
$storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
|
|
||||||
|
|
||||||
if ($storedTranslation !== null) {
|
|
||||||
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
|
||||||
|
|
||||||
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
|
||||||
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
|
|
||||||
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $storedEnvelope;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
|
||||||
?? data_get($context, 'reason_code');
|
|
||||||
|
|
||||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
|
||||||
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureReasonCode = data_get($run->failure_summary, '0.reason_code');
|
|
||||||
|
|
||||||
if (! is_string($failureReasonCode) || trim($failureReasonCode) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureReasonCode = trim($failureReasonCode);
|
|
||||||
|
|
||||||
if (! $this->isDirectlyTranslatableOperationReason($failureReasonCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$envelope = $this->translateOperationRunReason($failureReasonCode, $surface, $context);
|
|
||||||
|
|
||||||
if (! $envelope instanceof ReasonResolutionEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($envelope->nextSteps !== []) {
|
|
||||||
return $envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
|
||||||
|
|
||||||
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function translateOperationRunReason(
|
|
||||||
string $reasonCode,
|
|
||||||
string $surface,
|
|
||||||
array $context,
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
return $this->reasonTranslator->translate($reasonCode, surface: $surface, context: $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
if ($reasonCode === ProviderReasonCodes::UnknownError) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
|
||||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
|
||||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
|
||||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
|
||||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forProviderReason(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $reasonCode,
|
|
||||||
?ProviderConnection $connection = null,
|
|
||||||
string $surface = 'detail',
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
artifactKey: ProviderReasonTranslator::ARTIFACT_KEY,
|
|
||||||
surface: $surface,
|
|
||||||
context: [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'connection' => $connection,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forTenantOperabilityReason(
|
|
||||||
TenantOperabilityReasonCode|string|null $reasonCode,
|
|
||||||
string $surface = 'detail',
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
$normalizedCode = $reasonCode instanceof TenantOperabilityReasonCode ? $reasonCode->value : $reasonCode;
|
|
||||||
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
artifactKey: ReasonTranslator::TENANT_OPERABILITY_ARTIFACT,
|
|
||||||
surface: $surface,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forRbacReason(RbacReason|string|null $reasonCode, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$normalizedCode = $reasonCode instanceof RbacReason ? $reasonCode->value : $reasonCode;
|
|
||||||
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
artifactKey: ReasonTranslator::RBAC_ARTIFACT,
|
|
||||||
surface: $surface,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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
|
|
||||||
{
|
|
||||||
return $envelope?->diagnosticCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function primaryLabel(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->operatorLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->guidanceText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function bodyLines(?ReasonResolutionEnvelope $envelope, bool $includeGuidance = true): array
|
|
||||||
{
|
|
||||||
return $envelope?->toBodyLines($includeGuidance) ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final readonly class ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<int, NextStepOption> $nextSteps
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public string $internalCode,
|
|
||||||
public string $operatorLabel,
|
|
||||||
public string $shortExplanation,
|
|
||||||
public string $actionability,
|
|
||||||
public array $nextSteps = [],
|
|
||||||
public bool $showNoActionNeeded = false,
|
|
||||||
public ?string $diagnosticCodeLabel = null,
|
|
||||||
) {
|
|
||||||
if (trim($this->internalCode) === '') {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trim($this->operatorLabel) === '') {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes require an operator label.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trim($this->shortExplanation) === '') {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes require a short explanation.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($this->actionability, [
|
|
||||||
'retryable_transient',
|
|
||||||
'permanent_configuration',
|
|
||||||
'prerequisite_missing',
|
|
||||||
'non_actionable',
|
|
||||||
], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->nextSteps as $nextStep) {
|
|
||||||
if (! $nextStep instanceof NextStepOption) {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function fromArray(array $data): ?self
|
|
||||||
{
|
|
||||||
$internalCode = is_string($data['internal_code'] ?? null)
|
|
||||||
? trim((string) $data['internal_code'])
|
|
||||||
: (is_string($data['internalCode'] ?? null) ? trim((string) $data['internalCode']) : '');
|
|
||||||
$operatorLabel = is_string($data['operator_label'] ?? null)
|
|
||||||
? trim((string) $data['operator_label'])
|
|
||||||
: (is_string($data['operatorLabel'] ?? null) ? trim((string) $data['operatorLabel']) : '');
|
|
||||||
$shortExplanation = is_string($data['short_explanation'] ?? null)
|
|
||||||
? trim((string) $data['short_explanation'])
|
|
||||||
: (is_string($data['shortExplanation'] ?? null) ? trim((string) $data['shortExplanation']) : '');
|
|
||||||
$actionability = is_string($data['actionability'] ?? null) ? trim((string) $data['actionability']) : '';
|
|
||||||
$nextSteps = is_array($data['next_steps'] ?? null)
|
|
||||||
? NextStepOption::collect($data['next_steps'])
|
|
||||||
: (is_array($data['nextSteps'] ?? null) ? NextStepOption::collect($data['nextSteps']) : []);
|
|
||||||
$showNoActionNeeded = (bool) ($data['show_no_action_needed'] ?? $data['showNoActionNeeded'] ?? false);
|
|
||||||
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
|
||||||
? trim((string) $data['diagnostic_code_label'])
|
|
||||||
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
|
|
||||||
|
|
||||||
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
internalCode: $internalCode,
|
|
||||||
operatorLabel: $operatorLabel,
|
|
||||||
shortExplanation: $shortExplanation,
|
|
||||||
actionability: $actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: $showNoActionNeeded,
|
|
||||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, NextStepOption> $nextSteps
|
|
||||||
*/
|
|
||||||
public function withNextSteps(array $nextSteps): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
internalCode: $this->internalCode,
|
|
||||||
operatorLabel: $this->operatorLabel,
|
|
||||||
shortExplanation: $this->shortExplanation,
|
|
||||||
actionability: $this->actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: $this->showNoActionNeeded,
|
|
||||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function firstNextStep(): ?NextStepOption
|
|
||||||
{
|
|
||||||
return $this->nextSteps[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function guidanceText(): ?string
|
|
||||||
{
|
|
||||||
$nextStep = $this->firstNextStep();
|
|
||||||
|
|
||||||
if ($nextStep instanceof NextStepOption) {
|
|
||||||
return 'Next step: '.rtrim($nextStep->label, ". \t\n\r\0\x0B").'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->showNoActionNeeded) {
|
|
||||||
return 'No action needed.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function toBodyLines(bool $includeGuidance = true): array
|
|
||||||
{
|
|
||||||
$lines = [
|
|
||||||
$this->operatorLabel,
|
|
||||||
$this->shortExplanation,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($includeGuidance) {
|
|
||||||
$guidance = $this->guidanceText();
|
|
||||||
|
|
||||||
if (is_string($guidance) && $guidance !== '') {
|
|
||||||
$lines[] = $guidance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter($lines, static fn (?string $line): bool => is_string($line) && trim($line) !== ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function diagnosticCode(): string
|
|
||||||
{
|
|
||||||
return $this->diagnosticCodeLabel !== null && trim($this->diagnosticCodeLabel) !== ''
|
|
||||||
? $this->diagnosticCodeLabel
|
|
||||||
: $this->internalCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array{label: string, url: string}>
|
|
||||||
*/
|
|
||||||
public function toLegacyNextSteps(): array
|
|
||||||
{
|
|
||||||
return array_values(array_map(
|
|
||||||
static fn (NextStepOption $nextStep): array => $nextStep->toLegacyArray(),
|
|
||||||
array_filter(
|
|
||||||
$this->nextSteps,
|
|
||||||
static fn (NextStepOption $nextStep): bool => $nextStep->kind === 'link' && $nextStep->destination !== null,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* internal_code: string,
|
|
||||||
* operator_label: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* actionability: string,
|
|
||||||
* next_steps: array<int, array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* destination: ?string,
|
|
||||||
* authorization_required: bool,
|
|
||||||
* scope: string
|
|
||||||
* }>,
|
|
||||||
* show_no_action_needed: bool,
|
|
||||||
* diagnostic_code_label: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'internal_code' => $this->internalCode,
|
|
||||||
'operator_label' => $this->operatorLabel,
|
|
||||||
'short_explanation' => $this->shortExplanation,
|
|
||||||
'actionability' => $this->actionability,
|
|
||||||
'next_steps' => array_map(
|
|
||||||
static fn (NextStepOption $nextStep): array => $nextStep->toArray(),
|
|
||||||
$this->nextSteps,
|
|
||||||
),
|
|
||||||
'show_no_action_needed' => $this->showNoActionNeeded,
|
|
||||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
|
|
||||||
final class ReasonTranslator
|
|
||||||
{
|
|
||||||
public const string EXECUTION_DENIAL_ARTIFACT = 'execution_denial_reason_code';
|
|
||||||
|
|
||||||
public const string TENANT_OPERABILITY_ARTIFACT = 'tenant_operability_reason_code';
|
|
||||||
|
|
||||||
public const string RBAC_ARTIFACT = 'rbac_reason';
|
|
||||||
|
|
||||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = 'governance_artifact_truth_reason';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
|
||||||
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(
|
|
||||||
?string $reasonCode,
|
|
||||||
?string $artifactKey = null,
|
|
||||||
string $surface = 'detail',
|
|
||||||
array $context = [],
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : '';
|
|
||||||
|
|
||||||
if ($reasonCode === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
|
||||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
|
||||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
|
||||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
|
||||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
|
||||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
|
||||||
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
|
||||||
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
|
|
||||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
|
||||||
$artifactKey === self::RBAC_ARTIFACT,
|
|
||||||
$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),
|
|
||||||
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function fallbackTranslate(
|
|
||||||
string $reasonCode,
|
|
||||||
?string $artifactKey,
|
|
||||||
string $surface,
|
|
||||||
array $context,
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
if ($artifactKey === null) {
|
|
||||||
$normalizedCode = \App\Support\OpsUx\RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
|
||||||
|
|
||||||
if ($normalizedCode !== $reasonCode) {
|
|
||||||
return $this->translate($normalizedCode, null, $surface, $context + ['source_reason_code' => $reasonCode]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Tenants;
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum TenantOperabilityReasonCode: string
|
enum TenantOperabilityReasonCode: string
|
||||||
{
|
{
|
||||||
case WorkspaceMismatch = 'workspace_mismatch';
|
case WorkspaceMismatch = 'workspace_mismatch';
|
||||||
@ -19,89 +16,4 @@ enum TenantOperabilityReasonCode: string
|
|||||||
case OnboardingNotResumable = 'onboarding_not_resumable';
|
case OnboardingNotResumable = 'onboarding_not_resumable';
|
||||||
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
||||||
case RememberedContextStale = 'remembered_context_stale';
|
case RememberedContextStale = 'remembered_context_stale';
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'Workspace context changed',
|
|
||||||
self::TenantNotEntitled => 'Tenant access removed',
|
|
||||||
self::MissingCapability => 'Permission required',
|
|
||||||
self::WrongLane => 'Available from a different surface',
|
|
||||||
self::SelectorIneligibleLifecycle => 'Tenant unavailable in the current lifecycle',
|
|
||||||
self::TenantNotArchived => 'Tenant is not archived',
|
|
||||||
self::TenantAlreadyArchived => 'Tenant already archived',
|
|
||||||
self::OnboardingNotResumable => 'Onboarding cannot be resumed',
|
|
||||||
self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context',
|
|
||||||
self::RememberedContextStale => 'Saved tenant context is stale',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'The current workspace scope no longer matches this tenant interaction.',
|
|
||||||
self::TenantNotEntitled => 'The current actor is no longer entitled to this tenant.',
|
|
||||||
self::MissingCapability => 'The current actor is missing the capability required for this tenant action.',
|
|
||||||
self::WrongLane => 'This question can only be completed from a different tenant interaction lane.',
|
|
||||||
self::SelectorIneligibleLifecycle => 'This tenant lifecycle is not selectable from the current surface.',
|
|
||||||
self::TenantNotArchived => 'This action requires an archived tenant, but the tenant is still active or onboarding.',
|
|
||||||
self::TenantAlreadyArchived => 'The tenant is already archived, so there is nothing else to do for this action.',
|
|
||||||
self::OnboardingNotResumable => 'This onboarding session can no longer be resumed from the current lifecycle state.',
|
|
||||||
self::CanonicalViewFollowupOnly => 'This canonical workspace view is informational only and cannot complete tenant follow-up directly.',
|
|
||||||
self::RememberedContextStale => 'The remembered tenant context is no longer valid for the current tenant selector state.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::TenantAlreadyArchived => 'non_actionable',
|
|
||||||
self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale => 'prerequisite_missing',
|
|
||||||
default => 'permanent_configuration',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::TenantAlreadyArchived => [],
|
|
||||||
self::MissingCapability => [
|
|
||||||
NextStepOption::instruction('Ask a tenant Owner to grant the required capability.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::TenantNotEntitled, self::WorkspaceMismatch => [
|
|
||||||
NextStepOption::instruction('Return to an entitled tenant context before retrying.', scope: 'workspace'),
|
|
||||||
],
|
|
||||||
self::WrongLane, self::CanonicalViewFollowupOnly => [
|
|
||||||
NextStepOption::instruction('Open the tenant-specific management surface for follow-up.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::SelectorIneligibleLifecycle, self::RememberedContextStale => [
|
|
||||||
NextStepOption::instruction('Refresh the tenant selector and choose an eligible tenant context.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::TenantNotArchived => [
|
|
||||||
NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::OnboardingNotResumable => [
|
|
||||||
NextStepOption::instruction('Review the onboarding record and start a new onboarding flow if needed.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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: $this->actionability() === 'non_actionable',
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,790 +0,0 @@
|
|||||||
<?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', 600),
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -48,7 +48,7 @@
|
|||||||
'driver' => 'beanstalkd',
|
'driver' => 'beanstalkd',
|
||||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 600),
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
'block_for' => 0,
|
'block_for' => 0,
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
@ -68,7 +68,7 @@
|
|||||||
'driver' => 'redis',
|
'driver' => 'redis',
|
||||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
'queue' => env('REDIS_QUEUE', 'default'),
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 600),
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
'block_for' => null,
|
'block_for' => null,
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
@ -126,8 +126,4 @@
|
|||||||
'table' => 'failed_jobs',
|
'table' => 'failed_jobs',
|
||||||
],
|
],
|
||||||
|
|
||||||
'lifecycle_invariants' => [
|
|
||||||
'retry_after_safety_margin' => (int) env('QUEUE_RETRY_AFTER_SAFETY_MARGIN', 30),
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user