Compare commits
1 Commits
dev
...
178-ops-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4722e06288 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -133,6 +133,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
|
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
|
||||||
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
|
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment)
|
||||||
|
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -152,8 +154,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 178-ops-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
|
||||||
- 177-inventory-coverage-truth: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
|
- 177-inventory-coverage-truth: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
|
||||||
- 179-provider-truth-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials
|
- 179-provider-truth-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials
|
||||||
- 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -37,7 +37,6 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -527,7 +526,7 @@ public function basisRunSummary(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
||||||
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
|
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
|
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
|
||||||
|
|||||||
@ -233,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
'active' => $query->healthyActive(),
|
'active' => $query->healthyActive(),
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
|
||||||
'blocked' => $query->dashboardNeedsFollowUp(),
|
'blocked' => $query->dashboardNeedsFollowUp(),
|
||||||
'succeeded' => $query
|
'succeeded' => $query
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
@ -281,9 +283,29 @@ private function applyRequestedDashboardPrefilter(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$requestedProblemClass = request()->query('problemClass');
|
||||||
|
|
||||||
|
if (in_array($requestedProblemClass, [
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
], true)) {
|
||||||
|
$this->activeTab = (string) $requestedProblemClass;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$requestedTab = request()->query('activeTab');
|
$requestedTab = request()->query('activeTab');
|
||||||
|
|
||||||
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) {
|
if (in_array($requestedTab, [
|
||||||
|
'all',
|
||||||
|
'active',
|
||||||
|
'blocked',
|
||||||
|
'succeeded',
|
||||||
|
'partial',
|
||||||
|
'failed',
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
], true)) {
|
||||||
$this->activeTab = (string) $requestedTab;
|
$this->activeTab = (string) $requestedTab;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -784,7 +784,7 @@ private static function artifactTruthFact(
|
|||||||
|
|
||||||
private static function decisionAttentionNote(OperationRun $record): ?string
|
private static function decisionAttentionNote(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
return null;
|
return OperationUxPresenter::decisionAttentionNote($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
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;
|
||||||
@ -113,16 +114,46 @@ public function table(Table $table): Table
|
|||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
'status' => (string) $record->status,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||||
TextColumn::make('outcome')
|
TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
'outcome' => (string) $record->outcome,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
'status' => (string) $record->status,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
TextColumn::make('type')
|
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))
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
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;
|
||||||
@ -94,16 +95,46 @@ public function table(Table $table): Table
|
|||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
'status' => (string) $record->status,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||||
TextColumn::make('outcome')
|
TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
'outcome' => (string) $record->outcome,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
'status' => (string) $record->status,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
TextColumn::make('type')
|
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))
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\StuckRunClassifier;
|
use App\Support\SystemConsole\StuckRunClassifier;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
@ -112,10 +113,23 @@ public function table(Table $table): Table
|
|||||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
'status' => (string) $record->status,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
|
->description(null),
|
||||||
TextColumn::make('stuck_class')
|
TextColumn::make('stuck_class')
|
||||||
->label('Stuck class')
|
->label('Stuck class')
|
||||||
->state(function (OperationRun $record): string {
|
->state(function (OperationRun $record): string {
|
||||||
@ -126,6 +140,7 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('type')
|
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))
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('workspace.name')
|
TextColumn::make('workspace.name')
|
||||||
->label('Workspace')
|
->label('Workspace')
|
||||||
|
|||||||
@ -23,13 +23,7 @@ class DashboardKpis extends StatsOverviewWidget
|
|||||||
|
|
||||||
protected function getPollingInterval(): ?string
|
protected function getPollingInterval(): ?string
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,9 +54,14 @@ protected function getStats(): array
|
|||||||
->healthyActive()
|
->healthyActive()
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$followUpRuns = (int) OperationRun::query()
|
$staleActiveRuns = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->dashboardNeedsFollowUp()
|
->activeStaleAttention()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$terminalFollowUpRuns = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->terminalFollowUp()
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$openDriftUrl = $openDriftFindings > 0
|
$openDriftUrl = $openDriftFindings > 0
|
||||||
@ -96,10 +95,26 @@ protected function getStats(): array
|
|||||||
->description('healthy queued or running tenant work')
|
->description('healthy queued or running tenant work')
|
||||||
->color($activeRuns > 0 ? 'info' : 'gray')
|
->color($activeRuns > 0 ? 'info' : 'gray')
|
||||||
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
|
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
|
||||||
Stat::make('Operations needing follow-up', $followUpRuns)
|
Stat::make('Likely stale operations', $staleActiveRuns)
|
||||||
->description('failed, warning, or stalled runs')
|
->description('queued or running past the lifecycle window')
|
||||||
->color($followUpRuns > 0 ? 'danger' : 'gray')
|
->color($staleActiveRuns > 0 ? 'warning' : 'gray')
|
||||||
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
|
->url($staleActiveRuns > 0
|
||||||
|
? OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
Stat::make('Terminal follow-up operations', $terminalFollowUpRuns)
|
||||||
|
->description('blocked, partial, failed, or auto-reconciled runs')
|
||||||
|
->color($terminalFollowUpRuns > 0 ? 'danger' : 'gray')
|
||||||
|
->url($terminalFollowUpRuns > 0
|
||||||
|
? OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
)
|
||||||
|
: null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +127,8 @@ private function emptyStats(): array
|
|||||||
Stat::make('Open drift findings', 0),
|
Stat::make('Open drift findings', 0),
|
||||||
Stat::make('High severity active findings', 0),
|
Stat::make('High severity active findings', 0),
|
||||||
Stat::make('Active operations', 0),
|
Stat::make('Active operations', 0),
|
||||||
Stat::make('Operations needing follow-up', 0),
|
Stat::make('Likely stale operations', 0),
|
||||||
|
Stat::make('Terminal follow-up operations', 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,9 +48,13 @@ protected function getViewData(): array
|
|||||||
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||||
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||||
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||||
$operationsFollowUpCount = (int) OperationRun::query()
|
$staleActiveOperationsCount = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->dashboardNeedsFollowUp()
|
->activeStaleAttention()
|
||||||
|
->count();
|
||||||
|
$terminalFollowUpOperationsCount = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->terminalFollowUp()
|
||||||
->count();
|
->count();
|
||||||
$activeRuns = (int) OperationRun::query()
|
$activeRuns = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
@ -139,15 +143,35 @@ protected function getViewData(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($operationsFollowUpCount > 0) {
|
if ($staleActiveOperationsCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'key' => 'operations_follow_up',
|
'key' => 'operations_stale_attention',
|
||||||
'title' => 'Operations need follow-up',
|
'title' => 'Active operations look stale',
|
||||||
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
|
'body' => "{$staleActiveOperationsCount} run(s) are still marked active but are past the lifecycle window.",
|
||||||
|
'badge' => 'Operations',
|
||||||
|
'badgeColor' => 'warning',
|
||||||
|
'actionLabel' => 'Open stale operations',
|
||||||
|
'actionUrl' => OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($terminalFollowUpOperationsCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'key' => 'operations_terminal_follow_up',
|
||||||
|
'title' => 'Terminal operations need follow-up',
|
||||||
|
'body' => "{$terminalFollowUpOperationsCount} run(s) finished blocked, partially, failed, or were automatically reconciled.",
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
'actionLabel' => 'Open operations',
|
'actionLabel' => 'Open terminal follow-up',
|
||||||
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
'actionUrl' => OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,7 +208,7 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'healthyChecks' => $healthyChecks,
|
'healthyChecks' => $healthyChecks,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -29,7 +29,7 @@ public function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->heading('Recent Operations')
|
->heading('Recent Operations')
|
||||||
->query($this->getQuery())
|
->query($this->getQuery())
|
||||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
->poll(fn (): ?string => ActiveRuns::pollingIntervalForTenant($tenant instanceof Tenant ? $tenant : null))
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
|
||||||
->columns([
|
->columns([
|
||||||
@ -43,22 +43,52 @@ public function table(Table $table): Table
|
|||||||
->sortable()
|
->sortable()
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
->limit(40)
|
->limit(40)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
|
||||||
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
'status' => (string) $record->status,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
|
->description(null),
|
||||||
TextColumn::make('outcome')
|
TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->sortable()
|
->sortable()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
'outcome' => (string) $record->outcome,
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
'status' => (string) $record->status,
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->label)
|
||||||
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->color)
|
||||||
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->icon)
|
||||||
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
])->iconColor)
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->label('Started')
|
->label('Started')
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\HtmlString;
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class InventoryKpiHeader extends StatsOverviewWidget
|
class InventoryKpiHeader extends StatsOverviewWidget
|
||||||
@ -94,7 +93,7 @@ private function coverageBasisStat(TenantCoverageTruth $truth, Tenant $tenant):
|
|||||||
}
|
}
|
||||||
|
|
||||||
$outcomeBadge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
$outcomeBadge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
||||||
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
|
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
|
||||||
|
|
||||||
$description = Blade::render(<<<'BLADE'
|
$description = Blade::render(<<<'BLADE'
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@ -58,6 +58,8 @@ protected function getViewData(): array
|
|||||||
'type',
|
'type',
|
||||||
'status',
|
'status',
|
||||||
'outcome',
|
'outcome',
|
||||||
|
'context',
|
||||||
|
'failure_summary',
|
||||||
'created_at',
|
'created_at',
|
||||||
'started_at',
|
'started_at',
|
||||||
'completed_at',
|
'completed_at',
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* status_color: string,
|
* status_color: string,
|
||||||
* outcome_label: string,
|
* outcome_label: string,
|
||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
|
* lifecycle_label: ?string,
|
||||||
* guidance: ?string,
|
* guidance: ?string,
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
* destination: array<string, mixed>,
|
* destination: array<string, mixed>,
|
||||||
@ -50,6 +51,7 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* status_color: string,
|
* status_color: string,
|
||||||
* outcome_label: string,
|
* outcome_label: string,
|
||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
|
* lifecycle_label: ?string,
|
||||||
* guidance: ?string,
|
* guidance: ?string,
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
* destination: array<string, mixed>,
|
* destination: array<string, mixed>,
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -85,13 +86,13 @@ public function refreshRuns(): void
|
|||||||
|
|
||||||
$query = OperationRun::query()
|
$query = OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->active()
|
->healthyActive()
|
||||||
->orderByDesc('created_at');
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
$activeCount = (clone $query)->count();
|
$activeCount = (clone $query)->count();
|
||||||
$this->runs = (clone $query)->limit(6)->get();
|
$this->runs = (clone $query)->limit(6)->get();
|
||||||
$this->overflowCount = max(0, $activeCount - 5);
|
$this->overflowCount = max(0, $activeCount - 5);
|
||||||
$this->hasActiveRuns = $this->runs->isNotEmpty();
|
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): \Illuminate\Contracts\View\View
|
public function render(): \Illuminate\Contracts\View\View
|
||||||
|
|||||||
@ -18,6 +18,12 @@ class OperationRun extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
public const string PROBLEM_CLASS_NONE = 'none';
|
||||||
|
|
||||||
|
public const string PROBLEM_CLASS_ACTIVE_STALE_ATTENTION = 'active_stale_attention';
|
||||||
|
|
||||||
|
public const string PROBLEM_CLASS_TERMINAL_FOLLOW_UP = 'terminal_follow_up';
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -179,20 +185,34 @@ public function scopeDashboardNeedsFollowUp(Builder $query): Builder
|
|||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->where(function (Builder $terminalQuery): void {
|
->where(function (Builder $terminalQuery): void {
|
||||||
$terminalQuery
|
$terminalQuery->terminalFollowUp();
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereIn('outcome', [
|
|
||||||
OperationRunOutcome::Blocked->value,
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
OperationRunOutcome::Failed->value,
|
|
||||||
]);
|
|
||||||
})
|
})
|
||||||
->orWhere(function (Builder $activeQuery): void {
|
->orWhere(function (Builder $activeQuery): void {
|
||||||
$activeQuery->likelyStale();
|
$activeQuery->activeStaleAttention();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeActiveStaleAttention(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
||||||
|
{
|
||||||
|
return $query->likelyStale($policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeTerminalFollowUp(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereIn('outcome', [
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
])
|
||||||
|
->orWhereNotNull('context->reconciliation->reconciled_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function getSelectionHashAttribute(): ?string
|
public function getSelectionHashAttribute(): ?string
|
||||||
{
|
{
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
@ -340,17 +360,64 @@ public function freshnessState(): OperationRunFreshnessState
|
|||||||
return OperationRunFreshnessState::forRun($this);
|
return OperationRunFreshnessState::forRun($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function requiresDashboardFollowUp(): bool
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function problemClasses(): array
|
||||||
{
|
{
|
||||||
|
return [
|
||||||
|
self::PROBLEM_CLASS_NONE,
|
||||||
|
self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function problemClass(): string
|
||||||
|
{
|
||||||
|
$freshnessState = $this->freshnessState();
|
||||||
|
|
||||||
|
if ($freshnessState->isLikelyStale()) {
|
||||||
|
return self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($freshnessState->isReconciledFailed()) {
|
||||||
|
return self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP;
|
||||||
|
}
|
||||||
|
|
||||||
if ((string) $this->status === OperationRunStatus::Completed->value) {
|
if ((string) $this->status === OperationRunStatus::Completed->value) {
|
||||||
return in_array((string) $this->outcome, [
|
return in_array((string) $this->outcome, [
|
||||||
OperationRunOutcome::Blocked->value,
|
OperationRunOutcome::Blocked->value,
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
OperationRunOutcome::Failed->value,
|
OperationRunOutcome::Failed->value,
|
||||||
], true);
|
], true)
|
||||||
|
? self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||||
|
: self::PROBLEM_CLASS_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->freshnessState()->isLikelyStale();
|
return self::PROBLEM_CLASS_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasStaleLineage(): bool
|
||||||
|
{
|
||||||
|
return $this->freshnessState()->isReconciledFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCurrentlyActive(): bool
|
||||||
|
{
|
||||||
|
return in_array((string) $this->status, [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresOperatorReview(): bool
|
||||||
|
{
|
||||||
|
return $this->problemClass() !== self::PROBLEM_CLASS_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresDashboardFollowUp(): bool
|
||||||
|
{
|
||||||
|
return $this->requiresOperatorReview();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -80,6 +80,7 @@ public static function index(
|
|||||||
?CanonicalNavigationContext $context = null,
|
?CanonicalNavigationContext $context = null,
|
||||||
?string $activeTab = null,
|
?string $activeTab = null,
|
||||||
bool $allTenants = false,
|
bool $allTenants = false,
|
||||||
|
?string $problemClass = null,
|
||||||
): string {
|
): string {
|
||||||
$parameters = $context?->toQuery() ?? [];
|
$parameters = $context?->toQuery() ?? [];
|
||||||
|
|
||||||
@ -93,6 +94,18 @@ public static function index(
|
|||||||
$parameters['activeTab'] = $activeTab;
|
$parameters['activeTab'] = $activeTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
is_string($problemClass)
|
||||||
|
&& in_array($problemClass, OperationRun::problemClasses(), true)
|
||||||
|
&& $problemClass !== OperationRun::PROBLEM_CLASS_NONE
|
||||||
|
) {
|
||||||
|
$parameters['problemClass'] = $problemClass;
|
||||||
|
|
||||||
|
if (! is_string($activeTab) || $activeTab === '') {
|
||||||
|
$parameters['activeTab'] = $problemClass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return route('admin.operations.index', $parameters);
|
return route('admin.operations.index', $parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,9 +11,30 @@ final class ActiveRuns
|
|||||||
{
|
{
|
||||||
public static function existForTenant(Tenant $tenant): bool
|
public static function existForTenant(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
|
return self::existForTenantId((int) $tenant->getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function existForTenantId(?int $tenantId): bool
|
||||||
|
{
|
||||||
|
if (! is_int($tenantId) || $tenantId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenantId)
|
||||||
->active()
|
->healthyActive()
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function pollingIntervalForTenant(?Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
? self::pollingIntervalForTenantId((int) $tenant->getKey())
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pollingIntervalForTenantId(?int $tenantId): ?string
|
||||||
|
{
|
||||||
|
return self::existForTenantId($tenantId) ? '10s' : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -223,6 +223,70 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
|
|||||||
return $run->freshnessState();
|
return $run->freshnessState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function problemClass(OperationRun $run): string
|
||||||
|
{
|
||||||
|
return $run->problemClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function problemClassLabel(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return match (self::problemClass($run)) {
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Likely stale active run',
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 'Terminal follow-up',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function staleLineageNote(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
if (! $run->hasStaleLineage()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'This terminal run was automatically reconciled after stale lifecycle truth was lost.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* freshnessState:string,
|
||||||
|
* freshnessLabel:?string,
|
||||||
|
* problemClass:string,
|
||||||
|
* problemClassLabel:?string,
|
||||||
|
* isCurrentlyActive:bool,
|
||||||
|
* isReconciled:bool,
|
||||||
|
* staleLineageNote:?string,
|
||||||
|
* primaryNextAction:string,
|
||||||
|
* attentionNote:?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function decisionZoneTruth(OperationRun $run): array
|
||||||
|
{
|
||||||
|
$freshnessState = self::freshnessState($run);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'freshnessState' => $freshnessState->value,
|
||||||
|
'freshnessLabel' => self::lifecycleAttentionSummary($run),
|
||||||
|
'problemClass' => self::problemClass($run),
|
||||||
|
'problemClassLabel' => self::problemClassLabel($run),
|
||||||
|
'isCurrentlyActive' => $run->isCurrentlyActive(),
|
||||||
|
'isReconciled' => $run->isLifecycleReconciled(),
|
||||||
|
'staleLineageNote' => self::staleLineageNote($run),
|
||||||
|
'primaryNextAction' => self::surfaceGuidance($run) ?? 'No action needed.',
|
||||||
|
'attentionNote' => self::decisionAttentionNote($run),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function decisionAttentionNote(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return match (self::problemClass($run)) {
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Still active: Yes. Automatic reconciliation: No. This run is past its lifecycle window and needs stale-run investigation before retrying.',
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $run->hasStaleLineage()
|
||||||
|
? 'Still active: No. Automatic reconciliation: Yes. This terminal failure preserves stale-run lineage so operators can recover why the run stopped.'
|
||||||
|
: 'Still active: No. Automatic reconciliation: No. This run is terminal and still needs follow-up.',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
return self::memoizeExplanation(
|
return self::memoizeExplanation(
|
||||||
@ -247,7 +311,9 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
|
|||||||
return match (self::freshnessState($run)) {
|
return match (self::freshnessState($run)) {
|
||||||
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||||
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
|
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
|
||||||
default => null,
|
default => self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||||
|
? 'Terminal follow-up'
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
@ -359,7 +360,7 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
|
|||||||
nextActionUrl: $nextActionUrl,
|
nextActionUrl: $nextActionUrl,
|
||||||
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
||||||
relatedArtifactUrl: $snapshot->tenant !== null
|
relatedArtifactUrl: $snapshot->tenant !== null
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
? $this->panelSafeTenantArtifactUrl(fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant))
|
||||||
: null,
|
: null,
|
||||||
includePublicationDimension: false,
|
includePublicationDimension: false,
|
||||||
countDescriptors: [
|
countDescriptors: [
|
||||||
@ -500,9 +501,13 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||||
|
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||||
|
);
|
||||||
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
|
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
|
||||||
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant);
|
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||||
|
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
return $this->makeEnvelope(
|
||||||
@ -538,7 +543,9 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
nextActionUrl: $nextActionUrl,
|
nextActionUrl: $nextActionUrl,
|
||||||
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
||||||
relatedArtifactUrl: $review->tenant !== null
|
relatedArtifactUrl: $review->tenant !== null
|
||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
? $this->panelSafeTenantArtifactUrl(
|
||||||
|
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
includePublicationDimension: true,
|
includePublicationDimension: true,
|
||||||
countDescriptors: [
|
countDescriptors: [
|
||||||
@ -675,9 +682,13 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$nextActionUrl = null;
|
$nextActionUrl = null;
|
||||||
|
|
||||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||||
|
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant)
|
||||||
|
);
|
||||||
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
|
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
|
||||||
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant);
|
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
|
||||||
|
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant)
|
||||||
|
);
|
||||||
} elseif ($pack->operation_run_id !== null) {
|
} elseif ($pack->operation_run_id !== null) {
|
||||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||||
}
|
}
|
||||||
@ -715,7 +726,9 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
nextActionUrl: $nextActionUrl,
|
nextActionUrl: $nextActionUrl,
|
||||||
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
||||||
relatedArtifactUrl: $pack->tenant !== null
|
relatedArtifactUrl: $pack->tenant !== null
|
||||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
? $this->panelSafeTenantArtifactUrl(
|
||||||
|
fn (): string => ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
includePublicationDimension: true,
|
includePublicationDimension: true,
|
||||||
countDescriptors: [
|
countDescriptors: [
|
||||||
@ -1084,6 +1097,13 @@ classification: $classification,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function panelSafeTenantArtifactUrl(callable $resolver): ?string
|
||||||
|
{
|
||||||
|
return Filament::getCurrentPanel()?->getId() === 'system'
|
||||||
|
? null
|
||||||
|
: $resolver();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, CountDescriptor>
|
* @return array<int, CountDescriptor>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -63,6 +63,12 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false),
|
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$totalProblemOperationsCount = array_sum(array_map(
|
||||||
|
static fn (array $context): int => (int) ($context['terminal_follow_up_operations_count'] ?? 0)
|
||||||
|
+ (int) ($context['stale_attention_operations_count'] ?? 0),
|
||||||
|
$tenantContexts,
|
||||||
|
));
|
||||||
|
|
||||||
$totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants(
|
$totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants(
|
||||||
OperationRun::query(),
|
OperationRun::query(),
|
||||||
$workspaceId,
|
$workspaceId,
|
||||||
@ -86,6 +92,7 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
accessibleTenantCount: $accessibleTenants->count(),
|
accessibleTenantCount: $accessibleTenants->count(),
|
||||||
attentionItems: $attentionItems,
|
attentionItems: $attentionItems,
|
||||||
governanceAttentionTenantCount: $governanceAttentionTenantCount,
|
governanceAttentionTenantCount: $governanceAttentionTenantCount,
|
||||||
|
totalProblemOperationsCount: $totalProblemOperationsCount,
|
||||||
totalActiveOperationsCount: $totalActiveOperationsCount,
|
totalActiveOperationsCount: $totalActiveOperationsCount,
|
||||||
totalAlertFailuresCount: $totalAlertFailuresCount,
|
totalAlertFailuresCount: $totalAlertFailuresCount,
|
||||||
canViewAlerts: $canViewAlerts,
|
canViewAlerts: $canViewAlerts,
|
||||||
@ -176,18 +183,28 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$followUpRuns = $this->scopeToVisibleTenants(
|
$terminalFollowUpCounts = $this->scopeToVisibleTenants(
|
||||||
OperationRun::query()->with('tenant'),
|
OperationRun::query(),
|
||||||
$workspaceId,
|
$workspaceId,
|
||||||
$accessibleTenantIds,
|
$accessibleTenantIds,
|
||||||
)
|
)
|
||||||
->dashboardNeedsFollowUp()
|
->terminalFollowUp()
|
||||||
->latest('created_at')
|
->selectRaw('tenant_id, count(*) as aggregate_count')
|
||||||
->get()
|
->groupBy('tenant_id')
|
||||||
->groupBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
->pluck('aggregate_count', 'tenant_id')
|
||||||
|
->map(static fn (mixed $count): int => (int) $count)
|
||||||
|
->all();
|
||||||
|
|
||||||
$followUpCounts = $followUpRuns
|
$staleAttentionCounts = $this->scopeToVisibleTenants(
|
||||||
->map(static fn (Collection $runs): int => $runs->count())
|
OperationRun::query(),
|
||||||
|
$workspaceId,
|
||||||
|
$accessibleTenantIds,
|
||||||
|
)
|
||||||
|
->activeStaleAttention()
|
||||||
|
->selectRaw('tenant_id, count(*) as aggregate_count')
|
||||||
|
->groupBy('tenant_id')
|
||||||
|
->pluck('aggregate_count', 'tenant_id')
|
||||||
|
->map(static fn (mixed $count): int => (int) $count)
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$activeOperationCounts = $this->scopeToVisibleTenants(
|
$activeOperationCounts = $this->scopeToVisibleTenants(
|
||||||
@ -218,7 +235,7 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return $accessibleTenants
|
return $accessibleTenants
|
||||||
->map(function (Tenant $tenant) use ($followUpCounts, $followUpRuns, $activeOperationCounts, $alertFailureCounts): array {
|
->map(function (Tenant $tenant) use ($terminalFollowUpCounts, $staleAttentionCounts, $activeOperationCounts, $alertFailureCounts): array {
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
$aggregate = $this->governanceAggregate($tenant);
|
$aggregate = $this->governanceAggregate($tenant);
|
||||||
|
|
||||||
@ -226,8 +243,8 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
|
|||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
'aggregate' => $aggregate,
|
'aggregate' => $aggregate,
|
||||||
'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
|
'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
|
||||||
'follow_up_operations_count' => (int) ($followUpCounts[$tenantId] ?? 0),
|
'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0),
|
||||||
'latest_follow_up_run' => $followUpRuns->get($tenantId)?->first(),
|
'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0),
|
||||||
'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0),
|
'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0),
|
||||||
'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0),
|
'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0),
|
||||||
];
|
];
|
||||||
@ -281,16 +298,16 @@ private function attentionItems(
|
|||||||
CanonicalNavigationContext $navigationContext,
|
CanonicalNavigationContext $navigationContext,
|
||||||
): array {
|
): array {
|
||||||
$items = collect($tenantContexts)
|
$items = collect($tenantContexts)
|
||||||
->map(function (array $context) use ($user, $canViewAlerts, $navigationContext): ?array {
|
->flatMap(function (array $context) use ($user, $canViewAlerts, $navigationContext): array {
|
||||||
$tenant = $context['tenant'] ?? null;
|
$tenant = $context['tenant'] ?? null;
|
||||||
$aggregate = $context['aggregate'] ?? null;
|
$aggregate = $context['aggregate'] ?? null;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) {
|
if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($aggregate->lapsedGovernanceCount > 0) {
|
if ($aggregate->lapsedGovernanceCount > 0) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_lapsed_governance',
|
key: 'tenant_lapsed_governance',
|
||||||
family: 'governance',
|
family: 'governance',
|
||||||
@ -305,11 +322,11 @@ private function attentionItems(
|
|||||||
badgeColor: 'danger',
|
badgeColor: 'danger',
|
||||||
destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'),
|
destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'),
|
||||||
supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.',
|
supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.',
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($aggregate->overdueOpenFindingsCount > 0) {
|
if ($aggregate->overdueOpenFindingsCount > 0) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_overdue_findings',
|
key: 'tenant_overdue_findings',
|
||||||
family: 'findings',
|
family: 'findings',
|
||||||
@ -327,11 +344,11 @@ private function attentionItems(
|
|||||||
user: $user,
|
user: $user,
|
||||||
filters: ['tab' => 'overdue'],
|
filters: ['tab' => 'overdue'],
|
||||||
),
|
),
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->shouldPromoteCompareAttention($aggregate)) {
|
if ($this->shouldPromoteCompareAttention($aggregate)) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_compare_attention',
|
key: 'tenant_compare_attention',
|
||||||
family: 'compare',
|
family: 'compare',
|
||||||
@ -342,11 +359,11 @@ private function attentionItems(
|
|||||||
badgeColor: $aggregate->tone,
|
badgeColor: $aggregate->tone,
|
||||||
destination: $this->baselineCompareTarget($tenant, $user),
|
destination: $this->baselineCompareTarget($tenant, $user),
|
||||||
supportingMessage: $aggregate->supportingMessage,
|
supportingMessage: $aggregate->supportingMessage,
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($aggregate->highSeverityActiveFindingsCount > 0) {
|
if ($aggregate->highSeverityActiveFindingsCount > 0) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_high_severity_findings',
|
key: 'tenant_high_severity_findings',
|
||||||
family: 'findings',
|
family: 'findings',
|
||||||
@ -364,11 +381,11 @@ private function attentionItems(
|
|||||||
user: $user,
|
user: $user,
|
||||||
filters: ['tab' => 'needs_action', 'high_severity' => true],
|
filters: ['tab' => 'needs_action', 'high_severity' => true],
|
||||||
),
|
),
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($aggregate->expiringGovernanceCount > 0) {
|
if ($aggregate->expiringGovernanceCount > 0) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_expiring_governance',
|
key: 'tenant_expiring_governance',
|
||||||
family: 'governance',
|
family: 'governance',
|
||||||
@ -389,33 +406,71 @@ private function attentionItems(
|
|||||||
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
$followUpOperationsCount = (int) ($context['follow_up_operations_count'] ?? 0);
|
$items = [];
|
||||||
|
|
||||||
if ($followUpOperationsCount > 0) {
|
$terminalFollowUpOperationsCount = (int) ($context['terminal_follow_up_operations_count'] ?? 0);
|
||||||
return $this->makeAttentionItem(
|
|
||||||
|
if ($terminalFollowUpOperationsCount > 0) {
|
||||||
|
$items[] = $this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_operations_follow_up',
|
key: 'tenant_operations_terminal_follow_up',
|
||||||
family: 'operations',
|
family: 'operations',
|
||||||
urgency: 'medium',
|
urgency: 'medium',
|
||||||
title: 'Operations need follow-up',
|
title: 'Terminal operations need follow-up',
|
||||||
body: sprintf(
|
body: sprintf(
|
||||||
'%d run%s failed, completed with warnings, or still need operator follow-up.',
|
'%d run%s finished blocked, partially, failed, or were automatically reconciled.',
|
||||||
$followUpOperationsCount,
|
$terminalFollowUpOperationsCount,
|
||||||
$followUpOperationsCount === 1 ? '' : 's',
|
$terminalFollowUpOperationsCount === 1 ? '' : 's',
|
||||||
),
|
),
|
||||||
badge: 'Operations',
|
badge: 'Operations',
|
||||||
badgeColor: 'danger',
|
badgeColor: 'danger',
|
||||||
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'blocked'),
|
destination: $this->operationsIndexTarget(
|
||||||
|
$tenant,
|
||||||
|
$navigationContext,
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
'Open terminal follow-up',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$staleAttentionOperationsCount = (int) ($context['stale_attention_operations_count'] ?? 0);
|
||||||
|
|
||||||
|
if ($staleAttentionOperationsCount > 0) {
|
||||||
|
$items[] = $this->makeAttentionItem(
|
||||||
|
tenant: $tenant,
|
||||||
|
key: 'tenant_operations_stale_attention',
|
||||||
|
family: 'operations',
|
||||||
|
urgency: 'medium',
|
||||||
|
title: 'Active operations look stale',
|
||||||
|
body: sprintf(
|
||||||
|
'%d run%s are still marked active but are past the lifecycle window.',
|
||||||
|
$staleAttentionOperationsCount,
|
||||||
|
$staleAttentionOperationsCount === 1 ? '' : 's',
|
||||||
|
),
|
||||||
|
badge: 'Operations',
|
||||||
|
badgeColor: 'warning',
|
||||||
|
destination: $this->operationsIndexTarget(
|
||||||
|
$tenant,
|
||||||
|
$navigationContext,
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
'Open stale operations',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($items !== []) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
$activeOperationsCount = (int) ($context['active_operations_count'] ?? 0);
|
$activeOperationsCount = (int) ($context['active_operations_count'] ?? 0);
|
||||||
|
|
||||||
if ($activeOperationsCount > 0) {
|
if ($activeOperationsCount > 0) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_active_operations',
|
key: 'tenant_active_operations',
|
||||||
family: 'operations',
|
family: 'operations',
|
||||||
@ -429,13 +484,13 @@ private function attentionItems(
|
|||||||
badge: 'Operations',
|
badge: 'Operations',
|
||||||
badgeColor: 'warning',
|
badgeColor: 'warning',
|
||||||
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'),
|
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'),
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
$alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0);
|
$alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0);
|
||||||
|
|
||||||
if ($canViewAlerts && $alertFailuresCount > 0) {
|
if ($canViewAlerts && $alertFailuresCount > 0) {
|
||||||
return $this->makeAttentionItem(
|
return [$this->makeAttentionItem(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
key: 'tenant_alert_delivery_failures',
|
key: 'tenant_alert_delivery_failures',
|
||||||
family: 'alerts',
|
family: 'alerts',
|
||||||
@ -449,12 +504,11 @@ private function attentionItems(
|
|||||||
badge: 'Alerts',
|
badge: 'Alerts',
|
||||||
badgeColor: 'danger',
|
badgeColor: 'danger',
|
||||||
destination: $this->alertsOverviewTarget($navigationContext, true),
|
destination: $this->alertsOverviewTarget($navigationContext, true),
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return [];
|
||||||
})
|
})
|
||||||
->filter()
|
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
@ -480,7 +534,8 @@ private function attentionPriority(array $item): int
|
|||||||
'tenant_compare_attention' => 90,
|
'tenant_compare_attention' => 90,
|
||||||
'tenant_high_severity_findings' => 80,
|
'tenant_high_severity_findings' => 80,
|
||||||
'tenant_expiring_governance' => 70,
|
'tenant_expiring_governance' => 70,
|
||||||
'tenant_operations_follow_up' => 40,
|
'tenant_operations_stale_attention' => 45,
|
||||||
|
'tenant_operations_terminal_follow_up' => 40,
|
||||||
'tenant_active_operations' => 20,
|
'tenant_active_operations' => 20,
|
||||||
'tenant_alert_delivery_failures' => 10,
|
'tenant_alert_delivery_failures' => 10,
|
||||||
default => 0,
|
default => 0,
|
||||||
@ -625,11 +680,6 @@ private function recentOperations(
|
|||||||
array $accessibleTenantIds,
|
array $accessibleTenantIds,
|
||||||
CanonicalNavigationContext $navigationContext,
|
CanonicalNavigationContext $navigationContext,
|
||||||
): array {
|
): array {
|
||||||
$statusSpec = BadgeRenderer::label(BadgeDomain::OperationRunStatus);
|
|
||||||
$statusColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunStatus);
|
|
||||||
$outcomeSpec = BadgeRenderer::label(BadgeDomain::OperationRunOutcome);
|
|
||||||
$outcomeColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunOutcome);
|
|
||||||
|
|
||||||
return $this->scopeToAuthorizedTenants(
|
return $this->scopeToAuthorizedTenants(
|
||||||
OperationRun::query()->with('tenant'),
|
OperationRun::query()->with('tenant'),
|
||||||
$workspaceId,
|
$workspaceId,
|
||||||
@ -638,17 +688,27 @@ private function recentOperations(
|
|||||||
->latest('created_at')
|
->latest('created_at')
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get()
|
->get()
|
||||||
->map(function (OperationRun $run) use ($navigationContext, $statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array {
|
->map(function (OperationRun $run) use ($navigationContext): array {
|
||||||
$destination = $this->operationDetailTarget($run, $navigationContext);
|
$destination = $this->operationDetailTarget($run, $navigationContext);
|
||||||
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
]);
|
||||||
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $run->getKey(),
|
'id' => (int) $run->getKey(),
|
||||||
'title' => OperationCatalog::label((string) $run->type),
|
'title' => OperationCatalog::label((string) $run->type),
|
||||||
'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null,
|
'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null,
|
||||||
'status_label' => $statusSpec($run->status),
|
'status_label' => $statusSpec->label,
|
||||||
'status_color' => $statusColorSpec($run->status),
|
'status_color' => $statusSpec->color,
|
||||||
'outcome_label' => $outcomeSpec($run->outcome),
|
'outcome_label' => $outcomeSpec->label,
|
||||||
'outcome_color' => $outcomeColorSpec($run->outcome),
|
'outcome_color' => $outcomeSpec->color,
|
||||||
|
'lifecycle_label' => OperationUxPresenter::lifecycleAttentionSummary($run),
|
||||||
'guidance' => OperationUxPresenter::surfaceGuidance($run),
|
'guidance' => OperationUxPresenter::surfaceGuidance($run),
|
||||||
'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
|
'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
|
||||||
'destination' => $destination,
|
'destination' => $destination,
|
||||||
@ -665,6 +725,7 @@ private function calmnessState(
|
|||||||
int $accessibleTenantCount,
|
int $accessibleTenantCount,
|
||||||
array $attentionItems,
|
array $attentionItems,
|
||||||
int $governanceAttentionTenantCount,
|
int $governanceAttentionTenantCount,
|
||||||
|
int $totalProblemOperationsCount,
|
||||||
int $totalActiveOperationsCount,
|
int $totalActiveOperationsCount,
|
||||||
int $totalAlertFailuresCount,
|
int $totalAlertFailuresCount,
|
||||||
bool $canViewAlerts,
|
bool $canViewAlerts,
|
||||||
@ -686,7 +747,9 @@ private function calmnessState(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasActivityAttention = $totalActiveOperationsCount > 0 || ($canViewAlerts && $totalAlertFailuresCount > 0);
|
$hasActivityAttention = $totalActiveOperationsCount > 0
|
||||||
|
|| $totalProblemOperationsCount > 0
|
||||||
|
|| ($canViewAlerts && $totalAlertFailuresCount > 0);
|
||||||
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention;
|
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention;
|
||||||
|
|
||||||
if ($isCalm) {
|
if ($isCalm) {
|
||||||
@ -705,7 +768,7 @@ private function calmnessState(
|
|||||||
'checked_domains' => $checkedDomains,
|
'checked_domains' => $checkedDomains,
|
||||||
'title' => 'Workspace activity still needs review',
|
'title' => 'Workspace activity still needs review',
|
||||||
'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
|
'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
|
||||||
'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'blocked'),
|
'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'active'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -934,16 +997,18 @@ private function operationsIndexTarget(
|
|||||||
?Tenant $tenant,
|
?Tenant $tenant,
|
||||||
CanonicalNavigationContext $navigationContext,
|
CanonicalNavigationContext $navigationContext,
|
||||||
?string $activeTab = null,
|
?string $activeTab = null,
|
||||||
|
?string $problemClass = null,
|
||||||
string $label = 'Open operations',
|
string $label = 'Open operations',
|
||||||
): array {
|
): array {
|
||||||
return $this->destination(
|
return $this->destination(
|
||||||
kind: 'operations_index',
|
kind: 'operations_index',
|
||||||
url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null),
|
url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null, $problemClass),
|
||||||
label: $label,
|
label: $label,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
filters: array_filter([
|
filters: array_filter([
|
||||||
'tenant_id' => $tenant?->getKey(),
|
'tenant_id' => $tenant?->getKey(),
|
||||||
'activeTab' => $activeTab,
|
'activeTab' => $activeTab,
|
||||||
|
'problemClass' => $problemClass,
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
||||||
|
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||||
|
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||||
|
|
||||||
<x-filament::tabs label="Operations tabs">
|
<x-filament::tabs label="Operations tabs">
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
@ -15,10 +17,16 @@
|
|||||||
Active
|
Active
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'blocked'"
|
:active="$this->activeTab === $staleAttentionTab"
|
||||||
wire:click="$set('activeTab', 'blocked')"
|
wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
|
||||||
>
|
>
|
||||||
Needs follow-up
|
Likely stale
|
||||||
|
</x-filament::tabs.item>
|
||||||
|
<x-filament::tabs.item
|
||||||
|
:active="$this->activeTab === $terminalFollowUpTab"
|
||||||
|
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
|
||||||
|
>
|
||||||
|
Terminal follow-up
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'succeeded'"
|
:active="$this->activeTab === 'succeeded'"
|
||||||
@ -42,8 +50,8 @@
|
|||||||
|
|
||||||
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||||
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window.
|
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
|
||||||
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) have already been automatically reconciled.
|
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) already carry reconciled stale lineage and belong in terminal follow-up.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@ -4,20 +4,26 @@
|
|||||||
|
|
||||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||||
(string) $run->status,
|
[
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$outcomeSpec = (string) $run->status === 'completed'
|
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
? \App\Support\Badges\BadgeRenderer::spec(
|
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||||
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
[
|
||||||
(string) $run->outcome,
|
'outcome' => (string) $run->outcome,
|
||||||
)
|
'status' => (string) $run->status,
|
||||||
: null;
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
$hasSummary = count($summaryCounts) > 0;
|
$hasSummary = count($summaryCounts) > 0;
|
||||||
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
||||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||||
|
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@ -104,6 +110,47 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">
|
||||||
|
Current lifecycle truth
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Still active</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ ($decisionTruth['isCurrentlyActive'] ?? false) ? 'Yes' : 'No' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Automatic reconciliation</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ ($decisionTruth['isReconciled'] ?? false) ? 'Yes' : 'No' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Problem class</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ $decisionTruth['problemClassLabel'] ?? 'None' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($decisionTruth['attentionNote'] ?? null))
|
||||||
|
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
|
{{ $decisionTruth['attentionNote'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($decisionTruth['staleLineageNote'] ?? null))
|
||||||
|
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||||
|
{{ $decisionTruth['staleLineageNote'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($integrityNote)
|
@if ($integrityNote)
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
|
|||||||
@ -17,12 +17,20 @@
|
|||||||
@php
|
@php
|
||||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||||
(string) $run->status,
|
[
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||||
(string) $run->outcome,
|
[
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'freshness_state' => $run->freshnessState()->value,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
|
||||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||||
@endphp
|
@endphp
|
||||||
<li class="flex items-center justify-between gap-3 py-2">
|
<li class="flex items-center justify-between gap-3 py-2">
|
||||||
@ -38,6 +46,11 @@
|
|||||||
<x-filament::badge :color="$outcomeSpec->color" size="sm">
|
<x-filament::badge :color="$outcomeSpec->color" size="sm">
|
||||||
{{ $outcomeSpec->label }}
|
{{ $outcomeSpec->label }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
|
@if ($lifecycleAttention)
|
||||||
|
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
|
||||||
|
{{ $lifecycleAttention }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@ -54,6 +54,11 @@
|
|||||||
<x-filament::badge :color="$operation['outcome_color']" size="sm">
|
<x-filament::badge :color="$operation['outcome_color']" size="sm">
|
||||||
{{ $operation['outcome_label'] }}
|
{{ $operation['outcome_label'] }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
|
@if (filled($operation['lifecycle_label'] ?? null))
|
||||||
|
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
|
||||||
|
{{ $operation['lifecycle_label'] }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (filled($operation['guidance'] ?? null))
|
@if (filled($operation['guidance'] ?? null))
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
x-data="opsUxProgressWidgetPoller()"
|
x-data="opsUxProgressWidgetPoller()"
|
||||||
x-init="init()"
|
x-init="init()"
|
||||||
wire:key="ops-ux-progress-widget"
|
wire:key="ops-ux-progress-widget"
|
||||||
|
@if (! $disabled && $hasActiveRuns)
|
||||||
|
wire:poll.10s="refreshRuns"
|
||||||
|
@endif
|
||||||
>
|
>
|
||||||
@if($runs->isNotEmpty())
|
@if($runs->isNotEmpty())
|
||||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||||
|
|||||||
36
specs/178-ops-truth-alignment/checklists/requirements.md
Normal file
36
specs/178-ops-truth-alignment/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-05
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validated after the initial draft. The spec stays focused on operator trust, cross-surface truth alignment, and drill-through continuity while explicitly avoiding new persistence or a lifecycle-model rewrite.
|
||||||
|
- The only new semantic split is a derived operator-facing distinction between terminal follow-up and active stale or stuck attention, built from existing lifecycle truth rather than new stored state.
|
||||||
|
- No clarification markers remain. Scope, non-goals, dependencies, and measurable outcomes are explicit enough to proceed to planning.
|
||||||
@ -0,0 +1,279 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Operations Truth Alignment Contract
|
||||||
|
version: 1.0.0
|
||||||
|
summary: Route and UI truth contract for Spec 178.
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FreshnessState:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- fresh_active
|
||||||
|
- likely_stale
|
||||||
|
- reconciled_failed
|
||||||
|
- terminal_normal
|
||||||
|
- unknown
|
||||||
|
ProblemClass:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- active_stale_attention
|
||||||
|
- terminal_follow_up
|
||||||
|
OperationsDrillthroughState:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- workspace_id
|
||||||
|
- problemClass
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
tenant_id:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
problemClass:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
activeTab:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
navigationContext:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
RunDecisionZoneTruth:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- freshnessState
|
||||||
|
- problemClass
|
||||||
|
- isCurrentlyActive
|
||||||
|
- isReconciled
|
||||||
|
- primaryNextAction
|
||||||
|
properties:
|
||||||
|
freshnessState:
|
||||||
|
$ref: '#/components/schemas/FreshnessState'
|
||||||
|
problemClass:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
isCurrentlyActive:
|
||||||
|
type: boolean
|
||||||
|
isReconciled:
|
||||||
|
type: boolean
|
||||||
|
staleLineageNote:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
primaryNextAction:
|
||||||
|
type: string
|
||||||
|
paths:
|
||||||
|
/admin:
|
||||||
|
get:
|
||||||
|
operationId: viewWorkspaceOverview
|
||||||
|
summary: Display the workspace overview with aligned operations attention and recency truth.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Workspace overview rendered successfully.
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the current workspace scope.
|
||||||
|
x-embedded-surfaces:
|
||||||
|
workspaceOperationsAttention:
|
||||||
|
surfaceType: embedded_attention_summary
|
||||||
|
problemBuckets:
|
||||||
|
- active_stale_attention
|
||||||
|
- terminal_follow_up
|
||||||
|
canonicalCollectionRoute: /admin/operations
|
||||||
|
destinationContract:
|
||||||
|
$ref: '#/components/schemas/OperationsDrillthroughState'
|
||||||
|
workspaceRecentOperations:
|
||||||
|
surfaceType: diagnostic_recency_table
|
||||||
|
canonicalCollectionRoute: /admin/operations
|
||||||
|
canonicalDetailRoute: /admin/operations/{run}
|
||||||
|
rowTruth:
|
||||||
|
freshnessState:
|
||||||
|
$ref: '#/components/schemas/FreshnessState'
|
||||||
|
problemClass:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
/admin/t/{tenant}:
|
||||||
|
get:
|
||||||
|
operationId: viewTenantDashboard
|
||||||
|
summary: Display the tenant dashboard with aligned operations attention, recent activity, and local progress truth.
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant dashboard rendered successfully.
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the tenant scope.
|
||||||
|
x-embedded-surfaces:
|
||||||
|
tenantOperationsAttention:
|
||||||
|
surfaceType: embedded_attention_summary
|
||||||
|
problemBuckets:
|
||||||
|
- active_stale_attention
|
||||||
|
- terminal_follow_up
|
||||||
|
canonicalCollectionRoute: /admin/operations
|
||||||
|
destinationContract:
|
||||||
|
$ref: '#/components/schemas/OperationsDrillthroughState'
|
||||||
|
tenantRecentOperations:
|
||||||
|
surfaceType: diagnostic_recency_table
|
||||||
|
canonicalCollectionRoute: /admin/operations
|
||||||
|
canonicalDetailRoute: /admin/operations/{run}
|
||||||
|
rowTruth:
|
||||||
|
freshnessState:
|
||||||
|
$ref: '#/components/schemas/FreshnessState'
|
||||||
|
problemClass:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
bulkOperationProgress:
|
||||||
|
surfaceType: live_progress_indicator
|
||||||
|
activeOnly: true
|
||||||
|
polling:
|
||||||
|
interval: 10s
|
||||||
|
activeWhen: active runs exist for the current tenant
|
||||||
|
inactiveWhen: no relevant active runs remain
|
||||||
|
/admin/operations:
|
||||||
|
get:
|
||||||
|
operationId: listAdminOperations
|
||||||
|
summary: Display the canonical admin operations hub with problem-class-aware filtering.
|
||||||
|
parameters:
|
||||||
|
- name: tenant_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: problemClass
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
- name: activeTab
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Operations hub rendered successfully.
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the workspace or referenced tenant scope.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Operations
|
||||||
|
canonicalDetailRoute: /admin/operations/{run}
|
||||||
|
filters:
|
||||||
|
active_stale_attention:
|
||||||
|
definition: queued or running runs whose freshness state is likely_stale
|
||||||
|
terminal_follow_up:
|
||||||
|
definition: completed runs whose outcome is blocked, partially_succeeded, or failed; reconciled stale lineage remains visible
|
||||||
|
rowTruth:
|
||||||
|
freshnessState:
|
||||||
|
$ref: '#/components/schemas/FreshnessState'
|
||||||
|
problemClass:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
operationId: viewAdminOperation
|
||||||
|
summary: Display the canonical admin run detail with decision-zone lifecycle truth.
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical admin run detail rendered successfully.
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the workspace or referenced tenant scope.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: detail_first_operational
|
||||||
|
displayLabel: Operation
|
||||||
|
decisionZoneTruth:
|
||||||
|
$ref: '#/components/schemas/RunDecisionZoneTruth'
|
||||||
|
invariant:
|
||||||
|
- stale and reconciled lifecycle truth must be visible in the primary decision hierarchy
|
||||||
|
- problem class on the destination must confirm the problem class of the origin link
|
||||||
|
/system/ops/runs:
|
||||||
|
get:
|
||||||
|
operationId: listSystemOperations
|
||||||
|
summary: Display the platform-wide operations registry with stale/reconciled lineage visible.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System operations registry rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Operations
|
||||||
|
canonicalDetailRoute: /system/ops/runs/{run}
|
||||||
|
rowTruth:
|
||||||
|
freshnessState:
|
||||||
|
$ref: '#/components/schemas/FreshnessState'
|
||||||
|
problemClass:
|
||||||
|
$ref: '#/components/schemas/ProblemClass'
|
||||||
|
staleLineageVisible: true
|
||||||
|
/system/ops/failures:
|
||||||
|
get:
|
||||||
|
operationId: listSystemFailures
|
||||||
|
summary: Display the platform failure registry with reconciled stale lineage visible on failed terminal runs.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System failures registry rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Failed operations
|
||||||
|
canonicalDetailRoute: /system/ops/runs/{run}
|
||||||
|
filter:
|
||||||
|
baseOutcome: failed
|
||||||
|
invariant:
|
||||||
|
- failed runs that were auto-reconciled from stale state must visibly preserve that lineage
|
||||||
|
/system/ops/stuck:
|
||||||
|
get:
|
||||||
|
operationId: listSystemStuckOperations
|
||||||
|
summary: Display active queued/running operations that crossed the lifecycle stuck threshold.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System stuck registry rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: read_only_registry_report
|
||||||
|
displayLabel: Stuck operations
|
||||||
|
canonicalDetailRoute: /system/ops/runs/{run}
|
||||||
|
filter:
|
||||||
|
problemClass: active_stale_attention
|
||||||
|
invariant:
|
||||||
|
- the page remains active-only and uses the same lifecycle-policy thresholds as admin stale detection
|
||||||
|
/system/ops/runs/{run}:
|
||||||
|
get:
|
||||||
|
operationId: viewSystemOperation
|
||||||
|
summary: Display the platform run detail confirming stale/reconciled lineage and next action.
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: System run detail rendered successfully.
|
||||||
|
'403':
|
||||||
|
description: Authenticated platform user lacks operations view capability.
|
||||||
|
'404':
|
||||||
|
description: Wrong plane or inaccessible system surface.
|
||||||
|
x-ui-surface:
|
||||||
|
surfaceType: detail_first_operational
|
||||||
|
displayLabel: Operation
|
||||||
|
decisionZoneTruth:
|
||||||
|
$ref: '#/components/schemas/RunDecisionZoneTruth'
|
||||||
|
invariant:
|
||||||
|
- stale/reconciled lineage remains visible even when the run is already terminal
|
||||||
230
specs/178-ops-truth-alignment/data-model.md
Normal file
230
specs/178-ops-truth-alignment/data-model.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# Phase 1 Data Model: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add a table, persisted summary entity, or new lifecycle state machine. It aligns existing `OperationRun` truth and existing freshness/reconciliation semantics across multiple operator surfaces by introducing a small set of derived cross-surface contracts.
|
||||||
|
|
||||||
|
The central rule is unchanged: `OperationRun` is the only canonical lifecycle source of truth. Everything else in this slice is derived from status, outcome, freshness, and reconciliation metadata already present in the repo.
|
||||||
|
|
||||||
|
## Persistent Source Truths
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
**Purpose**: Canonical operational record for queued, running, completed, stale, and automatically reconciled work shown across tenant, workspace, canonical admin, and system monitoring surfaces.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `initiator_name`
|
||||||
|
- `summary_counts`
|
||||||
|
- `failure_summary`
|
||||||
|
- `context`
|
||||||
|
- `created_at`
|
||||||
|
- `started_at`
|
||||||
|
- `completed_at`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `status` and `outcome` remain service-owned; this feature must not introduce page-local lifecycle mutation.
|
||||||
|
- Every covered summary, list, detail, and notification surface must derive from the same run record and the same underlying lifecycle fields.
|
||||||
|
- The feature must not add a second persisted problem-state field or a new lifecycle table.
|
||||||
|
|
||||||
|
### Reconciliation Context (within `OperationRun.context`)
|
||||||
|
|
||||||
|
**Purpose**: Existing stored lineage that indicates a run was automatically reconciled after lifecycle drift.
|
||||||
|
|
||||||
|
**Expected fields**:
|
||||||
|
- `reconciled_at`
|
||||||
|
- `reason`
|
||||||
|
- `reason_code`
|
||||||
|
- `source`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Reconciliation context remains nested under `context.reconciliation`; this feature does not move it or normalize it into a new table.
|
||||||
|
- If reconciliation context exists, surfaces must preserve that stale/reconciled lineage within one navigation step of the canonical run truth.
|
||||||
|
|
||||||
|
## Existing Runtime Source Objects
|
||||||
|
|
||||||
|
### OperationRunFreshnessState
|
||||||
|
|
||||||
|
**Purpose**: Existing derived lifecycle interpretation for one run.
|
||||||
|
|
||||||
|
**Cases**:
|
||||||
|
- `fresh_active`
|
||||||
|
- `likely_stale`
|
||||||
|
- `reconciled_failed`
|
||||||
|
- `terminal_normal`
|
||||||
|
- `unknown`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- This remains the canonical freshness interpretation for the slice.
|
||||||
|
- Spec 178 must not introduce a parallel freshness-state family.
|
||||||
|
|
||||||
|
### OperationLifecyclePolicy
|
||||||
|
|
||||||
|
**Purpose**: Existing threshold policy for deciding when queued/running work becomes stale.
|
||||||
|
|
||||||
|
**Consumed fields**:
|
||||||
|
- covered operation types
|
||||||
|
- queued stale threshold
|
||||||
|
- running stale threshold
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Tenant, workspace, admin, and system stale or stuck semantics must all derive from the same underlying lifecycle-policy thresholds.
|
||||||
|
|
||||||
|
### StuckRunClassifier
|
||||||
|
|
||||||
|
**Purpose**: Existing system-panel classifier for active queued/running runs that crossed the stuck threshold.
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `Stuck` remains an active-stale registry surface.
|
||||||
|
- System visibility for reconciled stale runs must be preserved through adjacent system surfaces rather than a new classifier or new stored state.
|
||||||
|
|
||||||
|
### OperationUxPresenter
|
||||||
|
|
||||||
|
**Purpose**: Existing operator-facing seam for guidance, notification wording, and run presentation.
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- New stale/reconciled wording should flow through this seam or a compatible existing presentation seam rather than widget-local strings.
|
||||||
|
|
||||||
|
## Derived Cross-Surface Contracts
|
||||||
|
|
||||||
|
### ProblemClassContract
|
||||||
|
|
||||||
|
**Purpose**: The thin operator-facing split used to align summary buckets, monitoring filters, local progress removal rules, and entry-point wording.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `freshnessState` | enum | yes | Existing `OperationRunFreshnessState` value |
|
||||||
|
| `problemClass` | enum | yes | `none`, `active_stale_attention`, or `terminal_follow_up` |
|
||||||
|
| `staleLineage` | boolean | yes | Whether the run is terminal but carries stale/reconciled history |
|
||||||
|
| `isCurrentlyActive` | boolean | yes | Whether the run should still be treated as actively executing |
|
||||||
|
| `requiresOperatorReview` | boolean | yes | Whether the surface should escalate the run into an attention/follow-up bucket |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `freshnessState = fresh_active` ⇒ `problemClass = none`, `isCurrentlyActive = true`
|
||||||
|
- `freshnessState = likely_stale` ⇒ `problemClass = active_stale_attention`, `isCurrentlyActive = true`, `requiresOperatorReview = true`
|
||||||
|
- `freshnessState = reconciled_failed` ⇒ `problemClass = terminal_follow_up`, `staleLineage = true`, `isCurrentlyActive = false`, `requiresOperatorReview = true`
|
||||||
|
- `freshnessState = terminal_normal` and `outcome in {blocked, partially_succeeded, failed}` ⇒ `problemClass = terminal_follow_up`, `staleLineage = false`
|
||||||
|
- `freshnessState = terminal_normal` and healthy terminal outcome ⇒ `problemClass = none`
|
||||||
|
|
||||||
|
### OperationsAttentionBucket
|
||||||
|
|
||||||
|
**Purpose**: Shared contract for tenant/workspace operations attention summaries.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `problemClass` | enum | yes | `terminal_follow_up` or `active_stale_attention` |
|
||||||
|
| `count` | integer | yes | Bucket size |
|
||||||
|
| `label` | string | yes | Operator-facing bucket label |
|
||||||
|
| `destination` | object | yes | Canonical operations route plus tenant/workspace-safe filter state |
|
||||||
|
| `emptyAllowed` | boolean | yes | Whether the bucket may be hidden when count is zero |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Attention surfaces must not mix both problem classes into one undifferentiated bucket.
|
||||||
|
- Each bucket exposes one destination only.
|
||||||
|
|
||||||
|
### OperationsHubFilterState
|
||||||
|
|
||||||
|
**Purpose**: Structured state needed to keep `/admin/operations` semantically continuous when opened from a summary or notification.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `workspace_id` | integer | yes | Existing workspace scope |
|
||||||
|
| `tenant_id` | integer nullable | no | Active tenant filter when applicable |
|
||||||
|
| `problemClass` | enum | yes | `terminal_follow_up`, `active_stale_attention`, or `all` |
|
||||||
|
| `activeTab` | string nullable | no | Existing or extended visible tab/filter state |
|
||||||
|
| `navigationContext` | string nullable | no | Existing canonical back-link or page-context state |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Links opened because of stale active attention must land in a visibly stale-active view, not a mixed generic bucket.
|
||||||
|
- Links opened because of terminal follow-up must land in a visibly terminal-problem view.
|
||||||
|
|
||||||
|
### RecentOperationRowTruth
|
||||||
|
|
||||||
|
**Purpose**: Shared row-level contract for tenant/workspace recent-operation tables and admin/system list rendering.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `runId` | integer | yes | Canonical run identifier |
|
||||||
|
| `status` | string | yes | Existing lifecycle status |
|
||||||
|
| `outcome` | string | yes | Existing execution outcome |
|
||||||
|
| `freshnessState` | enum | yes | Existing freshness interpretation |
|
||||||
|
| `problemClass` | enum | yes | Derived attention class |
|
||||||
|
| `staleLineage` | boolean | yes | Whether the row should visibly indicate reconciled stale history |
|
||||||
|
| `guidance` | string nullable | no | Short operator-facing row hint |
|
||||||
|
| `destination` | object | yes | Canonical detail URL plus optional collection URL |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- A recent-operations row must not visually imply healthy active progress when `problemClass = active_stale_attention` or `staleLineage = true`.
|
||||||
|
- Terminal/reconciled rows must remain distinguishable from healthy completed rows.
|
||||||
|
|
||||||
|
### BulkOperationProgressSnapshot
|
||||||
|
|
||||||
|
**Purpose**: Active-only overlay contract for local progress rendering.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `runId` | integer | yes | Run being shown |
|
||||||
|
| `tenantId` | integer | yes | Tenant scope for the overlay |
|
||||||
|
| `freshnessState` | enum | yes | Existing freshness interpretation |
|
||||||
|
| `displayAsActive` | boolean | yes | Whether the run should remain visible in the overlay |
|
||||||
|
| `shouldPoll` | boolean | yes | Whether the component should continue polling |
|
||||||
|
| `overflowCount` | integer | yes | Existing overflow behavior |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Only currently active runs may remain in the overlay.
|
||||||
|
- If a run becomes terminal or reconciled, `displayAsActive` must become `false` within one refresh cycle.
|
||||||
|
- `shouldPoll` must become `false` when no relevant active runs remain.
|
||||||
|
|
||||||
|
### RunDecisionZoneTruth
|
||||||
|
|
||||||
|
**Purpose**: Canonical detail contract for what the operator should learn first.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `freshnessState` | enum | yes | Existing freshness interpretation |
|
||||||
|
| `problemClass` | enum | yes | Derived attention class |
|
||||||
|
| `isCurrentlyActive` | boolean | yes | Plain answer to “is the run still active?” |
|
||||||
|
| `isReconciled` | boolean | yes | Whether automatic reconciliation already happened |
|
||||||
|
| `staleLineageNote` | string nullable | no | Visible explanation when terminal truth came from stale reconciliation |
|
||||||
|
| `primaryNextAction` | string | yes | First follow-up step the operator should take |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- For `likely_stale` and `reconciled_failed`, this contract must be visible in the primary decision hierarchy rather than only in diagnostics.
|
||||||
|
- `primaryNextAction` must differ by problem class: infrastructure investigation for stale-active, follow-up/retry/artifact review for terminal problems.
|
||||||
|
|
||||||
|
### NotificationTruthPayload
|
||||||
|
|
||||||
|
**Purpose**: Minimal contract for completed notification wording and link continuity.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `title` | string | yes | Operator-facing summary title |
|
||||||
|
| `problemClass` | enum | yes | Derived attention class |
|
||||||
|
| `staleLineage` | boolean | yes | Whether stale/reconciled history must be visible in wording |
|
||||||
|
| `runUrl` | string | yes | Canonical run destination |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Notification wording must not be calmer than the current run truth.
|
||||||
|
- If `staleLineage = true`, the notification must preserve that lineage in title or body without requiring the operator to infer it later.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `OperationRun` yields one `OperationRunFreshnessState` and one derived `ProblemClassContract`.
|
||||||
|
- Tenant/workspace attention buckets, recent-operation rows, admin monitoring filters, system monitoring lists, canonical detail, and completed notifications all consume the same derived problem-class contract.
|
||||||
|
- `BulkOperationProgressSnapshot` is a specialized active-only view over the same run truth.
|
||||||
|
- `RunDecisionZoneTruth` is the highest-trust detailed interpretation of the same run truth and should confirm the same problem class visible on summary surfaces.
|
||||||
|
|
||||||
|
## Lifecycle Notes
|
||||||
|
|
||||||
|
1. `OperationRun` remains the single persisted source of lifecycle truth.
|
||||||
|
2. Freshness is derived by the existing lifecycle policy and freshness-state enum.
|
||||||
|
3. Problem class is derived from freshness plus terminal outcome; it is not stored.
|
||||||
|
4. Summary surfaces consume the derived problem class in separate buckets.
|
||||||
|
5. The canonical operations hub consumes the derived problem class as visible filter state.
|
||||||
|
6. Local progress consumes the same truth but removes terminal/reconciled runs because it is active-only.
|
||||||
|
7. Canonical detail and notifications confirm the same truth with stronger operator guidance.
|
||||||
312
specs/178-ops-truth-alignment/plan.md
Normal file
312
specs/178-ops-truth-alignment/plan.md
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
# Implementation Plan: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
**Branch**: `178-ops-truth-alignment` | **Date**: 2026-04-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Align operations truth across tenant dashboard summaries, workspace overview summaries, BulkOperationProgress, recent-operations widgets, the canonical admin monitoring hub, the canonical run-detail page, and the system-panel stuck or failure surfaces without changing the `OperationRun` schema, inventing a second lifecycle model, or widening authorization scope. The implementation stays narrow by reusing existing `OperationRun` status, outcome, freshness, reconciliation metadata, and route structure, then hardening four seams that already exist but drift independently today: summary bucketing, local progress freshness, canonical drill-through continuity, and decision-zone emphasis.
|
||||||
|
|
||||||
|
The first slice adds one shared derived problem-class contract on top of the existing lifecycle truth so all covered surfaces can separate `terminal follow-up` from `active stale/stuck attention` without creating new persistence. The second slice applies that contract to local progress and summary surfaces, aligns the admin and system monitoring surfaces around the same stale or reconciled story, and preserves that story through notifications and drill-throughs. Focused Pest coverage then locks in cross-surface truth, polling freshness, system visibility of reconciled stale lineage, and decision-zone emphasis.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
|
||||||
|
**Storage**: PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change
|
||||||
|
**Testing**: Pest 4 feature and Livewire or Filament component tests through Laravel Sail, plus existing system-panel and monitoring guard coverage
|
||||||
|
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: keep tenant, workspace, admin, and system monitoring surfaces DB-only at render; converge local progress truth within one polling cycle after canonical state changes; poll only while relevant active runs exist; preserve existing 10-second active-surface polling cadence where polling is used
|
||||||
|
**Constraints**: no schema migration; no new persisted lifecycle truth; no enum rewrite; no new route family; no cross-plane leakage; no ad-hoc status or badge mappings; lifecycle transitions remain service-owned; system stuck truth must remain discoverable after reconciliation; no new panel assets or provider-registration changes
|
||||||
|
**Scale/Scope**: 8 operator-facing surfaces across tenant, workspace, canonical admin, and system panels plus existing operation notifications and shared presenter or query seams; one canonical lifecycle model reused across existing operation types
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | No inventory, backup, or snapshot ownership semantics change. |
|
||||||
|
| Read/write separation | PASS | PASS | The slice is read-time truth alignment only; no new mutation path or action surface is introduced. |
|
||||||
|
| Graph contract path | N/A | N/A | No Microsoft Graph call path is touched. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Existing admin-plane and system-plane authorization remains authoritative. |
|
||||||
|
| RBAC-UX plane separation | PASS | PASS | `/admin` and `/system` remain separate; no cross-plane bypass is introduced. |
|
||||||
|
| Workspace + tenant isolation | PASS | PASS | Canonical admin routes remain workspace- and tenant-safe; system routes remain platform-scoped only. |
|
||||||
|
| Destructive confirmation standard | PASS | PASS | No new destructive action is introduced. Existing destructive flows remain governed by their originating features. |
|
||||||
|
| Global search safety | PASS | PASS | No global-search behavior changes are part of this slice. |
|
||||||
|
| Run observability / Ops-UX 3-surface contract | PASS | PASS | Existing `OperationRun` truth remains canonical; the feature changes presentation and polling seams only. |
|
||||||
|
| Ops lifecycle ownership | PASS | PASS | `OperationRun.status` and `OperationRun.outcome` remain service-owned; summary surfaces stay read-only. |
|
||||||
|
| Ops summary counts | PASS | PASS | No new `summary_counts` shape or key family is introduced. |
|
||||||
|
| Data minimization / DB-only render | PASS | PASS | Monitoring and dashboard surfaces remain DB-only and do not add render-time external calls. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The design reuses model scopes, presenter seams, and existing route helpers instead of adding a new lifecycle framework. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | No new table, persisted artifact, or top-level state family is added. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | Only a thin derived problem-class split is introduced; it remains derived from existing lifecycle truth. |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and presenter seams remain authoritative for status, outcome, and freshness meaning. |
|
||||||
|
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing widgets, tables, pages, and detail surfaces remain in place; no redundant inspect model is introduced. |
|
||||||
|
| Filament UX-001 | PASS | PASS | Existing detail and list hierarchies remain intact; stale or reconciled truth is elevated inside existing summary structures. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature stays inside the current Filament v5 + Livewire v4 stack. |
|
||||||
|
| Provider registration location | PASS | PASS | No panel or provider change is required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global-search hard rule | PASS | PASS | No globally searchable resource is added or altered. |
|
||||||
|
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment changes are needed. |
|
||||||
|
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds business-truth regression coverage for alignment, visibility, and drill-through continuity rather than thin view-only tests. |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Reuse the existing `OperationRunFreshnessState`, lifecycle policy, reconciliation metadata, and `OperationRun` query scopes as the canonical lifecycle base instead of introducing a second lifecycle or problem-state model.
|
||||||
|
- Introduce a thin derived split between `terminal follow-up` and `active stale/stuck attention` through existing model or presenter seams rather than a new taxonomy framework.
|
||||||
|
- Extend the repo's current conditional polling pattern to `BulkOperationProgress` instead of introducing a new live-refresh mechanism.
|
||||||
|
- Keep `BulkOperationProgress` as an active-only surface: terminal or reconciled runs should disappear from the overlay within one refresh cycle, while recent and attention surfaces carry their follow-up semantics.
|
||||||
|
- Use `/admin/operations` as the sole canonical collection route and preserve problem-class continuity through filter or tab state rather than new routes.
|
||||||
|
- Keep `/system/ops/stuck` focused on active stale candidates, but make reconciled stale lineage explicitly discoverable on system runs, failures, and detail surfaces so the stale truth chain does not disappear after reconciliation.
|
||||||
|
- Elevate stale and reconciled lifecycle truth through the existing canonical decision-zone and guidance seams instead of a new detail surface or banner framework.
|
||||||
|
- Keep notification and entry-point changes narrow by extending the existing `OperationRunCompleted` / `OperationUxPresenter` path instead of redesigning the notification subsystem.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/178-ops-truth-alignment/`:
|
||||||
|
|
||||||
|
- `data-model.md`: existing persistent source truth plus the derived cross-surface truth contracts for this slice
|
||||||
|
- `contracts/operations-truth-alignment.openapi.yaml`: internal route and UI contract for summary buckets, canonical drill-through state, and system/admin monitoring continuity
|
||||||
|
- `quickstart.md`: focused implementation and verification workflow for admin-plane, system-plane, and local-progress truth alignment
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- `OperationRun` remains the only persistent lifecycle source of truth; the design uses existing freshness and reconciliation semantics rather than adding a new table or state family.
|
||||||
|
- The narrowest shared seam is an extension of existing `OperationRun` query scopes and `OperationUxPresenter`-style rendering helpers so dashboard, workspace, monitoring, detail, and notification surfaces can agree on one derived problem-class split.
|
||||||
|
- `BulkOperationProgress` gains the same conditional polling discipline already used elsewhere and remains an active-only affordance rather than becoming a second summary surface.
|
||||||
|
- Dashboard, workspace, and recent-operation surfaces carry problem-class-specific drill-through metadata into `/admin/operations`, where the admin monitoring hub becomes the single canonical collection route for both `terminal follow-up` and `active stale/stuck attention`.
|
||||||
|
- System monitoring stays within the current page family. `Stuck` remains active-stale focused, while `Runs`, `Failures`, and detail surfaces make reconciled stale lineage visible so operators can still recover the stale story after reconciliation.
|
||||||
|
- Canonical run detail hardening happens inside existing summary and decision-zone seams so stale/reconciled attention is promoted without changing routing or page ownership.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/178-ops-truth-alignment/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── operations-truth-alignment.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ │ └── Operations.php
|
||||||
|
│ │ └── Operations/
|
||||||
|
│ │ └── TenantlessOperationRunViewer.php
|
||||||
|
│ ├── System/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── Ops/
|
||||||
|
│ │ ├── Runs.php
|
||||||
|
│ │ ├── Failures.php
|
||||||
|
│ │ ├── Stuck.php
|
||||||
|
│ │ └── ViewRun.php
|
||||||
|
│ └── Widgets/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ ├── DashboardKpis.php
|
||||||
|
│ │ ├── NeedsAttention.php
|
||||||
|
│ │ └── RecentOperations.php
|
||||||
|
│ └── Workspace/
|
||||||
|
│ ├── WorkspaceNeedsAttention.php
|
||||||
|
│ └── WorkspaceRecentOperations.php
|
||||||
|
├── Livewire/
|
||||||
|
│ └── BulkOperationProgress.php
|
||||||
|
├── Models/
|
||||||
|
│ └── OperationRun.php
|
||||||
|
├── Notifications/
|
||||||
|
│ └── OperationRunCompleted.php
|
||||||
|
└── Support/
|
||||||
|
├── Operations/
|
||||||
|
│ ├── OperationLifecyclePolicy.php
|
||||||
|
│ └── OperationRunFreshnessState.php
|
||||||
|
├── OpsUx/
|
||||||
|
│ ├── ActiveRuns.php
|
||||||
|
│ └── OperationUxPresenter.php
|
||||||
|
├── SystemConsole/
|
||||||
|
│ └── StuckRunClassifier.php
|
||||||
|
├── Workspaces/
|
||||||
|
│ └── WorkspaceOverviewBuilder.php
|
||||||
|
└── OperationRunLinks.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
├── views/
|
||||||
|
│ ├── filament/
|
||||||
|
│ │ └── system/
|
||||||
|
│ │ └── pages/
|
||||||
|
│ │ └── ops/
|
||||||
|
│ │ └── view-run.blade.php
|
||||||
|
│ └── livewire/
|
||||||
|
│ └── bulk-operation-progress.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── DashboardKpisWidgetTest.php
|
||||||
|
│ │ ├── NeedsAttentionWidgetTest.php
|
||||||
|
│ │ ├── RecentOperationsSummaryWidgetTest.php
|
||||||
|
│ │ └── WorkspaceOverviewOperationsTest.php
|
||||||
|
│ ├── Monitoring/
|
||||||
|
│ │ ├── MonitoringOperationsTest.php
|
||||||
|
│ │ ├── OperationsDashboardDrillthroughTest.php
|
||||||
|
│ │ ├── OperationsDbOnlyRenderTest.php
|
||||||
|
│ │ ├── OperationsDbOnlyTest.php
|
||||||
|
│ │ └── OperationsTenantScopeTest.php
|
||||||
|
│ ├── Notifications/
|
||||||
|
│ │ └── OperationRunNotificationTest.php
|
||||||
|
│ ├── OpsUx/
|
||||||
|
│ │ └── BulkOperationProgressDbOnlyTest.php
|
||||||
|
│ ├── System/
|
||||||
|
│ │ └── Spec114/
|
||||||
|
│ │ ├── CanonicalRunDetailTest.php
|
||||||
|
│ │ ├── OpsFailuresViewTest.php
|
||||||
|
│ │ ├── OpsStuckViewTest.php
|
||||||
|
│ │ └── OpsTriageActionsTest.php
|
||||||
|
│ ├── Guards/
|
||||||
|
│ │ └── ActionSurfaceContractTest.php
|
||||||
|
│ └── RunAuthorizationTenantIsolationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith structure. The implementation should extend current model scopes, presenters, widgets, monitoring pages, and system pages instead of introducing a new operations-overview layer or new directory family.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Introduce One Shared Derived Problem-Class Contract
|
||||||
|
|
||||||
|
**Goal**: Let every covered surface derive the same operator-facing split between terminal follow-up and active stale/stuck attention from the existing lifecycle truth.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Models/OperationRun.php` | Add narrow query scopes or helpers for `terminal follow-up` and `active stale/stuck attention`, keeping `dashboardNeedsFollowUp()` as a compatibility umbrella if needed. |
|
||||||
|
| A.2 | `app/Support/OpsUx/OperationUxPresenter.php` and existing freshness helpers | Centralize derived problem-class text, stale-lineage wording, and row/detail/notification display decisions using existing freshness and reconciliation truth. |
|
||||||
|
| A.3 | `app/Support/OpsUx/ActiveRuns.php` | Extend or refine active-run polling helpers so summary and progress surfaces can poll only while relevant active runs still exist. |
|
||||||
|
|
||||||
|
### Phase B — Harden Local Progress And Summary Surfaces
|
||||||
|
|
||||||
|
**Goal**: Remove stale local-progress residue and separate terminal problems from active stale attention on tenant and workspace entry surfaces.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Livewire/BulkOperationProgress.php` and `resources/views/livewire/bulk-operation-progress.blade.php` | Add conditional polling, canonical refresh behavior, and active-only visibility so terminal or reconciled runs disappear within one refresh cycle. |
|
||||||
|
| B.2 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Split mixed operations follow-up into explicit terminal vs stale-active buckets with one matching destination each. |
|
||||||
|
| B.3 | `app/Filament/Widgets/Dashboard/RecentOperations.php` | Surface freshness/problem-class truth per row so stale candidates and terminal follow-up do not read like generic recent activity. |
|
||||||
|
| B.4 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` | Mirror the same split and row semantics on workspace surfaces so workspace and tenant summaries speak the same truth grammar. |
|
||||||
|
|
||||||
|
### Phase C — Align Canonical Operations Hub And Drill-Through State
|
||||||
|
|
||||||
|
**Goal**: Make `/admin/operations` the canonical collection route for both problem classes and preserve the originating class through every entry point.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Filament/Pages/Monitoring/Operations.php` | Add or tighten problem-class-aware filters/tabs for `active stale/stuck attention` and `terminal follow-up` without creating a new monitoring page. |
|
||||||
|
| C.2 | `app/Support/OperationRunLinks.php` | Carry tenant-safe problem-class filter state and existing navigation context into canonical operations links from dashboard, workspace, and notification entry points. |
|
||||||
|
| C.3 | Existing summary and recent-operation surfaces | Replace broad `needs follow-up` drill-throughs with explicit problem-class destinations so the landing page confirms the originating operator story. |
|
||||||
|
|
||||||
|
### Phase D — Preserve Stale Truth Across Canonical And System Monitoring Surfaces
|
||||||
|
|
||||||
|
**Goal**: Keep stale/reconciled lineage visible to operators after auto-reconciliation and promote stale/reconciled truth inside the primary decision hierarchy.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | Existing canonical run-detail composition seams under `OperationRunResource` / `TenantlessOperationRunViewer` | Elevate likely-stale and reconciled lifecycle truth inside the existing decision-zone/current-state summary rather than leaving it only in secondary banners or diagnostics. |
|
||||||
|
| D.2 | `app/Filament/System/Pages/Ops/Runs.php`, `Failures.php`, `Stuck.php`, and `ViewRun.php` | Keep `Stuck` focused on active stale candidates while exposing reconciled stale lineage on system runs, failures, and detail so platform operators can still recover the stale story after reconciliation. |
|
||||||
|
| D.3 | `resources/views/filament/system/pages/ops/view-run.blade.php` | Strengthen stale/reconciled visual emphasis in the existing guidance/current-state rendering instead of adding a new detail surface. |
|
||||||
|
|
||||||
|
### Phase E — Keep Notifications And Entry-Point Wording Truthful
|
||||||
|
|
||||||
|
**Goal**: Ensure entry points never frame a run more calmly than its current lifecycle or freshness truth.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `app/Notifications/OperationRunCompleted.php` and existing presenter seams | Preserve problem-class wording and stale-lineage emphasis in terminal notifications and linked entry-point copy. |
|
||||||
|
| E.2 | Existing dashboard/workspace/navigation copy | Keep `needs follow-up` as an umbrella only when the concrete sub-class remains visible and recoverable. |
|
||||||
|
|
||||||
|
### Phase F — Regression Protection And Verification
|
||||||
|
|
||||||
|
**Goal**: Lock the truth contract into tests and preserve DB-only rendering, authorization semantics, and system/admin continuity.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| F.1 | `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `NeedsAttentionWidgetTest.php`, `RecentOperationsSummaryWidgetTest.php`, and `WorkspaceOverviewOperationsTest.php` | Add assertions for terminal-vs-stale separation, row truth, and workspace/tenant summary parity. |
|
||||||
|
| F.2 | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` | Prove polling freshness and active-only visibility without enqueue-event dependence. |
|
||||||
|
| F.3 | `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `OperationsDashboardDrillthroughTest.php`, `OperationsDbOnlyRenderTest.php`, `OperationsDbOnlyTest.php`, `OperationsTenantScopeTest.php`, and `RunAuthorizationTenantIsolationTest.php` | Prove canonical hub filters, drill-through continuity, DB-only rendering, and tenant-safe access semantics. |
|
||||||
|
| F.4 | `tests/Feature/System/Spec114/CanonicalRunDetailTest.php`, `OpsFailuresViewTest.php`, `OpsStuckViewTest.php`, `OpsTriageActionsTest.php`, and `tests/Feature/Notifications/OperationRunNotificationTest.php` | Prove stale/reconciled visibility across system detail, failures, stuck surfaces, and notifications. |
|
||||||
|
| F.5 | `tests/Feature/Guards/ActionSurfaceContractTest.php` plus `vendor/bin/sail bin pint --dirty --format agent` | Preserve surface-contract compliance and formatting before implementation is considered complete. |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Preserve `OperationRunFreshnessState` as the lifecycle base and derive problem class above it
|
||||||
|
|
||||||
|
The repo already has a narrow, useful freshness enum. The plan builds the operator-facing split above that existing truth instead of adding a second enum or persisted state family.
|
||||||
|
|
||||||
|
### D-002 — Use existing model/presenter seams instead of a new taxonomy framework
|
||||||
|
|
||||||
|
The narrowest implementation is to extend `OperationRun` query scopes, `OperationUxPresenter`, and existing route helpers. A new cross-domain classification framework would be disproportionate to the problem.
|
||||||
|
|
||||||
|
### D-003 — Keep `BulkOperationProgress` active-only
|
||||||
|
|
||||||
|
The overlay should not become a second follow-up surface. Once a run is terminal or reconciled, it should leave the overlay and let recent/attention/canonical surfaces tell the rest of the story.
|
||||||
|
|
||||||
|
### D-004 — `/admin/operations` remains the only canonical collection route
|
||||||
|
|
||||||
|
Problem-class continuity should be achieved with filters/tabs and link state, not by introducing tenant-specific or class-specific duplicate routes.
|
||||||
|
|
||||||
|
### D-005 — `Stuck` remains active-stale focused, but stale lineage must survive reconciliation elsewhere in system monitoring
|
||||||
|
|
||||||
|
Widening `/system/ops/stuck` into a mixed registry would blur its purpose. The narrower design keeps active stale candidates there and makes reconciled stale lineage visible on `/system/ops/runs`, `/system/ops/failures`, and system detail.
|
||||||
|
|
||||||
|
### D-006 — Decision-zone emphasis is the canonical fix for stale/reconciled truth
|
||||||
|
|
||||||
|
The canonical detail surface already has the right ownership. The plan promotes stale/reconciled truth inside that existing hierarchy instead of inventing a new banner or side-panel architecture.
|
||||||
|
|
||||||
|
### D-007 — Notification changes stay on the existing `OperationRunCompleted` path
|
||||||
|
|
||||||
|
The feature should extend current terminal notification semantics, not create a new notification subsystem or duplicate entry-point logic elsewhere.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Summary surfaces still share labels but not identical filter meaning | High | Medium | Carry problem-class state through `OperationRunLinks` and verify destination continuity in focused tests. |
|
||||||
|
| Bulk progress stays live too long or polls too often | High | Medium | Reuse the existing conditional polling pattern and verify convergence/stoppage behavior in DB-only tests. |
|
||||||
|
| System surfaces lose stale lineage after reconciliation | High | Medium | Keep stale lineage explicit on system runs, failures, and detail even when `Stuck` remains active-only. |
|
||||||
|
| Canonical detail duplicates stale/reconciled emphasis in multiple equal-priority areas | Medium | Medium | Reuse the existing decision-zone hierarchy and verify emphasis through detail tests instead of adding parallel warning surfaces. |
|
||||||
|
| The thin derived split drifts into a new framework over time | Medium | Low | Keep it constrained to existing model scopes, presenter seams, route helpers, and regression coverage. |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend tenant and workspace summary widget coverage so dashboard and workspace attention surfaces clearly separate terminal follow-up from active stale/stuck attention.
|
||||||
|
- Extend `BulkOperationProgressDbOnlyTest.php` to prove terminal/reconciled runs disappear within one refresh cycle and polling stops when no relevant active runs remain.
|
||||||
|
- Extend canonical monitoring tests so `/admin/operations` filters, row rendering, and drill-throughs preserve problem-class continuity and remain DB-only and tenant-safe.
|
||||||
|
- Extend system-panel tests so `/system/ops/stuck`, `/system/ops/failures`, `/system/ops/runs`, and system detail preserve stale/reconciled lineage in a way platform operators can recover.
|
||||||
|
- Extend notification tests so completed notifications and linked destinations do not frame reconciled or stale-derived terminal runs more calmly than current truth.
|
||||||
|
- Run focused Pest suites through Sail plus `vendor/bin/sail bin pint --dirty --format agent`; full-suite execution is not required for planning artifacts.
|
||||||
|
|
||||||
|
## Post-Design Constitution Re-check
|
||||||
|
|
||||||
|
- `PASS` The design keeps `OperationRun` as the only persistent lifecycle truth and introduces no new schema or state family.
|
||||||
|
- `PASS` The derived problem-class split remains narrow and stays within existing model, presenter, and route-helper seams.
|
||||||
|
- `PASS` Admin-plane and system-plane surfaces stay separated and tenant-safe; no cross-plane leakage path is introduced.
|
||||||
|
- `PASS` The run-detail emphasis work reuses existing decision-zone ownership instead of adding a second detail hierarchy.
|
||||||
|
- `PASS` No new destructive actions, global-search changes, asset registration, or provider registration changes are required.
|
||||||
|
- `PASS` Livewire v4 and Filament v5 compliance remains intact.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution waiver is expected. This slice hardens shared truth semantics and removes drift without introducing new persistence, new orchestration, or a new semantic framework.
|
||||||
155
specs/178-ops-truth-alignment/quickstart.md
Normal file
155
specs/178-ops-truth-alignment/quickstart.md
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# Quickstart: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that tenant, workspace, canonical admin, and system operations surfaces now tell the same lifecycle story for fresh active runs, likely stale active runs, reconciled stale runs, and terminal problem runs, and that local progress surfaces stop implying false activity within one polling cycle.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail.
|
||||||
|
2. Ensure you have one admin-plane user with workspace and tenant access and one platform user with system operations view access.
|
||||||
|
3. Seed or create run scenarios for:
|
||||||
|
- fresh active run
|
||||||
|
- likely stale active run
|
||||||
|
- automatically reconciled stale run
|
||||||
|
- terminal failed run
|
||||||
|
- terminal blocked or partially succeeded run
|
||||||
|
- healthy completed run
|
||||||
|
4. Ensure the seeded runs are visible through:
|
||||||
|
- tenant dashboard operations summary and recent operations
|
||||||
|
- workspace overview operations summary and recent operations
|
||||||
|
- `/admin/operations`
|
||||||
|
- `/admin/operations/{run}`
|
||||||
|
- `/system/ops/stuck`
|
||||||
|
- `/system/ops/failures`
|
||||||
|
- `/system/ops/runs/{run}`
|
||||||
|
5. Prepare at least one scenario where canonical truth changes from active to terminal or reconciled without a new enqueue event so local progress freshness can be verified.
|
||||||
|
|
||||||
|
## Implementation Validation Order
|
||||||
|
|
||||||
|
### 1. Run lifecycle and reconciliation baselines
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Console/ReconcileOperationRunsCommandTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/MonitoringOperationsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Existing stale/reconciliation semantics remain stable.
|
||||||
|
- Canonical monitoring still renders DB-only and tenant-safe.
|
||||||
|
|
||||||
|
### 2. Run tenant/workspace summary and recency coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Tenant and workspace summary surfaces separate terminal follow-up from active stale/stuck attention.
|
||||||
|
- Recent-operation rows distinguish fresh active, stale active, reconciled stale, and terminal follow-up truth.
|
||||||
|
|
||||||
|
### 3. Run local-progress freshness coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- The overlay polls only while relevant active runs exist.
|
||||||
|
- Terminal or reconciled runs disappear within one refresh cycle even without a new enqueue event.
|
||||||
|
|
||||||
|
### 4. Run canonical admin monitoring and drill-through coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsTenantScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- `/admin/operations` preserves tenant-safe problem-class continuity from dashboard/workspace entry points.
|
||||||
|
- Canonical monitoring remains DB-only and authorization-safe.
|
||||||
|
|
||||||
|
### 5. Run system-panel visibility coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/CanonicalRunDetailTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- `/system/ops/stuck` keeps active stale candidates visible.
|
||||||
|
- `/system/ops/failures` and system detail preserve stale/reconciled lineage for terminal runs that were auto-reconciled.
|
||||||
|
|
||||||
|
### 6. Run notification and surface-guard coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Notifications/OperationRunNotificationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Terminal notifications and linked destinations do not frame reconciled or stale-derived terminal runs more calmly than current truth.
|
||||||
|
- Changed surfaces still satisfy their action-surface contracts.
|
||||||
|
|
||||||
|
### 7. Format touched files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Changed files conform to project formatting rules.
|
||||||
|
|
||||||
|
### 8. Re-run the final focused verification pack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/MonitoringOperationsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsTenantScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/CanonicalRunDetailTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Notifications/OperationRunNotificationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Cross-surface truth alignment, stale-lineage visibility, local-progress freshness, and drill-through continuity all remain stable after formatting.
|
||||||
|
|
||||||
|
## Manual Smoke Check
|
||||||
|
|
||||||
|
1. Open `/admin/t/{tenant}` for seeded tenant scenarios representing fresh active, likely stale active, reconciled stale, terminal failure, and healthy completion.
|
||||||
|
2. Confirm tenant summary surfaces do not mix terminal follow-up with active stale/stuck attention.
|
||||||
|
3. Leave BulkOperationProgress visible while one run transitions from active to terminal or reconciled without a new enqueue event and confirm the overlay stops showing it as active within one refresh cycle.
|
||||||
|
4. Click tenant and workspace operations-attention links and confirm `/admin/operations` opens with the same problem class visible on arrival.
|
||||||
|
5. Open a stale-active run in `/admin/operations/{run}` and confirm the decision zone answers whether the run is still active and what to do next.
|
||||||
|
6. Open an auto-reconciled run in `/admin/operations/{run}` and confirm the decision zone answers that the run is no longer active, that reconciliation already happened, and what follow-up is appropriate.
|
||||||
|
7. As a platform user, open `/system/ops/stuck`, `/system/ops/failures`, and `/system/ops/runs/{run}` and confirm stale/reconciled lineage stays discoverable across the system truth chain.
|
||||||
|
8. Open an operation completion notification for a reconciled stale run and confirm the linked destination visibly confirms the same problem class.
|
||||||
|
9. Time a seeded operator walkthrough across tenant dashboard, workspace overview, `/admin/operations`, `/admin/operations/{run}`, and `/system/ops/runs/{run}` and confirm each entry surface makes the run's fresh-active, likely-stale, reconciled, or terminal-follow-up state understandable within 10 seconds.
|
||||||
|
|
||||||
|
## Non-Goals For This Slice
|
||||||
|
|
||||||
|
- No schema migration.
|
||||||
|
- No new lifecycle or problem-state table.
|
||||||
|
- No new route family for tenant-specific operations monitoring.
|
||||||
|
- No notification subsystem redesign.
|
||||||
|
- No tenant-admin retry/cancel capability expansion.
|
||||||
73
specs/178-ops-truth-alignment/research.md
Normal file
73
specs/178-ops-truth-alignment/research.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Phase 0 Research: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
## Decision: Reuse the existing freshness and reconciliation model as the lifecycle base
|
||||||
|
|
||||||
|
**Rationale**: The repo already has `OperationRunFreshnessState`, `OperationLifecyclePolicy`, `likelyStale()` model scope logic, and `context.reconciliation` metadata. The trust problem is not missing lifecycle truth. It is that different surfaces summarize or surface that truth differently. Reusing the current freshness/reconciliation model is narrower and keeps Spec 178 aligned with Spec 160 instead of creating a competing lifecycle layer.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a new persisted problem-state field on `operation_runs`: rejected because the feature is explicitly scoped away from schema change and because the current operator problem is cross-surface drift, not missing stored truth.
|
||||||
|
- Introduce a second enum family for `problem class`: rejected because the current freshness and outcome model already provides enough signal for a derived split.
|
||||||
|
|
||||||
|
## Decision: Introduce a thin derived split between `terminal follow-up` and `active stale/stuck attention`
|
||||||
|
|
||||||
|
**Rationale**: The current main mixing point is `OperationRun::dashboardNeedsFollowUp()`, which ORs terminal failures and `likelyStale()` runs into one bucket. The narrowest correction is to derive two explicit attention classes from existing outcome and freshness truth, not to create a dashboard or monitoring framework.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep `needs follow-up` as the only bucket and add more text: rejected because the spec requires operators to distinguish active stale from terminal problems without guesswork.
|
||||||
|
- Build a new cross-domain attention taxonomy: rejected as disproportionate for a monitoring-only hardening slice.
|
||||||
|
|
||||||
|
## Decision: Extend existing model/query and presenter seams instead of introducing a new helper framework
|
||||||
|
|
||||||
|
**Rationale**: Existing seams already own most of the relevant logic: `OperationRun` scopes own query truth, `OperationUxPresenter` owns operator-facing guidance and notification wording, `ActiveRuns` already controls polling on several surfaces, and `OperationRunLinks` already owns canonical navigation. Extending those seams keeps the change local and consistent with repo bias.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a new reusable classification service or taxonomy registry: rejected because the slice only needs a thin derived split and would risk introducing a semantic framework the constitution explicitly disfavors.
|
||||||
|
- Push all logic into widget-local queries: rejected because that would preserve or worsen the existing drift.
|
||||||
|
|
||||||
|
## Decision: Apply the repo's conditional polling pattern to `BulkOperationProgress`
|
||||||
|
|
||||||
|
**Rationale**: Dashboard and recent-operation widgets already poll only while active runs exist. `BulkOperationProgress` currently refreshes its run list but does not share the same disciplined poll gate. Extending the existing 10-second active-run polling pattern to that component is the narrowest way to remove stale live-progress residue.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep the overlay event-driven only: rejected because the spec explicitly identifies stale UI residue after canonical truth changes without a new enqueue event.
|
||||||
|
- Add aggressive always-on polling: rejected because other repo surfaces already established a conditional active-run-only approach.
|
||||||
|
|
||||||
|
## Decision: Keep `BulkOperationProgress` as an active-only surface
|
||||||
|
|
||||||
|
**Rationale**: The overlay's job is to tell the operator about currently active work. Once a run becomes terminal or reconciled, the canonical follow-up story belongs on recent operations, attention surfaces, the monitoring hub, and detail pages. Removing terminal/reconciled runs from the overlay is narrower and preserves surface roles.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Reclassify terminal or reconciled runs inside the overlay: rejected because it would turn the overlay into a second summary/attention widget with overlapping semantics.
|
||||||
|
- Leave terminal/reconciled runs visible until manual refresh: rejected because it directly violates the spec's trust objective.
|
||||||
|
|
||||||
|
## Decision: Keep `/admin/operations` as the sole canonical collection route and preserve problem class through filter state
|
||||||
|
|
||||||
|
**Rationale**: The repo already standardized canonical operations navigation on `/admin/operations` and `/admin/operations/{run}`. Dashboard, workspace, and notification entry points should carry tenant-safe problem-class filter or tab state into that route instead of opening new collection pages.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add tenant-specific operations collection routes: rejected because the repo already treats canonical operations as workspace-context routes with tenant-safe filtering.
|
||||||
|
- Keep raw route links without filter continuity: rejected because that is the current trust-drift problem.
|
||||||
|
|
||||||
|
## Decision: Keep `/system/ops/stuck` focused on active stale candidates and surface reconciled stale lineage elsewhere in system monitoring
|
||||||
|
|
||||||
|
**Rationale**: The `Stuck` page is already a clean read-only registry of queued/running runs that crossed the lifecycle threshold. Widening it to include completed reconciled runs would blur that page's purpose. The narrower fix is to keep `Stuck` active-only while ensuring `/system/ops/runs`, `/system/ops/failures`, and system detail explicitly reveal when a failed or completed run was auto-reconciled from a stale lifecycle state.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add completed reconciled runs directly to `/system/ops/stuck`: rejected because it would collapse active-stuck and terminal-history semantics into one list.
|
||||||
|
- Leave reconciled stale lineage visible only on admin surfaces: rejected because Spec 178 requires the system truth chain to remain consistent too.
|
||||||
|
|
||||||
|
## Decision: Promote stale/reconciled lifecycle truth through the existing canonical decision-zone seam
|
||||||
|
|
||||||
|
**Rationale**: Spec 164 already established the canonical run detail as a decision-first surface. The right fix is to strengthen that existing decision/current-state zone so it answers active vs reconciled vs terminal clearly. A new banner framework or a separate detail card would just reintroduce duplicated truth.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add more banners above the current page: rejected because it would duplicate lifecycle emphasis rather than integrating it into the canonical decision hierarchy.
|
||||||
|
- Push the lifecycle answer into diagnostics only: rejected because the spec explicitly disallows that.
|
||||||
|
|
||||||
|
## Decision: Keep notification hardening on the existing `OperationRunCompleted` path
|
||||||
|
|
||||||
|
**Rationale**: Terminal notifications already route through `OperationUxPresenter::terminalDatabaseNotification()`. Extending that path to preserve stale/reconciled problem-class wording is the narrowest way to keep entry points aligned with canonical truth.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Introduce a new notification class for reconciled or stale-derived terminal runs: rejected because the spec explicitly avoids a notification redesign.
|
||||||
|
- Leave notifications unchanged and rely on the destination page only: rejected because entry-point semantics must not be calmer than the current truth.
|
||||||
277
specs/178-ops-truth-alignment/spec.md
Normal file
277
specs/178-ops-truth-alignment/spec.md
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# Feature Specification: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
**Feature Branch**: `178-ops-truth-alignment`
|
||||||
|
**Created**: 2026-04-05
|
||||||
|
**Status**: Proposed
|
||||||
|
**Input**: User description: "Spec 178 - Operations Lifecycle Alignment & Cross-Surface Truth Consistency"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + workspace + canonical-view + platform
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin` as the workspace overview surface where workspace attention and workspace recent operations appear
|
||||||
|
- `/admin/t/{tenant}` as the tenant dashboard surface where tenant attention, recent operations, and active progress affordances appear
|
||||||
|
- `/admin/operations` as the canonical monitoring hub and drill-through destination from admin-plane summaries
|
||||||
|
- `/admin/operations/{run}` as the canonical run detail surface
|
||||||
|
- `/system/ops/runs`, `/system/ops/failures`, and `/system/ops/stuck` as the platform-plane monitoring registry surfaces
|
||||||
|
- `/system/ops/runs/{run}` as the platform-plane operation detail surface
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Existing `OperationRun` records remain the only canonical lifecycle source of truth for queued, running, completed, stale, and automatically reconciled runs
|
||||||
|
- Existing workspace-owned monitoring truth with optional tenant linkage remains in place; the feature does not add a second summary record, mirror lifecycle store, or notification-specific state model
|
||||||
|
- Freshness interpretation, stale or reconciled visibility, terminal follow-up grouping, and cross-surface drill-through continuity remain derived views over existing `OperationRun` truth
|
||||||
|
- No schema migration, no new persisted lifecycle state, and no enum rewrite are introduced
|
||||||
|
- **RBAC**:
|
||||||
|
- Admin-plane summary and canonical-view surfaces continue to require workspace membership, and any tenant-bound summary or run detail continues to require tenant entitlement for the referenced tenant
|
||||||
|
- Platform-plane system surfaces continue to rely on existing system operations view and manage capabilities without broadening `/system` access
|
||||||
|
- Non-members or users outside the relevant workspace or tenant scope remain `404`; in-scope users lacking a capability for a guarded follow-up affordance remain `403`
|
||||||
|
- Cross-plane navigation must remain explicit and must not leak tenant truth from admin surfaces into system surfaces or vice versa
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: `/admin/operations` may continue to prefilter to the active tenant, but dashboard, attention, recent-operations, and notification drill-throughs MUST also preserve the originating problem class so operators land on the same issue family they clicked. Operators may broaden filters only within already entitled scope.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Every admin-plane summary claim, pre-applied filter, run detail page, and related drill-through MUST resolve only after workspace membership and tenant entitlement checks against the referenced run. Reconciled, stale, terminal-failure, and follow-up states must not reveal another tenant's existence or activity to unauthorized users.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard operations attention | Embedded attention summary | One explicit problem-class CTA per summary bucket | forbidden | none | none | `/admin/operations` | `/admin/operations/{run}` | Active tenant context and tenant-preserving destination state | Operations / Operation | Separate terminal issues from stale active issues | Multi-bucket summary surface |
|
||||||
|
| Tenant dashboard recent operations | Diagnostic recency table | Row open to canonical operation detail | required | header link only | none | `/admin/operations` with tenant prefilter | `/admin/operations/{run}` | Active tenant context and tenant-scoped recent activity | Operations / Operation | Fresh active, likely stale, and terminal follow-up states remain distinguishable per row | none |
|
||||||
|
| Bulk operation progress | Live progress indicator | Compact item open to canonical operation detail plus collection fallback | compact item link only | collection link only | none | `/admin/operations` with tenant prefilter | `/admin/operations/{run}` | Active tenant context and active-run-only framing | Operations / Operation | Only truly active or still-problematic runs remain visible | Compact progress surface |
|
||||||
|
| Workspace operations attention | Embedded attention summary | One explicit problem-class CTA per summary bucket | forbidden | none | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope plus tenant counts where relevant | Operations / Operation | Separate terminal issues from stale active issues across the workspace | Multi-bucket summary surface |
|
||||||
|
| Workspace recent operations | Diagnostic recency table | Row open to canonical operation detail | required | header link only | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope with tenant identity per row | Operations / Operation | Recent operations do not hide stale or reconciled truth behind generic recency language | none |
|
||||||
|
| Operations hub | Read-only Registry / Report | Full-row click to canonical operation detail | required | filters, tabs, and header-level context only | none on the list | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, tenant filter state, and problem-class filter state | Operations / Operation | Lifecycle truth, freshness truth, and problem class are visible before opening detail | none |
|
||||||
|
| Canonical operation detail | Detail-first operational surface | Dedicated detail page | forbidden | detail header links only | none introduced by this spec | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant context when relevant, and run identity | Operations / Operation | Decision-zone lifecycle truth and next step are visible without opening diagnostics | none |
|
||||||
|
| System failed operations | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none | `/system/ops/failures` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | Terminal-problem truth remains aligned with admin-plane canonical truth | none |
|
||||||
|
| System stuck operations | Read-only Registry / Report | Full-row click to system operation detail | required | header CTA only | none | `/system/ops/stuck` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | Active stale or stuck truth and reconciled visibility remain operator-legible | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard operations attention | Tenant operator | Embedded attention summary | Do I have a terminal issue to follow up or an active run that is likely stale? | Separate counts or labels for terminal follow-up and stale active attention, with one matching destination each | Detailed failure payloads, count internals, and infrastructure evidence | problem class, urgency, tenant scope | none | Open terminal issues, open stale active issues | none |
|
||||||
|
| Tenant dashboard recent operations | Tenant operator | Diagnostic recency table | Which recent tenant operation should I inspect next? | Operation label, lifecycle truth, outcome, freshness truth, and recency | Failure internals, raw summary counts, extended diagnostics | execution status, execution outcome, freshness | none | Open operation detail, open operations list | none |
|
||||||
|
| Bulk operation progress | Tenant operator | Live progress indicator | Is this run really still active, or has its truth changed since enqueue time? | Active run identity, current visible lifecycle truth, and quick path to detail | Low-level progress internals and failure metadata | execution status, freshness, active visibility | none | Open operation detail, open operations list | none |
|
||||||
|
| Workspace operations attention | Workspace operator | Embedded attention summary | Which operation problem class needs workspace-level follow-up first? | Separate terminal issues from stale active issues, with workspace-safe destination semantics | Deep diagnostics remain on operations views | problem class, urgency, workspace spread | none | Open terminal issues, open stale active issues | none |
|
||||||
|
| Workspace recent operations | Workspace operator | Diagnostic recency table | Which operation across the workspace changed meaning recently? | Run identity, tenant, lifecycle truth, and recency | Deeper failure and reconciliation detail remain secondary | execution status, freshness, tenant scope | none | Open operation detail, open operations list | none |
|
||||||
|
| Operations hub | Workspace operator | Read-only Registry / Report | Is this run fresh active, likely stale, reconciled, or a terminal issue, and which bucket am I looking at? | Explicit problem-class framing, lifecycle truth, freshness truth, outcome, tenant or workspace scope | Queue internals, raw context, and extended traces | execution status, execution outcome, freshness, problem class | none | Open operation detail, adjust filter or tab | none |
|
||||||
|
| Canonical operation detail | Workspace operator | Detail-first operational surface | What happened, is the run still active, was it automatically reconciled, and what do I do next? | Primary decision zone with lifecycle assessment, active or not-active answer, reconciliation state, and one primary next step | Raw payloads, detailed failure arrays, and artifact-deep diagnostics | execution status, execution outcome, freshness, operator next action | none | Return to operations, open related artifact or follow-up destination | none introduced by this spec |
|
||||||
|
| System failed operations | Platform operator | Read-only Registry / Report | Which terminal operation issue needs platform investigation first? | Terminal problem class, operation identity, workspace, tenant, and recency | Deep diagnostics remain on system detail | execution outcome, terminal problem class, recency | none | Open operation detail, show all operations | none |
|
||||||
|
| System stuck operations | Platform operator | Read-only Registry / Report | Which active run crossed the stuck threshold or was recently auto-reconciled for that reason? | Stuck or stale class, operation identity, workspace, tenant, and recency | Deep diagnostics remain on system detail | freshness, lifecycle stall state, recency | none | Open operation detail, show all operations | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No
|
||||||
|
- **New persisted entity/table/artifact?**: No
|
||||||
|
- **New abstraction?**: No
|
||||||
|
- **New enum/state/reason family?**: No
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: Yes, but only as a narrow derived monitoring split between `terminal follow-up` and `active stale/stuck attention`, built from existing lifecycle truth rather than new stored state
|
||||||
|
- **Current operator problem**: Operators can currently see the same run framed as normal active progress on one surface, terminal or reconciled on another, invisible on system stuck surfaces, and mixed into a generic follow-up bucket elsewhere
|
||||||
|
- **Existing structure is insufficient because**: Existing surfaces already have valid local logic, but their aggregation, drill-through, and attention language do not consistently tell the same operator story for stale, reconciled, and terminal problem runs
|
||||||
|
- **Narrowest correct implementation**: Reuse the current `OperationRun`, status, outcome, freshness, and reconciliation model, then align summary buckets, filters, drill-throughs, and decision-zone emphasis across existing surfaces without adding persistence or a new lifecycle engine
|
||||||
|
- **Ownership cost**: The codebase takes on shared cross-surface classification rules, copy alignment, and regression coverage to keep dashboard, recent, bulk, admin monitoring, and system monitoring semantics locked together
|
||||||
|
- **Alternative intentionally rejected**: A new persisted problem-state model, an enum rewrite, a notification redesign, or a full operations architecture refactor were rejected because the present issue is truth drift between existing surfaces, not missing core domain structure
|
||||||
|
- **Release truth**: Current-release truth. The feature hardens already shipped lifecycle semantics before more triage or monitoring slices depend on them
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Recover The Same Truth From Every Entry Point (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I want dashboard, attention, recent-operations, monitoring, and system surfaces to describe the same run with the same problem class, so that I do not have to guess which screen is telling the truth.
|
||||||
|
|
||||||
|
**Why this priority**: Cross-surface truth drift is the core trust problem. If the same run reads differently across entry points, every later triage decision becomes suspect.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding fresh active, likely stale, reconciled-failed, and terminal problem runs, then verifying that tenant, workspace, canonical, and system surfaces classify the same run consistently and drill through into matching destinations.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run is canonically `likely_stale`, **When** an operator sees it on tenant attention, workspace attention, recent operations, the operations hub, and canonical detail, **Then** none of those surfaces frame it as an unremarkable normal active run.
|
||||||
|
2. **Given** a run is terminal with a blocked, partial, or failed outcome, **When** an operator reaches it from dashboard or monitoring summaries, **Then** the destination confirms a terminal follow-up problem rather than an active stale issue.
|
||||||
|
3. **Given** a run was automatically reconciled after becoming stale, **When** an operator checks admin monitoring and system monitoring surfaces, **Then** the stale or reconciled history remains discoverable instead of disappearing from the truth chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Trust Live Progress Without Waiting For A New Event (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want local progress and recent-activity surfaces to stop implying that a finished or reconciled run is still active, even when no new enqueue event occurs, so that I can trust what is on screen.
|
||||||
|
|
||||||
|
**Why this priority**: Bulk progress and recent activity are the most immediate trust surfaces. If they lag behind canonical truth, operators see false liveness first.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening active-progress and recent-operations surfaces, changing the underlying run to terminal or reconciled truth without dispatching a new enqueue event, and verifying that local surfaces update within the allowed refresh window and then stop behaving like live active surfaces.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** BulkOperationProgress is open for an active run, **When** the run completes or is automatically reconciled, **Then** the surface stops presenting it as active within the next refresh cycle even if no new enqueue event fires.
|
||||||
|
2. **Given** Recent Operations is visible on a tenant or workspace surface, **When** a displayed run becomes likely stale or terminal, **Then** the row updates to the new truth instead of continuing to imply healthy progress.
|
||||||
|
3. **Given** no relevant active runs remain, **When** the surface reaches that state, **Then** live refresh stops or becomes inactive instead of polling indefinitely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Decide What To Do From The Canonical Detail Surface (Priority: P2)
|
||||||
|
|
||||||
|
As an operator opening canonical run detail, I want the primary decision zone to tell me immediately whether the run is still active, likely stale, already reconciled, or terminal and what the next step is, so that I do not have to derive action from scattered diagnostics.
|
||||||
|
|
||||||
|
**Why this priority**: Canonical detail is the highest-trust surface. If it makes lifecycle attention secondary, summary surfaces cannot reliably inherit the right operator interpretation.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening stale, reconciled, partial, failed, and healthy active runs and verifying that the decision zone makes lifecycle truth and next action visible without relying on banners or secondary panels alone.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run is likely stale but not yet reconciled, **When** the canonical detail page loads, **Then** the primary decision zone states that the run is still non-terminal but likely unhealthy and names the next investigation step.
|
||||||
|
2. **Given** a run has already been automatically reconciled, **When** the canonical detail page loads, **Then** the primary decision zone states that the run is no longer active, that reconciliation already happened, and what follow-up is appropriate.
|
||||||
|
3. **Given** a run type has deeper artifact truth, **When** the canonical detail page loads, **Then** lifecycle truth and next action remain visible before artifact-deep diagnostics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Preserve Problem-Class Continuity In System And Notification Entry Points (Priority: P3)
|
||||||
|
|
||||||
|
As a system or workspace operator, I want notifications and platform monitoring entry points to confirm the same problem class that brought me there, so that I never land on a calmer or differently framed destination than the one I clicked.
|
||||||
|
|
||||||
|
**Why this priority**: Link continuity is where trust drift becomes obvious. If the destination tells a different story, operators stop trusting the product's routing and labels.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by navigating from dashboard KPIs, attention items, recent operations, and operation notifications into admin and system monitoring destinations, then verifying that the originating problem class is visible and recoverable on arrival.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a notification frames a run as needing terminal follow-up, **When** the operator opens the linked destination, **Then** the destination visibly confirms that terminal-problem framing.
|
||||||
|
2. **Given** a dashboard or workspace attention link frames a run as stale active attention, **When** the operator opens the monitoring destination, **Then** the destination visibly confirms the stale active problem class instead of a generic mixed bucket.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A run may move from `likely_stale` to `reconciled_failed` while an operator keeps a local progress surface open; the UI must not continue showing healthy activity after reconciliation.
|
||||||
|
- A run may be removed from the active stuck list after reconciliation; the system truth chain must still expose that it was recently stale or auto-reconciled rather than making the issue disappear.
|
||||||
|
- A run may be terminal with a poor outcome and also belong to an artifact-heavy domain; the page must not bury the lifecycle answer behind artifact diagnostics.
|
||||||
|
- A tenant-scoped summary may link into the canonical operations hub while tenant context is stale or absent; the destination must preserve the correct tenant-safe problem filter or fall back to workspace-safe scope without changing the run's problem class.
|
||||||
|
- Notifications may be generated before a stale run is later reconciled; entry-point language and destinations must not stay calmer than the current run truth.
|
||||||
|
- Run types may differ in artifact richness, but none may diverge on the base question of fresh active, likely stale, reconciled, or terminal follow-up.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, no new queued operation type, and no new persisted operations record. It hardens the truth alignment of existing operations and monitoring surfaces over existing `OperationRun`, freshness, and reconciliation semantics.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature stays deliberately narrow. It adds no new persistence, no new lifecycle table, no new orchestration layer, and no new enum family. The only new semantic split is a derived operator-facing distinction between terminal follow-up and active stale or stuck attention, built from existing status, outcome, freshness, and reconciliation truth.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Existing `OperationRun` records remain subject to the three-surface feedback contract. Toasts remain intent-only. Active awareness remains on allowed progress and monitoring surfaces only. Terminal state transitions remain service-owned. This feature may change how active progress surfaces refresh and how summaries classify runs, but it must not add ad-hoc status mutation or a second terminal lifecycle model. Summary counts remain numeric-only and scheduled or system-run notification rules remain unchanged. Regression coverage MUST prove progress freshness, truth alignment, and reconciled visibility without reintroducing direct state mutation on render surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature spans the admin plane and the platform plane. Admin-plane tenant and workspace surfaces continue to use deny-as-not-found for non-members or non-entitled users, and canonical operation routes continue to authorize from workspace and tenant entitlement before revealing run truth. Platform-plane system monitoring continues to rely on platform capability checks. The feature adds no new mutation, no new destructive action, and no cross-plane bypass. Any in-scope destination affordance that is visible but capability-gated must remain helper-texted or disabled rather than turning into a misleading dead-end link.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Authentication handshake exceptions remain unrelated to operations monitoring and cannot be used to justify stale or reconciled truth drift.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing centralized semantics for operation status, outcome, freshness, and related attention labels remain authoritative. The feature MUST not allow dashboard widgets, recent-operation surfaces, operations hub rows, or notifications to invent page-local meanings for stale, reconciled, blocked, partial, or failed states.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament widgets, tables, detail sections, alerts, tabs, and shared UI primitives. It should strengthen semantic emphasis through existing components and shared mappings, not through page-local markup or a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are operations summary buckets, operation rows, run detail labels, and notification or entry-point copy. `Needs follow-up` may remain as an umbrella concept, but operator-facing copy MUST differentiate the two problem classes it currently mixes: terminal follow-up and active stale or stuck attention. Copy MUST not use a generic `blocked` or `needs follow-up` label for a mixed bucket unless the visible sub-class is also made explicit.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each changed surface keeps one primary inspect or drill-through model. Attention summaries use explicit problem-class destinations. Recent-operation tables keep row-click inspection. The operations hub remains a scan-first registry with explicit problem-class filtering. The canonical detail page remains the highest-trust detail surface. System failed and system stuck lists remain row-click-only registry surfaces. No new destructive action is introduced, and no exception to the action-surface contract is required.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Summary surfaces answer whether the operator is dealing with terminal follow-up or active stale attention. The operations hub answers which bucket the operator is in and what it means. Canonical detail answers what happened, whether the run is still active, and what to do next before showing diagnostics. System surfaces answer which platform-visible failure or stuck class is being surfaced without requiring the operator to infer it from raw context.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from canonical run truth to UI remains preferred. The feature may add a thin derived problem-class split, but it must not create redundant truth across persisted records, presenters, summaries, notifications, and system surfaces. Tests MUST focus on operator-visible consequences: whether the same run tells the same story across surfaces and whether drill-through preserves that story.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. No new View actions, no empty action groups, and no list-level destructive controls are introduced. Changed dashboard and monitoring surfaces remain inspection or drill-through surfaces only. UI-FIL-001 remains satisfied with no exemption.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** The canonical run detail page keeps one primary decision zone and must elevate stale or reconciled lifecycle truth inside that decision zone rather than only in side banners or lower sections. Summary surfaces keep operator priority order: problem class first, recency and diagnostics second. Existing tables continue to support search, sort, and filtering on core lifecycle dimensions.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-178-001**: The system MUST treat canonical `OperationRun` lifecycle and freshness truth as authoritative for every summary, list, detail, and notification surface covered by this feature.
|
||||||
|
- **FR-178-002**: The same run MUST NOT appear as fresh normal activity on one covered surface and as likely stale, reconciled, or terminal problem truth on another covered surface at the same time.
|
||||||
|
- **FR-178-003**: Covered admin and system monitoring surfaces MUST use one shared derived lifecycle interpretation that distinguishes at least `fresh_active`, `likely_stale`, `reconciled_failed`, and `terminal_normal` without introducing a new persisted state model.
|
||||||
|
- **FR-178-004**: Reconciliation behavior and system stuck monitoring MUST remain semantically aligned so stale runs do not disappear from operator truth once they are auto-reconciled.
|
||||||
|
- **FR-178-005**: Automatically reconciled stale runs MUST remain semantically discoverable for operators on admin monitoring or system monitoring surfaces within one navigation step.
|
||||||
|
- **FR-178-006**: Bulk operation progress surfaces MUST refresh while relevant active runs exist and MUST stop presenting a run as active once canonical truth becomes terminal or reconciled.
|
||||||
|
- **FR-178-007**: Bulk operation progress surfaces MUST remove or reclassify terminal or reconciled runs within one refresh cycle even when no new enqueue event occurs.
|
||||||
|
- **FR-178-008**: Recent Operations surfaces on tenant and workspace pages MUST distinguish fresh active runs, likely stale active runs, and terminal follow-up runs rather than flattening them into generic recency.
|
||||||
|
- **FR-178-009**: Tenant and workspace attention surfaces MUST separate terminal follow-up from active stale or stuck attention instead of mixing them into one undifferentiated bucket.
|
||||||
|
- **FR-178-010**: The operations hub MUST expose an explicit monitoring view, filter, or tab for active but likely stale runs and an explicit view, filter, or tab for terminal follow-up runs.
|
||||||
|
- **FR-178-011**: Dashboard, attention, KPI, and recent-operation drill-throughs into the operations hub MUST preserve the originating problem class in visible destination framing.
|
||||||
|
- **FR-178-012**: The canonical run detail page MUST present stale, reconciled, and terminal-problem lifecycle truth inside the primary decision zone rather than only in secondary banners, side panels, or lower diagnostic sections.
|
||||||
|
- **FR-178-013**: For likely stale and reconciled runs, the primary decision zone MUST answer whether the run is still active, whether automatic reconciliation already happened, and what the primary next step is.
|
||||||
|
- **FR-178-014**: Local summary and progress surfaces MUST reuse centralized status, outcome, freshness, and problem-class semantics rather than page-local mappings.
|
||||||
|
- **FR-178-015**: Notification and in-app entry-point language MUST NOT frame a run more calmly than its current lifecycle or freshness truth.
|
||||||
|
- **FR-178-016**: Cross-links from dashboard KPIs, attention surfaces, recent operations, and notifications MUST land on destination surfaces that visibly confirm the same problem class that initiated the navigation.
|
||||||
|
- **FR-178-017**: The feature MUST use the existing `OperationRun`, status, outcome, freshness, and reconciliation model without introducing a schema migration, a new persisted lifecycle artifact, or an enum rewrite.
|
||||||
|
- **FR-178-018**: Run-type differences MAY preserve deeper artifact truth, but they MUST NOT change the base lifecycle answers of fresh active, likely stale, reconciled, or terminal follow-up.
|
||||||
|
- **FR-178-019**: Regression coverage MUST prove that the same seeded runs are classified consistently across tenant dashboard, workspace overview, operations hub, canonical run detail, and system failed or stuck surfaces.
|
||||||
|
- **FR-178-020**: Regression coverage MUST prove bulk-progress freshness, reconciliation visibility, drill-through continuity, and decision-zone emphasis for stale or reconciled runs.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard operations attention | `/admin/t/{tenant}` dashboard | none | Explicit problem-class CTA per bucket | none | none | Existing healthy fallback remains read-only reassurance only when no operations issue exists | n/a | n/a | no new audit behavior | Summary surface only; must not render mixed problem buckets |
|
||||||
|
| Tenant dashboard recent operations | `/admin/t/{tenant}` dashboard | `Open operations` | Row click to canonical operation detail | none | none | Existing diagnostic empty state remains non-primary | n/a | n/a | no new audit behavior | Recency surface; no destructive actions |
|
||||||
|
| Workspace operations attention | `/admin` workspace overview | none | Explicit problem-class CTA per bucket | none | none | Existing healthy fallback remains read-only reassurance only when no operations issue exists | n/a | n/a | no new audit behavior | Summary surface only; must not render mixed problem buckets |
|
||||||
|
| Workspace recent operations | `/admin` workspace overview | `Open operations` | Row click to canonical operation detail | none | none | Existing diagnostic empty state remains non-primary | n/a | n/a | no new audit behavior | Recency surface; no destructive actions |
|
||||||
|
| Operations hub | `/admin/operations` | Filter or tab controls only; no new destructive actions | Full-row click to canonical operation detail | none | none | Existing empty state remains explanatory and filter-aware | n/a | n/a | no new audit behavior | Scan-first registry surface; problem-class filters must align with summary entry points |
|
||||||
|
| Canonical operation detail | `/admin/operations/{run}` | `Back to operations` plus existing related navigation only | n/a | n/a | n/a | n/a | Existing related navigation only; no new destructive action introduced by this spec | n/a | no new audit behavior | Decision-zone truth is the hardening target |
|
||||||
|
| System failed operations | `/system/ops/failures` | `Show all operations` | Full-row click to system operation detail | none | none | `Show all operations` | n/a | n/a | no new audit behavior | Must confirm terminal-problem semantics, not generic follow-up |
|
||||||
|
| System stuck operations | `/system/ops/stuck` | `Show all operations` | Full-row click to system operation detail | none | none | `Show all operations` | n/a | n/a | no new audit behavior | Must preserve stale or reconciled visibility for platform operators |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Operation Run**: The canonical operational record whose status, outcome, freshness, and reconciliation context define the authoritative lifecycle truth.
|
||||||
|
- **Freshness State**: The derived lifecycle interpretation that distinguishes fresh active work, likely stale work, reconciled failure, and normal terminal completion without adding new persisted state.
|
||||||
|
- **Problem Class**: The operator-facing split between terminal follow-up and active stale or stuck attention, derived from existing lifecycle truth and used to align summary surfaces and drill-throughs.
|
||||||
|
- **Drill-through Contract**: The promise that a summary count, notification, or attention label can be visibly rediscovered on the destination surface it opens.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-178-001**: In covered regression scenarios, 100% of runs seeded as `likely_stale` are shown as stale or otherwise problematic on every covered summary and monitoring surface, and 0 are shown as unremarkable fresh activity.
|
||||||
|
- **SC-178-002**: In covered regression scenarios, 100% of automatically reconciled stale runs remain semantically recoverable for operators through admin monitoring or system monitoring within one navigation step.
|
||||||
|
- **SC-178-003**: In covered freshness regression scenarios, local progress surfaces stop showing terminal or reconciled runs as active within one refresh cycle and without requiring a new enqueue event.
|
||||||
|
- **SC-178-004**: In covered navigation regression scenarios, 100% of dashboard, attention, recent-operation, and notification drill-throughs land on destinations whose visible framing matches the originating problem class.
|
||||||
|
- **SC-178-005**: In operator review on seeded scenarios, an operator can determine within 10 seconds whether the run is fresh active, likely stale, reconciled, or terminal follow-up from every covered entry surface.
|
||||||
|
- **SC-178-006**: The feature ships without a schema migration, a new persisted lifecycle artifact, or a new status or outcome family.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing lifecycle freshness and reconciliation semantics from the operation lifecycle guarantees work remain the authoritative base truth for this hardening slice.
|
||||||
|
- Existing run-detail decision-zone structure remains the correct place to elevate stale and reconciled lifecycle truth.
|
||||||
|
- Existing tenant and workspace dashboard truth alignment work remains the baseline grammar for admin-plane summary surfaces.
|
||||||
|
- Existing system operations surface alignment remains the baseline interaction model for `/system/ops/failures` and `/system/ops/stuck`.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introducing tenant-admin retry or cancel capabilities
|
||||||
|
- Rebuilding the operations domain, run schema, or lifecycle engine
|
||||||
|
- Adding a new persisted problem-state model, enum rewrite, or schema migration
|
||||||
|
- Redesigning all notification behavior across the product
|
||||||
|
- Performing deep non-governance result-quality analysis for every run type
|
||||||
|
- Replacing run-type-specific artifact truth with a uniform artifact model
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing operations auto-refresh behavior and active-run polling patterns
|
||||||
|
- Existing operation lifecycle guarantees, freshness thresholds, and reconciliation behavior
|
||||||
|
- Existing canonical run detail hierarchy and decision-zone structure
|
||||||
|
- Existing tenant dashboard and workspace overview truth-alignment semantics
|
||||||
|
- Existing system operations surface alignment for row-click-only platform monitoring pages
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- If stale thresholds are too aggressive, legitimate long-running work could be surfaced as stale too early.
|
||||||
|
- If summary and monitoring surfaces share labels but not the same underlying filter meaning, operators will continue to mistrust drill-throughs.
|
||||||
|
- If reconciled stale visibility is over-corrected without hierarchy, system surfaces could become noisy instead of trustworthy.
|
||||||
|
- If local progress polling is too eager, the product could gain freshness at the cost of unnecessary load.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 178 is complete when:
|
||||||
|
|
||||||
|
- BulkOperationProgress no longer leaves trust-damaging stale residue that keeps terminal or reconciled runs looking active.
|
||||||
|
- stale or stuck semantics are consistent between lifecycle reconciliation, tenant and workspace summaries, the operations hub, canonical run detail, and system stuck or failure surfaces.
|
||||||
|
- tenant and workspace summary surfaces visibly separate terminal problem runs from active stale or stuck runs.
|
||||||
|
- the operations hub no longer distorts dashboard semantics through mixed or misleading tabs, filters, or bucket names.
|
||||||
|
- the canonical run detail page prioritizes stale or reconciled lifecycle truth inside the primary decision zone.
|
||||||
|
- cross-surface links preserve the same operator-visible problem class from origin to destination.
|
||||||
|
- focused regression coverage proves truth alignment, stale visibility, drill-through continuity, and progress freshness.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This feature is a late-foundation hardening slice for the operations domain. The underlying lifecycle model is already strong: `OperationRun` is canonical, status and outcome are separated, stale reconciliation exists, system stuck surfaces exist, and canonical run detail already owns the deepest operational truth. The remaining problem is not missing architecture; it is trust drift between surfaces that summarize or relabel that truth.
|
||||||
|
|
||||||
|
Spec 178 closes that gap by making every covered surface tell the same story about whether a run is still active, likely stale, already reconciled, or terminal and in need of follow-up. It keeps the model narrow by reusing existing lifecycle and freshness truth, then aligning summaries, live progress, drill-throughs, and decision-zone emphasis so operators do not have to reconcile conflicting screens by hand.
|
||||||
256
specs/178-ops-truth-alignment/tasks.md
Normal file
256
specs/178-ops-truth-alignment/tasks.md
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
# Tasks: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/178-ops-truth-alignment/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/operations-truth-alignment.openapi.yaml`
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, `tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `tests/Feature/Notifications/OperationRunNotificationTest.php`, `tests/Feature/System/Spec114/CanonicalRunDetailTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, `tests/Feature/System/Spec114/OpsStuckViewTest.php`, `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, and `tests/Feature/RunAuthorizationTenantIsolationTest.php`.
|
||||||
|
**Operations**: This feature does not create a new `OperationRun` type or change lifecycle ownership. Tasks must keep `OperationRun` as the only canonical truth, keep active awareness on existing progress and monitoring surfaces, preserve exactly-once terminal notification behavior via `app/Notifications/OperationRunCompleted.php`, and avoid any new queued/running database notifications.
|
||||||
|
**RBAC**: Existing admin-plane and system-plane authorization remains authoritative. Tasks must preserve tenant-safe `/admin/operations` filter continuity, maintain `404` for non-member/non-entitled scope access, maintain `403` for in-scope capability failures where applicable, and avoid any cross-plane leakage.
|
||||||
|
**Operator Surfaces**: Tenant dashboard attention and recency, workspace attention and recency, `BulkOperationProgress`, `/admin/operations`, `/admin/operations/{run}`, `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, and `/system/ops/runs/{run}` must stay operator-first and expose lifecycle/problem-class truth by default.
|
||||||
|
**Filament UI Action Surfaces**: This feature changes summary, registry, and detail surfaces only. No new destructive actions, no empty action groups, and no redundant inspect affordances are introduced. Row-click/detail ownership and CTA semantics must remain aligned with the existing Action Surface Contract.
|
||||||
|
**Filament UI UX-001**: No new create/edit/view CRUD pages are introduced. Existing widgets, monitoring pages, and system pages keep their current layout while stale/reconciled emphasis is hardened inside current summary and decision-zone structures.
|
||||||
|
**Badges**: Existing centralized status, outcome, and freshness semantics remain authoritative. Do not introduce page-local badge mappings.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Lock the implementation targets to the generated design artifacts and current runtime seams before editing behavior.
|
||||||
|
|
||||||
|
- [X] T001 Reconfirm the aligned surface contract and verification pack in `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml` and `specs/178-ops-truth-alignment/quickstart.md` before touching runtime files.
|
||||||
|
- [X] T002 Inspect the current lifecycle-truth touchpoints in `app/Models/OperationRun.php`, `app/Support/OpsUx/OperationUxPresenter.php`, `app/Support/OpsUx/ActiveRuns.php`, `app/Support/OperationRunLinks.php`, `app/Livewire/BulkOperationProgress.php`, `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, and `app/Filament/System/Pages/Ops/ViewRun.php` so every story maps to an existing seam.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared truth-derivation, filter-state, and guard baselines that every story depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should be considered complete until these shared seams are in place.
|
||||||
|
|
||||||
|
- [X] T003 Update the shared cross-surface regression baseline in `tests/Feature/Monitoring/MonitoringOperationsTest.php` and `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` so stale-active versus terminal-follow-up continuity can be asserted before story-specific rendering changes.
|
||||||
|
- [X] T004 [P] Update guard and authorization baseline coverage in `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/RunAuthorizationTenantIsolationTest.php` so tenant-safe filter continuity, inspect semantics, and `404` versus `403` expectations are locked before surface edits.
|
||||||
|
- [X] T005 [P] Implement the shared derived problem-class and stale-lineage helpers in `app/Models/OperationRun.php`, `app/Support/OpsUx/OperationUxPresenter.php`, and `app/Support/OpsUx/ActiveRuns.php`.
|
||||||
|
- [X] T006 [P] Implement canonical problem-class drill-through and filter-state support in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: The shared truth contract, canonical drill-through state, and guard expectations are ready for story work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Recover The Same Truth From Every Entry Point (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make tenant, workspace, canonical admin, and system monitoring surfaces classify the same run with the same problem class and drill-through framing.
|
||||||
|
|
||||||
|
**Independent Test**: Seed fresh active, likely stale, reconciled-failed, and terminal-problem runs, then verify tenant/workspace summaries, `/admin/operations`, `/admin/operations/{run}`, `/system/ops/failures`, and `/system/ops/stuck` all surface the same lifecycle story and matching destinations.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Add tenant/workspace summary bucket and row-truth assertions in `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, and `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`.
|
||||||
|
- [X] T008 [P] [US1] Add admin/system list truth-alignment assertions for stale lineage and problem-class filters in `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, `tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, and `tests/Feature/System/Spec114/OpsStuckViewTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [P] [US1] Split tenant terminal-follow-up versus active-stale attention buckets in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`.
|
||||||
|
- [X] T010 [P] [US1] Render problem-class-aware recent-operation rows on the tenant dashboard in `app/Filament/Widgets/Dashboard/RecentOperations.php`.
|
||||||
|
- [X] T011 [P] [US1] Mirror the same bucket and row truth on workspace surfaces in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`.
|
||||||
|
- [X] T012 [US1] Align canonical admin and system monitoring list surfaces around the shared problem-class contract in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, and `app/Filament/System/Pages/Ops/Stuck.php`.
|
||||||
|
- [X] T013 [US1] Run focused US1 verification from `specs/178-ops-truth-alignment/quickstart.md` against the Filament, monitoring, and system test files updated in T007 and T008.
|
||||||
|
|
||||||
|
**Checkpoint**: Cross-surface list and summary entry points now tell the same stale, reconciled, and terminal truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Trust Live Progress Without Waiting For A New Event (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make local progress and recency surfaces converge on canonical truth within one polling cycle even when no new enqueue event is emitted.
|
||||||
|
|
||||||
|
**Independent Test**: Keep `BulkOperationProgress` open while a run changes from active to terminal or reconciled without a new enqueue event, then verify the overlay stops treating it as active and recent-operation rows refresh to the new truth.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T014 [P] [US2] Add active-only polling and no-new-enqueue convergence assertions in `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`.
|
||||||
|
- [X] T015 [P] [US2] Add recent-operations freshness-update assertions for tenant/workspace surfaces in `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` and `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [US2] Implement conditional polling, active-only snapshots, and terminal/reconciled removal in `app/Livewire/BulkOperationProgress.php` and `resources/views/livewire/bulk-operation-progress.blade.php`.
|
||||||
|
- [X] T017 [US2] Tighten active-run polling gates and visibility decisions for local progress in `app/Support/OpsUx/ActiveRuns.php` and `app/Models/OperationRun.php`.
|
||||||
|
- [X] T018 [US2] Re-render tenant/workspace recent-operation freshness after canonical truth changes in `app/Filament/Widgets/Dashboard/RecentOperations.php`, `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`.
|
||||||
|
- [X] T019 [US2] Run focused US2 verification from `specs/178-ops-truth-alignment/quickstart.md` against `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, and `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Local progress and recency surfaces no longer show false activity after canonical truth changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Decide What To Do From The Canonical Detail Surface (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make the canonical decision zone explain current lifecycle truth, reconciliation state, and primary next step before artifact-deep diagnostics.
|
||||||
|
|
||||||
|
**Independent Test**: Open stale, reconciled, partial, failed, and healthy active runs and verify the primary decision zone answers whether the run is still active, whether reconciliation already happened, and what to do next without relying on secondary diagnostics.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [P] [US3] Add canonical admin detail decision-zone assertions for stale, reconciled, terminal, and artifact-rich runs in `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php` and `tests/Feature/Monitoring/MonitoringOperationsTest.php`.
|
||||||
|
- [X] T021 [P] [US3] Add system detail decision-zone priority assertions, including artifact-rich run behavior and stale-lineage triage context, in `tests/Feature/System/Spec114/CanonicalRunDetailTest.php` and `tests/Feature/System/Spec114/OpsTriageActionsTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T022 [US3] Elevate stale/reconciled lifecycle truth and next-step guidance in the canonical admin detail composition in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `app/Support/OpsUx/OperationUxPresenter.php`.
|
||||||
|
- [X] T023 [US3] Surface the same decision-zone contract on the system detail page in `app/Filament/System/Pages/Ops/ViewRun.php` and `resources/views/filament/system/pages/ops/view-run.blade.php`.
|
||||||
|
- [X] T024 [US3] Align detail copy so lifecycle truth stays above artifact-deep diagnostics in `app/Support/OpsUx/OperationUxPresenter.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `app/Filament/System/Pages/Ops/ViewRun.php`.
|
||||||
|
- [X] T025 [US3] Run focused US3 verification from `specs/178-ops-truth-alignment/quickstart.md` against `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `tests/Feature/System/Spec114/CanonicalRunDetailTest.php`, and `tests/Feature/System/Spec114/OpsTriageActionsTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Canonical detail surfaces answer the operator's first lifecycle question before any deep diagnostics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Preserve Problem-Class Continuity In System And Notification Entry Points (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Keep notifications and platform entry points visibly aligned with the same problem class that initiated navigation.
|
||||||
|
|
||||||
|
**Independent Test**: Enter the flow from dashboard/workspace attention, recent operations, and notifications, then verify the destination preserves the same problem class, stale lineage, and authorization-safe context on arrival.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [X] T026 [P] [US4] Add notification problem-class wording and linked-destination continuity assertions in `tests/Feature/Notifications/OperationRunNotificationTest.php` and `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`.
|
||||||
|
- [X] T027 [P] [US4] Add reconciled stale-lineage visibility and plane-safe navigation assertions in `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, `tests/Feature/System/Spec114/OpsStuckViewTest.php`, and `tests/Feature/RunAuthorizationTenantIsolationTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T028 [US4] Preserve problem-class wording and stale-lineage cues in `app/Notifications/OperationRunCompleted.php` and `app/Support/OpsUx/OperationUxPresenter.php`.
|
||||||
|
- [X] T029 [US4] Preserve tenant-safe problem-class landing state from dashboard/workspace/notification entry points in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`.
|
||||||
|
- [X] T030 [US4] Make reconciled stale lineage visible across platform entry surfaces in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, and `app/Filament/System/Pages/Ops/ViewRun.php`.
|
||||||
|
- [X] T031 [US4] Run focused US4 verification from `specs/178-ops-truth-alignment/quickstart.md` against `tests/Feature/Notifications/OperationRunNotificationTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/System/Spec114/OpsFailuresViewTest.php`, `tests/Feature/System/Spec114/OpsStuckViewTest.php`, and `tests/Feature/RunAuthorizationTenantIsolationTest.php`.
|
||||||
|
|
||||||
|
**Checkpoint**: Notifications and system/admin entry points preserve the same problem-class story through to their destinations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish shared copy alignment, formatting, and final verification across all stories.
|
||||||
|
|
||||||
|
- [X] T032 [P] Align remaining operator-facing truth labels and helper copy in `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Widgets/Dashboard/RecentOperations.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `app/Filament/System/Pages/Ops/ViewRun.php`, `app/Notifications/OperationRunCompleted.php`, and `resources/views/filament/system/pages/ops/view-run.blade.php`.
|
||||||
|
- [X] T033 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` for the runtime and test files touched by Spec 178 using `specs/178-ops-truth-alignment/quickstart.md`.
|
||||||
|
- [X] T034 Run the full focused verification pack in `specs/178-ops-truth-alignment/quickstart.md` against the Filament, OpsUx, Monitoring, System, Notification, Guard, and authorization tests touched by this feature.
|
||||||
|
- [X] T035 Validate the final behavior against `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml` and `specs/178-ops-truth-alignment/quickstart.md` before handoff.
|
||||||
|
- [X] T036 Run the timed 10-second operator-comprehension smoke check from `specs/178-ops-truth-alignment/spec.md` and `specs/178-ops-truth-alignment/quickstart.md` across tenant, workspace, admin, and system entry surfaces before handoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and establishes the shared truth/guard baseline.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational.
|
||||||
|
- **User Story 4 (Phase 6)**: Depends on Foundational and should follow User Story 1 so destination problem-class framing already exists.
|
||||||
|
- **Polish (Phase 7)**: Depends on the desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Can start immediately after Phase 2 and is the recommended MVP.
|
||||||
|
- **US2 (P1)**: Can start after Phase 2 and remains independently testable, though it reuses the same shared problem-class helpers as US1.
|
||||||
|
- **US3 (P2)**: Can start after Phase 2 because it focuses on detail-surface emphasis rather than summary routing.
|
||||||
|
- **US4 (P3)**: Should start after US1 and US3 stabilize the visible destination framing used by notifications and platform entry points.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Add or update story-specific tests first and make them fail for the intended behavior.
|
||||||
|
- Apply runtime source changes next.
|
||||||
|
- Run the smallest focused verification pack before moving to another story.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- `T004`, `T005`, and `T006` can run in parallel after `T003` establishes the shared monitoring baseline.
|
||||||
|
- `T007` and `T008` can run in parallel for User Story 1.
|
||||||
|
- `T009`, `T010`, and `T011` can run in parallel for User Story 1 once the foundational helpers land.
|
||||||
|
- `T014` and `T015` can run in parallel for User Story 2.
|
||||||
|
- `T016` and `T017` can run in parallel for User Story 2 because they touch the Livewire surface versus shared polling helpers.
|
||||||
|
- `T020` and `T021` can run in parallel for User Story 3.
|
||||||
|
- `T022` and `T023` can run in parallel for User Story 3.
|
||||||
|
- `T026` and `T027` can run in parallel for User Story 4.
|
||||||
|
- `T028`, `T029`, and `T030` can run in parallel for User Story 4 once the tests are in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T007 tests/Feature/Filament/DashboardKpisWidgetTest.php, tests/Feature/Filament/NeedsAttentionWidgetTest.php, tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php, tests/Feature/Filament/WorkspaceOverviewOperationsTest.php"
|
||||||
|
Task: "T008 tests/Feature/Monitoring/OperationsDbOnlyTest.php, tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/System/Spec114/OpsFailuresViewTest.php, tests/Feature/System/Spec114/OpsStuckViewTest.php"
|
||||||
|
Task: "T009 app/Filament/Widgets/Dashboard/DashboardKpis.php and app/Filament/Widgets/Dashboard/NeedsAttention.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T014 tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php"
|
||||||
|
Task: "T015 tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php and tests/Feature/Filament/WorkspaceOverviewOperationsTest.php"
|
||||||
|
Task: "T016 app/Livewire/BulkOperationProgress.php and resources/views/livewire/bulk-operation-progress.blade.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T020 tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php and tests/Feature/Monitoring/MonitoringOperationsTest.php"
|
||||||
|
Task: "T021 tests/Feature/System/Spec114/CanonicalRunDetailTest.php and tests/Feature/System/Spec114/OpsTriageActionsTest.php"
|
||||||
|
Task: "T023 app/Filament/System/Pages/Ops/ViewRun.php and resources/views/filament/system/pages/ops/view-run.blade.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 4
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Task: "T026 tests/Feature/Notifications/OperationRunNotificationTest.php and tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php"
|
||||||
|
Task: "T027 tests/Feature/System/Spec114/OpsFailuresViewTest.php, tests/Feature/System/Spec114/OpsStuckViewTest.php, and tests/Feature/RunAuthorizationTenantIsolationTest.php"
|
||||||
|
Task: "T028 app/Notifications/OperationRunCompleted.php and app/Support/OpsUx/OperationUxPresenter.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Validate the cross-surface truth-alignment slice with the focused US1 verification run before touching local progress or detail emphasis.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Finish Setup + Foundational to lock shared truth derivation, canonical filter state, and guard expectations.
|
||||||
|
2. Deliver User Story 1 for summary/list truth alignment.
|
||||||
|
3. Deliver User Story 2 for local progress freshness and active-only convergence.
|
||||||
|
4. Deliver User Story 3 for canonical decision-zone hardening.
|
||||||
|
5. Deliver User Story 4 for notification/system entry-point continuity.
|
||||||
|
6. Finish with Phase 7 polish, formatting, full focused verification, and the timed operator-comprehension smoke check.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One engineer can take the Foundational shared helper work while another updates the shared guard/monitoring baseline tests.
|
||||||
|
2. After Phase 2:
|
||||||
|
- Engineer A can take US1 summary and monitoring alignment.
|
||||||
|
- Engineer B can take US2 local progress and recency freshness.
|
||||||
|
- Engineer C can prepare US3 decision-zone tests.
|
||||||
|
3. US4 should land after destination framing is stable, then Phase 7 closes the feature with shared verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks touch different files or are safe concurrent work once their dependencies are met.
|
||||||
|
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the user stories in `spec.md`.
|
||||||
|
- The suggested MVP scope is Phase 1 through Phase 3.
|
||||||
|
- This plan intentionally avoids schema changes, provider-registration changes, new assets, new destructive actions, and any second lifecycle model.
|
||||||
@ -54,6 +54,8 @@
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$operationsIndexUrl = route('admin.operations.index');
|
||||||
|
|
||||||
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||||
|
|
||||||
$page
|
$page
|
||||||
@ -66,7 +68,9 @@
|
|||||||
->assertSee(OperationRunLinks::openLabel())
|
->assertSee(OperationRunLinks::openLabel())
|
||||||
->assertSee(ViewTenant::verificationHeaderActionLabel())
|
->assertSee(ViewTenant::verificationHeaderActionLabel())
|
||||||
->assertDontSee('Start verification')
|
->assertDontSee('Start verification')
|
||||||
->click(OperationRunLinks::openCollectionLabel())
|
->assertScript("Array.from(document.querySelectorAll('a[href=\"{$operationsIndexUrl}\"]')).some((element) => element.textContent?.includes('Open operations'))", true);
|
||||||
|
|
||||||
|
visit($operationsIndexUrl)
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertRoute('admin.operations.index');
|
->assertRoute('admin.operations.index');
|
||||||
|
|
||||||
@ -87,7 +91,7 @@
|
|||||||
visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertSee('Verification report')
|
->assertSee('Verification report')
|
||||||
->assertSee('No verification operation has been started yet.')
|
->assertSee('No provider verification check has been recorded yet.')
|
||||||
->assertSee('Start verification')
|
->assertSee('Start verification')
|
||||||
->assertDontSee(OperationRunLinks::openLabel());
|
->assertDontSee(OperationRunLinks::openLabel());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
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\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
@ -146,6 +147,10 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
|
||||||
|
$basisRunUrl = OperationRunLinks::view($run, $tenant);
|
||||||
|
$inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
|
||||||
|
|
||||||
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
|
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
|
||||||
|
|
||||||
$searchPage
|
$searchPage
|
||||||
@ -164,7 +169,7 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
|
|||||||
->assertSee('Browser Inventory 01')
|
->assertSee('Browser Inventory 01')
|
||||||
->assertDontSee('Browser Inventory 02');
|
->assertDontSee('Browser Inventory 02');
|
||||||
|
|
||||||
$page = visit(InventoryCoverage::getUrl(tenant: $tenant));
|
$page = visit($coverageUrl);
|
||||||
|
|
||||||
$page
|
$page
|
||||||
->waitForText('Tenant coverage truth')
|
->waitForText('Tenant coverage truth')
|
||||||
@ -177,21 +182,21 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertSee('Conditional Access')
|
->assertSee('Conditional Access')
|
||||||
->assertScript("document.querySelector('input[placeholder=\"Search by type or label\"]')?.value === 'Conditional Access'", true)
|
->assertScript("document.querySelector('input[placeholder=\"Search by type or label\"]')?.value === 'Conditional Access'", true)
|
||||||
->click('Open basis run')
|
->assertScript("Array.from(document.querySelectorAll('a[href=\"{$basisRunUrl}\"]')).some((element) => element.textContent?.includes('Open basis run'))", true);
|
||||||
|
|
||||||
|
visit($basisRunUrl)
|
||||||
->waitForText('Operation #'.(int) $run->getKey())
|
->waitForText('Operation #'.(int) $run->getKey())
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
|
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
|
||||||
->assertSee('Inventory sync coverage')
|
->assertSee('Inventory sync coverage')
|
||||||
->assertSee('Need follow-up');
|
->assertSee('Need follow-up');
|
||||||
|
|
||||||
$page->script(<<<'JS'
|
visit($coverageUrl)
|
||||||
history.back();
|
|
||||||
JS);
|
|
||||||
|
|
||||||
$page
|
|
||||||
->waitForText('Tenant coverage truth')
|
->waitForText('Tenant coverage truth')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->click('Open inventory items')
|
->assertScript("Array.from(document.querySelectorAll('a[href=\"{$inventoryItemsUrl}\"]')).some((element) => element.textContent?.includes('Open inventory items'))", true);
|
||||||
|
|
||||||
|
visit($inventoryItemsUrl)
|
||||||
->waitForText('Inventory Items')
|
->waitForText('Inventory Items')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertSee('Browser Inventory 01');
|
->assertSee('Browser Inventory 01');
|
||||||
|
|||||||
@ -130,10 +130,23 @@ function dashboardKpiStatPayloads($component): array
|
|||||||
'description' => 'healthy queued or running tenant work',
|
'description' => 'healthy queued or running tenant work',
|
||||||
'url' => OperationRunLinks::index($tenant, activeTab: 'active'),
|
'url' => OperationRunLinks::index($tenant, activeTab: 'active'),
|
||||||
],
|
],
|
||||||
'Operations needing follow-up' => [
|
'Likely stale operations' => [
|
||||||
'value' => '3',
|
'value' => '1',
|
||||||
'description' => 'failed, warning, or stalled runs',
|
'description' => 'queued or running past the lifecycle window',
|
||||||
'url' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
'url' => OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'Terminal follow-up operations' => [
|
||||||
|
'value' => '2',
|
||||||
|
'description' => 'blocked, partial, failed, or auto-reconciled runs',
|
||||||
|
'url' => OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -284,3 +284,35 @@ function createNeedsAttentionTenant(): array
|
|||||||
->not->toContain(FindingResource::getUrl('index', ['tab' => 'overdue'], panel: 'tenant', tenant: $tenant))
|
->not->toContain(FindingResource::getUrl('index', ['tab' => 'overdue'], panel: 'tenant', tenant: $tenant))
|
||||||
->toContain('Open Baseline Compare');
|
->toContain('Open Baseline Compare');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('separates stale active attention from terminal follow-up on tenant operations attention', function (): void {
|
||||||
|
[$user, $tenant] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Active operations look stale')
|
||||||
|
->assertSee('Terminal operations need follow-up')
|
||||||
|
->assertSee('Open stale operations')
|
||||||
|
->assertSee('Open terminal follow-up')
|
||||||
|
->assertDontSee('Current governance and findings signals look trustworthy.');
|
||||||
|
});
|
||||||
|
|||||||
@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('renders recent operations from the record tenant in admin panel context', function (): void {
|
it('renders recent operations from the record tenant in admin panel context', function (): void {
|
||||||
@ -31,3 +35,54 @@
|
|||||||
->assertSee('No action needed.')
|
->assertSee('No action needed.')
|
||||||
->assertDontSee('No operations yet.');
|
->assertDontSee('No operations yet.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders stale-active and reconciled terminal truth on tenant recent-operations surfaces', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'restore.execute',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'context' => [
|
||||||
|
'reconciliation' => [
|
||||||
|
'reconciled_at' => now()->toIso8601String(),
|
||||||
|
'reason' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'source' => 'failed_callback',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'code' => 'operation.failed',
|
||||||
|
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'message' => 'Infrastructure ended the run before completion.',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(RecentOperationsSummary::class, ['record' => $tenant])
|
||||||
|
->assertSee('Likely stale')
|
||||||
|
->assertSee('Automatically reconciled');
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(RecentOperations::class)
|
||||||
|
->assertSee('Likely stale')
|
||||||
|
->assertSee('Automatically reconciled')
|
||||||
|
->assertSee('Review worker health and logs before retrying from the start surface.')
|
||||||
|
->assertSee('Review worker health and logs before retrying this operation.');
|
||||||
|
});
|
||||||
|
|||||||
@ -69,7 +69,7 @@ function seedTrustworthyCompare(array $tenantContext): void
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('suppresses calm dashboard wording when operations follow-up still exists', function (): void {
|
it('suppresses calm dashboard wording when stale and terminal operations both need attention', function (): void {
|
||||||
$tenantContext = createTruthAlignedDashboardTenant();
|
$tenantContext = createTruthAlignedDashboardTenant();
|
||||||
[$user, $tenant] = $tenantContext;
|
[$user, $tenant] = $tenantContext;
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -85,12 +85,22 @@ function seedTrustworthyCompare(array $tenantContext): void
|
|||||||
'created_at' => now()->subHour(),
|
'created_at' => now()->subHour(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(NeedsAttention::class)
|
Livewire::test(NeedsAttention::class)
|
||||||
->assertSee('Operations need follow-up')
|
->assertSee('Active operations look stale')
|
||||||
->assertSee('Open operations')
|
->assertSee('Terminal operations need follow-up')
|
||||||
|
->assertSee('Open stale operations')
|
||||||
|
->assertSee('Open terminal follow-up')
|
||||||
->assertDontSee('Current governance and findings signals look trustworthy.');
|
->assertDontSee('Current governance and findings signals look trustworthy.');
|
||||||
|
|
||||||
Livewire::test(BaselineCompareNow::class)
|
Livewire::test(BaselineCompareNow::class)
|
||||||
@ -151,7 +161,8 @@ function seedTrustworthyCompare(array $tenantContext): void
|
|||||||
Livewire::test(NeedsAttention::class)
|
Livewire::test(NeedsAttention::class)
|
||||||
->assertSee('Current governance and findings signals look trustworthy.')
|
->assertSee('Current governance and findings signals look trustworthy.')
|
||||||
->assertSee('Operations are active')
|
->assertSee('Operations are active')
|
||||||
->assertDontSee('Operations need follow-up');
|
->assertDontSee('Terminal operations need follow-up')
|
||||||
|
->assertDontSee('Active operations look stale');
|
||||||
|
|
||||||
Livewire::test(BaselineCompareNow::class)
|
Livewire::test(BaselineCompareNow::class)
|
||||||
->assertSee('Aligned')
|
->assertSee('Aligned')
|
||||||
|
|||||||
@ -66,7 +66,8 @@
|
|||||||
->assertSee('Lifecycle summary')
|
->assertSee('Lifecycle summary')
|
||||||
->assertSee('This tenant is active and available across normal management, tenant selection, and operational follow-up flows.')
|
->assertSee('This tenant is active and available across normal management, tenant selection, and operational follow-up flows.')
|
||||||
->assertSee('RBAC status')
|
->assertSee('RBAC status')
|
||||||
->assertSee('App status');
|
->assertDontSee('App status')
|
||||||
|
->assertSee('Provider connection');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the archived banner from the shared lifecycle presentation contract', function (): void {
|
it('renders the archived banner from the shared lifecycle presentation contract', function (): void {
|
||||||
|
|||||||
@ -201,7 +201,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(TenantVerificationReport::class, ['record' => $tenant])
|
Livewire::test(TenantVerificationReport::class, ['record' => $tenant])
|
||||||
->assertSee('No verification operation has been started yet.')
|
->assertSee('No provider verification check has been recorded yet.')
|
||||||
->call('startVerification');
|
->call('startVerification');
|
||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
@ -234,7 +234,7 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(TenantVerificationReport::class, ['record' => $tenant])
|
->test(TenantVerificationReport::class, ['record' => $tenant])
|
||||||
->assertSee('No verification operation has been started yet.')
|
->assertSee('No provider verification check has been recorded yet.')
|
||||||
->assertSee('Verification can be started from tenant management only while the tenant is active.')
|
->assertSee('Verification can be started from tenant management only while the tenant is active.')
|
||||||
->assertDontSee('Start verification');
|
->assertDontSee('Start verification');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,7 +41,7 @@
|
|||||||
->and($overview['calmness']['is_calm'])->toBeFalse()
|
->and($overview['calmness']['is_calm'])->toBeFalse()
|
||||||
->and($overview['calmness']['next_action']['kind'])->toBe('operations_index')
|
->and($overview['calmness']['next_action']['kind'])->toBe('operations_index')
|
||||||
->and($overview['calmness']['next_action']['url'])->toContain('tenant_scope=all')
|
->and($overview['calmness']['next_action']['url'])->toContain('tenant_scope=all')
|
||||||
->and($overview['calmness']['next_action']['url'])->toContain('activeTab=blocked');
|
->and($overview['calmness']['next_action']['url'])->toContain('activeTab=active');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses switch workspace as the zero-tenant recovery action', function (): void {
|
it('uses switch workspace as the zero-tenant recovery action', function (): void {
|
||||||
|
|||||||
@ -96,9 +96,10 @@
|
|||||||
->and($items->get('tenant_overdue_findings')['destination']['kind'])->toBe('tenant_findings')
|
->and($items->get('tenant_overdue_findings')['destination']['kind'])->toBe('tenant_findings')
|
||||||
->and($items->get('tenant_overdue_findings')['destination']['url'])->toContain('tab=overdue')
|
->and($items->get('tenant_overdue_findings')['destination']['url'])->toContain('tab=overdue')
|
||||||
->and($items->get('tenant_compare_attention')['destination']['kind'])->toBe('baseline_compare_landing')
|
->and($items->get('tenant_compare_attention')['destination']['kind'])->toBe('baseline_compare_landing')
|
||||||
->and($items->get('tenant_operations_follow_up')['destination']['kind'])->toBe('operations_index')
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['kind'])->toBe('operations_index')
|
||||||
->and($items->get('tenant_operations_follow_up')['destination']['url'])->toContain('activeTab=blocked')
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('activeTab=terminal_follow_up')
|
||||||
->and($items->get('tenant_operations_follow_up')['destination']['url'])->toContain('tenant_id='.(string) $tenantOperations->getKey())
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('problemClass=terminal_follow_up')
|
||||||
|
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('tenant_id='.(string) $tenantOperations->getKey())
|
||||||
->and($items->get('tenant_alert_delivery_failures')['destination']['kind'])->toBe('alerts_overview')
|
->and($items->get('tenant_alert_delivery_failures')['destination']['kind'])->toBe('alerts_overview')
|
||||||
->and($items->get('tenant_alert_delivery_failures')['destination']['url'])->toContain('nav%5Bback_url%5D=');
|
->and($items->get('tenant_alert_delivery_failures')['destination']['url'])->toContain('nav%5Bback_url%5D=');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -52,6 +52,29 @@
|
|||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
'status' => \App\Support\OperationRunStatus::Running->value,
|
'status' => \App\Support\OperationRunStatus::Running->value,
|
||||||
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
'started_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'restore.execute',
|
||||||
|
'status' => \App\Support\OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => \App\Support\OperationRunOutcome::Failed->value,
|
||||||
|
'context' => [
|
||||||
|
'reconciliation' => [
|
||||||
|
'reconciled_at' => now()->toIso8601String(),
|
||||||
|
'reason' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'source' => 'failed_callback',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'code' => 'operation.failed',
|
||||||
|
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'message' => 'Infrastructure ended the run before completion.',
|
||||||
|
]],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@ -59,5 +82,7 @@
|
|||||||
->get('/admin')
|
->get('/admin')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Diagnostic recency across your visible workspace slice. This does not define governance health on its own.')
|
->assertSee('Diagnostic recency across your visible workspace slice. This does not define governance health on its own.')
|
||||||
|
->assertSee('Likely stale')
|
||||||
|
->assertSee('Automatically reconciled')
|
||||||
->assertDontSee('Visible governance, findings, compare posture, and activity currently look calm.');
|
->assertDontSee('Visible governance, findings, compare posture, and activity currently look calm.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -87,8 +87,8 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('active operation(s) are beyond their lifecycle window')
|
->assertSee('active operation(s) are beyond their lifecycle window and belong in the stale-attention view')
|
||||||
->assertSee('operation(s) have already been automatically reconciled');
|
->assertSee('operation(s) already carry reconciled stale lineage and belong in terminal follow-up');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders completed operation rows without leaking array-state unknown badges', function (): void {
|
it('renders completed operation rows without leaking array-state unknown badges', function (): void {
|
||||||
|
|||||||
@ -39,6 +39,6 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('1 active operation(s) are beyond their lifecycle window.')
|
->assertSee('1 active operation(s) are beyond their lifecycle window and belong in the stale-attention view.')
|
||||||
->assertSee('1 operation(s) have already been automatically reconciled.');
|
->assertSee('1 operation(s) already carry reconciled stale lineage and belong in terminal follow-up.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,13 +45,14 @@
|
|||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Likely stale')
|
->assertSee('Likely stale')
|
||||||
->assertSee('operation(s) have already been automatically reconciled');
|
->assertSee('belong in terminal follow-up');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $reconciledRun->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $reconciledRun->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Automatically reconciled');
|
->assertSee('Automatically reconciled')
|
||||||
|
->assertSee('Still active: No. Automatic reconciliation: Yes.');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
|||||||
@ -66,7 +66,59 @@
|
|||||||
->assertCanNotSeeTableRecords([$staleActive, $otherTenantActive]);
|
->assertCanNotSeeTableRecords([$staleActive, $otherTenantActive]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the blocked dashboard tab as the tenant-safe follow-up landing for failed, warning, and stalled runs', function (): void {
|
it('uses the stale-attention dashboard landing for likely stale active runs', function (): void {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleRun = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$terminalRun = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherTenantStale = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenantA, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||||
|
|
||||||
|
Livewire::withQueryParams([
|
||||||
|
'tenant_id' => (string) $tenantA->getKey(),
|
||||||
|
'activeTab' => OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
])
|
||||||
|
->actingAs($user)
|
||||||
|
->test(Operations::class)
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||||
|
->assertSet('activeTab', OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||||
|
->assertCanSeeTableRecords([$staleRun])
|
||||||
|
->assertCanNotSeeTableRecords([$terminalRun, $otherTenantStale]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the terminal follow-up landing for failed, warning, and blocked runs without mixing in stale active work', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|
||||||
@ -131,17 +183,18 @@
|
|||||||
|
|
||||||
Livewire::withQueryParams([
|
Livewire::withQueryParams([
|
||||||
'tenant_id' => (string) $tenantA->getKey(),
|
'tenant_id' => (string) $tenantA->getKey(),
|
||||||
'activeTab' => 'blocked',
|
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
])
|
])
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->test(Operations::class)
|
->test(Operations::class)
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||||
->assertSet('activeTab', 'blocked')
|
->assertSet('activeTab', OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||||
->assertCanSeeTableRecords([$partialRun, $failedRun, $blockedRun, $staleRun])
|
->assertCanSeeTableRecords([$partialRun, $failedRun, $blockedRun])
|
||||||
->assertCanNotSeeTableRecords([$healthyActive, $otherTenantFailed]);
|
->assertCanNotSeeTableRecords([$healthyActive, $otherTenantFailed]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows workspace-originated operations drill-through to clear tenant context and show workspace-wide follow-up', function (): void {
|
it('allows workspace-originated operations drill-through to clear tenant context and show workspace-wide terminal follow-up', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|
||||||
@ -151,10 +204,9 @@
|
|||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||||
|
|
||||||
$workspaceRun = OperationRun::factory()->tenantlessForWorkspace($tenantA->workspace()->firstOrFail())->create([
|
$workspaceRun = OperationRun::factory()->tenantlessForWorkspace($tenantA->workspace()->firstOrFail())->create([
|
||||||
'type' => 'inventory_sync',
|
'type' => 'policy.sync',
|
||||||
'status' => OperationRunStatus::Queued->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$otherTenantFailed = OperationRun::factory()->create([
|
$otherTenantFailed = OperationRun::factory()->create([
|
||||||
@ -181,12 +233,13 @@
|
|||||||
|
|
||||||
Livewire::withQueryParams([
|
Livewire::withQueryParams([
|
||||||
'tenant_scope' => 'all',
|
'tenant_scope' => 'all',
|
||||||
'activeTab' => 'blocked',
|
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
])
|
])
|
||||||
->actingAs($user)
|
->actingAs($user)
|
||||||
->test(Operations::class)
|
->test(Operations::class)
|
||||||
->assertSet('tableFilters.tenant_id.value', null)
|
->assertSet('tableFilters.tenant_id.value', null)
|
||||||
->assertSet('activeTab', 'blocked')
|
->assertSet('activeTab', OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||||
->assertCanSeeTableRecords([$workspaceRun, $otherTenantFailed])
|
->assertCanSeeTableRecords([$workspaceRun, $otherTenantFailed])
|
||||||
->assertCanNotSeeTableRecords([$healthyActive]);
|
->assertCanNotSeeTableRecords([$healthyActive]);
|
||||||
});
|
});
|
||||||
@ -199,14 +252,24 @@
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'activeTab' => 'active',
|
'activeTab' => 'active',
|
||||||
]))
|
]))
|
||||||
->and(OperationRunLinks::index($tenant, activeTab: 'blocked'))
|
->and(OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
))
|
||||||
->toBe(route('admin.operations.index', [
|
->toBe(route('admin.operations.index', [
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'activeTab' => 'blocked',
|
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
]))
|
]))
|
||||||
->and(OperationRunLinks::index(activeTab: 'blocked', allTenants: true))
|
->and(OperationRunLinks::index(
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
allTenants: true,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
))
|
||||||
->toBe(route('admin.operations.index', [
|
->toBe(route('admin.operations.index', [
|
||||||
'tenant_scope' => 'all',
|
'tenant_scope' => 'all',
|
||||||
'activeTab' => 'blocked',
|
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,6 +32,8 @@
|
|||||||
->assertDontSee('Avg Duration (7 days)')
|
->assertDontSee('Avg Duration (7 days)')
|
||||||
->assertSee('All')
|
->assertSee('All')
|
||||||
->assertSee('Active')
|
->assertSee('Active')
|
||||||
|
->assertSee('Likely stale')
|
||||||
|
->assertSee('Terminal follow-up')
|
||||||
->assertSee('Succeeded')
|
->assertSee('Succeeded')
|
||||||
->assertSee('Partial')
|
->assertSee('Partial')
|
||||||
->assertSee('Failed');
|
->assertSee('Failed');
|
||||||
|
|||||||
@ -129,6 +129,15 @@
|
|||||||
'initiator_name' => 'A-active',
|
'initiator_name' => 'A-active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$runStaleA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantA->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'A-stale',
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
$runSucceededA = OperationRun::factory()->create([
|
$runSucceededA = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantA->getKey(),
|
'tenant_id' => $tenantA->getKey(),
|
||||||
'type' => 'policy.sync',
|
'type' => 'policy.sync',
|
||||||
@ -189,23 +198,26 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(Operations::class)
|
->test(Operations::class)
|
||||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA])
|
->assertCanSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA])
|
||||||
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
||||||
->set('activeTab', 'active')
|
->set('activeTab', 'active')
|
||||||
->assertCanSeeTableRecords([$runActiveA])
|
->assertCanSeeTableRecords([$runActiveA])
|
||||||
->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
->assertCanNotSeeTableRecords([$runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
->set('activeTab', 'blocked')
|
->set('activeTab', OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||||
|
->assertCanSeeTableRecords([$runStaleA])
|
||||||
|
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
|
->set('activeTab', OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||||
->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA])
|
->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA])
|
||||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runActiveB, $runFailedB])
|
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runActiveB, $runFailedB])
|
||||||
->set('activeTab', 'succeeded')
|
->set('activeTab', 'succeeded')
|
||||||
->assertCanSeeTableRecords([$runSucceededA])
|
->assertCanSeeTableRecords([$runSucceededA])
|
||||||
->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
->set('activeTab', 'partial')
|
->set('activeTab', 'partial')
|
||||||
->assertCanSeeTableRecords([$runPartialA])
|
->assertCanSeeTableRecords([$runPartialA])
|
||||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
->set('activeTab', 'failed')
|
->set('activeTab', 'failed')
|
||||||
->assertCanSeeTableRecords([$runFailedA])
|
->assertCanSeeTableRecords([$runFailedA])
|
||||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB, $runFailedB]);
|
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB, $runFailedB]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->withSession([
|
->withSession([
|
||||||
@ -213,7 +225,8 @@
|
|||||||
])
|
])
|
||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Needs follow-up')
|
->assertSee('Likely stale')
|
||||||
|
->assertSee('Terminal follow-up')
|
||||||
->assertSee('Succeeded')
|
->assertSee('Succeeded')
|
||||||
->assertSee('Partial')
|
->assertSee('Partial')
|
||||||
->assertSee('Failed');
|
->assertSee('Failed');
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
|
'created_at' => now(),
|
||||||
'initiator_name' => 'System',
|
'initiator_name' => 'System',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -42,12 +43,29 @@
|
|||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
'status' => 'running',
|
'status' => 'running',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
|
'started_at' => now(),
|
||||||
'initiator_name' => 'System',
|
'initiator_name' => 'System',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
|
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns false when tenant only has likely stale runs', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'created_at' => now()->subWeeks(2),
|
||||||
|
'started_at' => null,
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ActiveRuns::existForTenant($tenant))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
it('is tenant scoped (other tenant active runs do not count)', function (): void {
|
it('is tenant scoped (other tenant active runs do not count)', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|||||||
@ -45,10 +45,64 @@
|
|||||||
});
|
});
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
|
it('removes terminal runs from the progress overlay on the next refresh cycle without a new enqueue event', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(BulkOperationProgress::class)
|
||||||
|
->call('refreshRuns')
|
||||||
|
->assertSet('hasActiveRuns', true)
|
||||||
|
->assertSee('Inventory sync');
|
||||||
|
|
||||||
|
$run->forceFill([
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'completed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->call('refreshRuns')
|
||||||
|
->assertSet('hasActiveRuns', false)
|
||||||
|
->assertDontSee('Inventory sync');
|
||||||
|
})->group('ops-ux');
|
||||||
|
|
||||||
|
it('does not show likely stale runs in the progress overlay and stops polling when only stale runs remain', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'created_at' => now()->subWeeks(2),
|
||||||
|
'started_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(BulkOperationProgress::class)
|
||||||
|
->call('refreshRuns')
|
||||||
|
->assertSet('hasActiveRuns', false)
|
||||||
|
->assertDontSee('Inventory sync');
|
||||||
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
|
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
|
||||||
$contents = file_get_contents(resource_path('views/livewire/bulk-operation-progress.blade.php'));
|
$contents = file_get_contents(resource_path('views/livewire/bulk-operation-progress.blade.php'));
|
||||||
|
|
||||||
expect($contents)->toContain('new MutationObserver');
|
expect($contents)->toContain('new MutationObserver');
|
||||||
expect($contents)->toContain('teardownObserver');
|
expect($contents)->toContain('teardownObserver');
|
||||||
|
expect($contents)->toContain('wire:poll.10s="refreshRuns"');
|
||||||
expect($contents)->not->toContain('wire:poll.5s="refreshRuns"');
|
expect($contents)->not->toContain('wire:poll.5s="refreshRuns"');
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
->and(OperationRunLinks::view((int) $run->getKey(), $tenant))->toBe($expectedUrl);
|
->and(OperationRunLinks::view((int) $run->getKey(), $tenant))->toBe($expectedUrl);
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('preserves tenant prefilter and requested tab on canonical operations collection links', function (): void {
|
it('preserves tenant prefilter, requested tab, and problem class on canonical operations collection links', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
|
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
|
||||||
@ -61,9 +61,24 @@
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'activeTab' => 'active',
|
'activeTab' => 'active',
|
||||||
]))
|
]))
|
||||||
->and(OperationRunLinks::index($tenant, activeTab: 'blocked'))
|
->and(OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
))
|
||||||
->toBe(route('admin.operations.index', [
|
->toBe(route('admin.operations.index', [
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'activeTab' => 'blocked',
|
'activeTab' => OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||||
|
]))
|
||||||
|
->and(OperationRunLinks::index(
|
||||||
|
$tenant,
|
||||||
|
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
))
|
||||||
|
->toBe(route('admin.operations.index', [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
|
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||||
]));
|
]));
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|||||||
@ -27,6 +27,9 @@
|
|||||||
->get(SystemOperationRunLinks::view($run))
|
->get(SystemOperationRunLinks::view($run))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Operation #'.(int) $run->getKey())
|
->assertSee('Operation #'.(int) $run->getKey())
|
||||||
|
->assertSee('Current lifecycle truth')
|
||||||
|
->assertSee('Still active')
|
||||||
|
->assertSee('Automatic reconciliation')
|
||||||
->assertSee('Show all operations')
|
->assertSee('Show all operations')
|
||||||
->assertSee('Go to runbooks');
|
->assertSee('Go to runbooks');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,13 +4,16 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
it('forbids failures page when platform.operations.view is missing', function () {
|
it('forbids failures page when platform.operations.view is missing', function () {
|
||||||
$platformUser = PlatformUser::factory()->create([
|
$platformUser = PlatformUser::factory()->create([
|
||||||
@ -30,6 +33,14 @@
|
|||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
'type' => 'inventory_sync',
|
'type' => 'inventory_sync',
|
||||||
|
'context' => [
|
||||||
|
'reconciliation' => [
|
||||||
|
'reconciled_at' => now()->toIso8601String(),
|
||||||
|
'reason' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'reason_code' => 'run.infrastructure_timeout_or_abandonment',
|
||||||
|
'source' => 'failed_callback',
|
||||||
|
],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$succeededRun = OperationRun::factory()->create([
|
$succeededRun = OperationRun::factory()->create([
|
||||||
@ -51,6 +62,86 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Failed operations')
|
->assertSee('Failed operations')
|
||||||
->assertSee('Show all operations')
|
->assertSee('Show all operations')
|
||||||
|
->assertSee('Automatically reconciled')
|
||||||
->assertSee(SystemOperationRunLinks::view($failedRun))
|
->assertSee(SystemOperationRunLinks::view($failedRun))
|
||||||
->assertDontSee(SystemOperationRunLinks::view($succeededRun));
|
->assertDontSee(SystemOperationRunLinks::view($succeededRun));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders governance artifact failures without resolving tenant artifact routes on the system panel', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$initiator = User::factory()->create();
|
||||||
|
|
||||||
|
$evidenceRun = $this->makeArtifactTruthRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'tenant.evidence.snapshot.generate',
|
||||||
|
attributes: [
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'failure_summary' => [
|
||||||
|
['code' => 'operation.failed', 'message' => 'Evidence generation failed'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$evidenceSnapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, [
|
||||||
|
'operation_run_id' => (int) $evidenceRun->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reviewRun = $this->makeArtifactTruthRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'tenant.review.compose',
|
||||||
|
attributes: [
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'failure_summary' => [
|
||||||
|
['code' => 'operation.failed', 'message' => 'Review composition failed'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$review = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $initiator,
|
||||||
|
snapshot: $evidenceSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'operation_run_id' => (int) $reviewRun->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$packRun = $this->makeArtifactTruthRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'tenant.review_pack.generate',
|
||||||
|
attributes: [
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'failure_summary' => [
|
||||||
|
['code' => 'operation.failed', 'message' => 'Review pack generation failed'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeArtifactTruthReviewPack(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $initiator,
|
||||||
|
snapshot: $evidenceSnapshot,
|
||||||
|
review: $review,
|
||||||
|
packOverrides: [
|
||||||
|
'operation_run_id' => (int) $packRun->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$platformUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPERATIONS_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform')
|
||||||
|
->get('/system/ops/failures')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Evidence snapshot generation')
|
||||||
|
->assertSee('Review composition')
|
||||||
|
->assertSee('Review pack generation');
|
||||||
|
});
|
||||||
|
|||||||
@ -69,6 +69,7 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Stuck operations')
|
->assertSee('Stuck operations')
|
||||||
->assertSee('Show all operations')
|
->assertSee('Show all operations')
|
||||||
|
->assertSee('Likely stale')
|
||||||
->assertSee('#'.(int) $stuckQueued->getKey())
|
->assertSee('#'.(int) $stuckQueued->getKey())
|
||||||
->assertSee('#'.(int) $stuckRunning->getKey())
|
->assertSee('#'.(int) $stuckRunning->getKey())
|
||||||
->assertDontSee('#'.(int) $freshQueued->getKey());
|
->assertDontSee('#'.(int) $freshQueued->getKey());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user