diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 9d9fdca5..656bbfd3 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -950,9 +950,12 @@ private function resumeCaptureAction(): Action $viewAction = Action::make('view_run') ->label(OperationRunLinks::openLabel()) ->url(OperationRunLinks::tenantlessView($run)); + $runTenantId = is_numeric($run->managed_environment_id) + ? (int) $run->managed_environment_id + : null; if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) { - OpsUxBrowserEvents::dispatchRunEnqueued($this); + OpsUxBrowserEvents::dispatchRunEnqueued($this, $runTenantId); OperationUxPresenter::alreadyQueuedToast((string) $run->type) ->actions([$viewAction]) @@ -961,7 +964,7 @@ private function resumeCaptureAction(): Action return; } - OpsUxBrowserEvents::dispatchRunEnqueued($this); + OpsUxBrowserEvents::dispatchRunEnqueued($this, $runTenantId); OperationUxPresenter::queuedToast((string) $run->type) ->actions([$viewAction]) diff --git a/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index 3c650038..26204f5a 100644 --- a/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -138,7 +138,7 @@ private function captureAction(): Action ->url(OperationRunLinks::view($run, $sourceTenant)); if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) { - OpsUxBrowserEvents::dispatchRunEnqueued($this); + OpsUxBrowserEvents::dispatchRunEnqueued($this, $sourceTenant); OperationUxPresenter::alreadyQueuedToast((string) $run->type) ->actions([$viewAction]) @@ -147,7 +147,7 @@ private function captureAction(): Action return; } - OpsUxBrowserEvents::dispatchRunEnqueued($this); + OpsUxBrowserEvents::dispatchRunEnqueued($this, $sourceTenant); OperationUxPresenter::queuedToast((string) $run->type) ->actions([$viewAction]) @@ -291,7 +291,7 @@ private function compareNowAction(): Action ->url(OperationRunLinks::view($run, $targetTenant)); if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) { - OpsUxBrowserEvents::dispatchRunEnqueued($this); + OpsUxBrowserEvents::dispatchRunEnqueued($this, $targetTenant); OperationUxPresenter::alreadyQueuedToast((string) $run->type) ->actions([$viewAction]) @@ -300,7 +300,7 @@ private function compareNowAction(): Action return; } - OpsUxBrowserEvents::dispatchRunEnqueued($this); + OpsUxBrowserEvents::dispatchRunEnqueued($this, $targetTenant); OperationUxPresenter::queuedToast((string) $run->type) ->actions([$viewAction]) diff --git a/apps/platform/app/Services/Baselines/BaselineCaptureService.php b/apps/platform/app/Services/Baselines/BaselineCaptureService.php index 31bb14e1..b2fd7e5b 100644 --- a/apps/platform/app/Services/Baselines/BaselineCaptureService.php +++ b/apps/platform/app/Services/Baselines/BaselineCaptureService.php @@ -111,7 +111,11 @@ public function startCapture( ); if ($run->wasRecentlyCreated) { - CaptureBaselineSnapshotJob::dispatch($run); + $this->runs->dispatchOrFail( + $run, + fn () => CaptureBaselineSnapshotJob::dispatch($run), + emitQueuedNotification: true, + ); } return ['ok' => true, 'run' => $run]; diff --git a/apps/platform/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php b/apps/platform/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php index 6877f53e..bf9a5907 100644 --- a/apps/platform/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php +++ b/apps/platform/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php @@ -118,11 +118,12 @@ public function resume(OperationRun $priorRun, User $initiator): array ); if ($run->wasRecentlyCreated) { - match ($runType) { - OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run), - OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run), - default => null, + $dispatcher = match ($runType) { + OperationRunType::BaselineCapture->value => fn () => CaptureBaselineSnapshotJob::dispatch($run), + OperationRunType::BaselineCompare->value => fn () => CompareBaselineToTenantJob::dispatch($run), }; + + $this->runs->dispatchOrFail($run, $dispatcher, emitQueuedNotification: true); } $this->auditLogger->log( diff --git a/apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php b/apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php index 69701660..aea5a1a8 100644 --- a/apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php +++ b/apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php @@ -6,13 +6,14 @@ use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Livewire\BulkOperationProgress; +use App\Models\ManagedEnvironment; use Filament\Facades\Filament; final class OpsUxBrowserEvents { public const RunEnqueued = 'ops-ux:run-enqueued'; - public static function dispatchRunEnqueued(mixed $livewire): void + public static function dispatchRunEnqueued(mixed $livewire, ManagedEnvironment|int|null $tenant = null): void { if (! is_object($livewire)) { return; @@ -22,12 +23,33 @@ public static function dispatchRunEnqueued(mixed $livewire): void return; } - $tenantId = Filament::getTenant()?->getKey(); + $tenantId = self::resolveTenantId($tenant); - // In Livewire v3, dispatch() emits a DOM event that bubbles. + // In Livewire v4, dispatch() emits a DOM event that bubbles. // Our progress widget is mounted outside the initiating component's DOM tree, // so we target it explicitly to ensure it receives the event immediately. $livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(BulkOperationProgress::class); $livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(InventoryKpiHeader::class); } + + private static function resolveTenantId(ManagedEnvironment|int|null $tenant): ?int + { + if ($tenant instanceof ManagedEnvironment) { + return (int) $tenant->getKey(); + } + + if (is_int($tenant) && $tenant > 0) { + return $tenant; + } + + $currentTenantId = Filament::getTenant()?->getKey(); + + if (! is_numeric($currentTenantId)) { + return null; + } + + $currentTenantId = (int) $currentTenantId; + + return $currentTenantId > 0 ? $currentTenantId : null; + } } diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php index c63a2eff..07ebbf1c 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php @@ -5,11 +5,15 @@ use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile; use App\Jobs\CaptureBaselineSnapshotJob; +use App\Livewire\BulkOperationProgress; use App\Models\BaselineProfile; use App\Models\OperationRun; use App\Models\ManagedEnvironment; +use App\Notifications\OperationRunQueued; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\OperationRunLinks; use App\Support\OperationRunType; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -106,6 +110,7 @@ function seedCaptureProfileForTenant( ->assertActionHasLabel('capture', 'Capture baseline (full content)') ->assertActionEnabled('capture') ->callAction('capture', data: ['source_environment_id' => (int) $tenant->getKey()]) + ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()) ->assertStatus(200); $topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component)) @@ -129,6 +134,19 @@ function seedCaptureProfileForTenant( expect($run)->not->toBeNull(); expect($run?->status)->toBe('queued'); expect(data_get($run?->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey()); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunQueued::class, + 'data->format' => 'filament', + 'data->title' => 'Baseline capture queued', + ]); + + $notification = $user->notifications()->latest('id')->first(); + + expect(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::view($run, $tenant)) + ->and(data_get($notification?->data, 'actions.0.target'))->toBe('admin_operation_run'); }); it('shows the shared capture block on the start surface when no credible inventory basis exists', function (): void { diff --git a/apps/platform/tests/Feature/Filament/OperationRunResumeCaptureActionTest.php b/apps/platform/tests/Feature/Filament/OperationRunResumeCaptureActionTest.php index 51e97e12..5f75d923 100644 --- a/apps/platform/tests/Feature/Filament/OperationRunResumeCaptureActionTest.php +++ b/apps/platform/tests/Feature/Filament/OperationRunResumeCaptureActionTest.php @@ -1,15 +1,20 @@ test(TenantlessOperationRunViewer::class, ['run' => $run]) ->assertActionVisible('resumeCapture') ->callAction('resumeCapture') + ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()) ->assertStatus(200); Queue::assertPushed(CompareBaselineToTenantJob::class); @@ -66,4 +72,72 @@ expect($resumed)->not->toBeNull(); $context = is_array($resumed?->context) ? $resumed->context : []; expect($context['baseline_compare']['resume_token'] ?? null)->toBe($token); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunQueued::class, + 'data->format' => 'filament', + 'data->title' => 'Baseline compare queued', + ]); +}); + +it('notifies and refreshes operation activity after resuming a baseline capture run', function (): void { + Queue::fake(); + config()->set('tenantpilot.baselines.full_content_capture.enabled', true); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ]); + + $token = BaselineEvidenceResumeToken::encode(['offset' => 1]); + + $run = OperationRun::factory()->for($tenant)->create([ + 'type' => OperationRunType::BaselineCapture->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'user_id' => (int) $user->getKey(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_environment_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + 'capture_mode' => BaselineCaptureMode::FullContent->value, + 'baseline_capture' => [ + 'resume_token' => $token, + ], + ], + ]); + + Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertActionVisible('resumeCapture') + ->callAction('resumeCapture') + ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()) + ->assertStatus(200); + + Queue::assertPushed(CaptureBaselineSnapshotJob::class); + + $resumed = OperationRun::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::BaselineCapture->value) + ->where('status', OperationRunStatus::Queued->value) + ->latest('id') + ->first(); + + expect($resumed)->not->toBeNull(); + $context = is_array($resumed?->context) ? $resumed->context : []; + expect($context['baseline_capture']['resume_token'] ?? null)->toBe($token); + + $notification = $user->notifications() + ->where('type', OperationRunQueued::class) + ->latest('id') + ->first(); + + expect($notification)->not->toBeNull() + ->and(data_get($notification?->data, 'title'))->toBe('Baseline capture queued') + ->and(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::view($resumed, $tenant)) + ->and(data_get($notification?->data, 'actions.0.target'))->toBe('admin_operation_run'); }); diff --git a/specs/272-operationrun-phase-composite-progress/plan.md b/specs/272-operationrun-phase-composite-progress/plan.md index e9dd04ff..27e6f7a3 100644 --- a/specs/272-operationrun-phase-composite-progress/plan.md +++ b/specs/272-operationrun-phase-composite-progress/plan.md @@ -22,6 +22,7 @@ ### Explicit delta in this plan - add canonical operator-safe phase metadata and label derivation for `baseline_capture` and `baseline_compare` - add one bounded composite summary path for `tenant.review.compose` from current aggregate operation truth +- ensure workspace/canonical baseline capture, compare, and resume launch paths pass the concrete tenant id into the existing `run-enqueued` event and opt newly created human-initiated baseline capture/resume runs into the existing queued database notification path - keep counted, terminal, and generic activity semantics unchanged except for consuming more truthful non-counted detail - document the narrowed candidate boundary explicitly: review-pack and evidence-snapshot overlap remain with Spec 271, while provider health and support diagnostics remain deferred until repo-real queued progress truth exists @@ -120,9 +121,9 @@ ## OperationRun UX Impact - **Touches OperationRun start/completion/link UX?**: yes, for active progress meaning only - **Central contract reused**: existing OperationRun Start UX Contract plus `OperationRunProgressContract` -- **Delegated UX behaviors**: queued toast, canonical run links, `run-enqueued` event, and terminal-notification lifecycle remain delegated and unchanged +- **Delegated UX behaviors**: queued toast, canonical run links, `run-enqueued` event, and terminal-notification lifecycle remain delegated. Workspace/canonical baseline launch and resume surfaces pass the concrete tenant id into the existing event path because `Filament::getTenant()` is unavailable on those routes. - **Surface-owned behavior kept local**: current launch inputs, detailed diagnostics, and domain-specific result explanations stay local to their current surfaces -- **Queued DB-notification policy**: `N/A` - unchanged +- **Queued DB-notification policy**: explicit opt-in only. Baseline capture start and baseline evidence resume reuse the existing `OperationRunQueued` database notification via `OperationRunService`; no new notification class, queue policy, or sidebar notification path is introduced. - **Terminal notification path**: unchanged central lifecycle mechanism - **Exception path**: none @@ -257,4 +258,4 @@ ## Proportionality Review - **Narrowest correct implementation**: update only selected repo-real phase/composite families and keep all other work on current generic fallback or counted semantics. - **Ownership cost created**: selected context writes in existing jobs/services, focused tests, and one standards update. - **Alternative intentionally rejected**: a workflow engine, child-run graph, or fake percentage model was rejected because all would be structurally heavier and less truthful than the current-release need. -- **Release truth**: current-release truth. The repo already contains the contract, the shell adopter, the baseline phase hints, and the tenant-review aggregate truth needed for this rollout. \ No newline at end of file +- **Release truth**: current-release truth. The repo already contains the contract, the shell adopter, the baseline phase hints, and the tenant-review aggregate truth needed for this rollout. diff --git a/specs/272-operationrun-phase-composite-progress/spec.md b/specs/272-operationrun-phase-composite-progress/spec.md index 9f783069..08c14600 100644 --- a/specs/272-operationrun-phase-composite-progress/spec.md +++ b/specs/272-operationrun-phase-composite-progress/spec.md @@ -51,9 +51,9 @@ ## OperationRun UX Impact *(mandatory)* - **Touches OperationRun start/completion/link UX?**: yes - **Shared OperationRun UX contract/layer reused**: existing OperationRun Start UX Contract plus `App\Support\OpsUx\OperationRunProgressContract` and `App\Support\OpsUx\OperationUxPresenter` -- **Delegated start/completion UX behaviors**: queued toast wording, canonical `View operation` links, tenant-safe URL resolution, current `run-enqueued` browser events, and existing terminal notifications remain delegated to the current shared path and are unchanged in this slice +- **Delegated start/completion UX behaviors**: queued toast wording, canonical `View operation` links, tenant-safe URL resolution, current `run-enqueued` browser events, and existing terminal notifications remain delegated to the current shared path. Workspace/canonical baseline launch and resume surfaces must pass the concrete tenant id into the existing event path because `Filament::getTenant()` is absent outside tenant-scoped routes. - **Local surface-owned behavior that remains**: current baseline and tenant-review launch inputs plus current run-detail diagnostics stay local to their existing surfaces; non-counted progress semantics do not -- **Queued DB-notification policy**: `N/A` - unchanged +- **Queued DB-notification policy**: explicit opt-in only. Baseline capture start and baseline evidence resume reuse the existing `OperationRunQueued` database notification via `OperationRunService`; no new notification class, queue policy, or sidebar notification path is introduced. - **Terminal notification path**: unchanged central lifecycle mechanism - **Exception required?**: none @@ -228,6 +228,8 @@ ### Functional Requirements - **FR-012**: This slice MUST NOT add new `summary_counts` keys, new progress capabilities, a new dashboard card, a child-run graph, a workflow engine, or provider health/support-diagnostics progress behavior. - **FR-013**: This slice MUST NOT reopen review-pack or evidence-snapshot progress semantics owned by Spec 271 unless current repo truth proves they still need non-counted treatment in a later follow-up spec. - **FR-014**: The feature MUST update `docs/ui/tenantpilot-enterprise-ui-standards.md` so maintainers can see which current run families may claim phase/composite progress and which families remain activity-only, counted, or deferred. +- **FR-015**: Workspace/canonical baseline capture, compare, and resume launch surfaces MUST pass the concrete tenant id into the existing `run-enqueued` event so tenant-bound operation activity feedback refreshes even when `Filament::getTenant()` is null. +- **FR-016**: Newly created human-initiated baseline capture and baseline evidence resume runs MUST use `OperationRunService::dispatchOrFail(..., emitQueuedNotification: true)` so the existing queued sidebar database notification path is written consistently. ### Authorization and Safety Requirements @@ -307,4 +309,4 @@ ## Risks ## Open Questions -- None blocking safe implementation. If a selected run family cannot produce stable operator-safe non-counted detail without speculative copy, that family must drop back to current generic fallback behavior rather than widening the slice. \ No newline at end of file +- None blocking safe implementation. If a selected run family cannot produce stable operator-safe non-counted detail without speculative copy, that family must drop back to current generic fallback behavior rather than widening the slice. diff --git a/specs/272-operationrun-phase-composite-progress/tasks.md b/specs/272-operationrun-phase-composite-progress/tasks.md index 62940f94..164b3e8a 100644 --- a/specs/272-operationrun-phase-composite-progress/tasks.md +++ b/specs/272-operationrun-phase-composite-progress/tasks.md @@ -130,6 +130,9 @@ ## Phase 7: Polish & Cross-Cutting Validation - [x] T022 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/TenantReview/TenantReviewOperationsUxTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/TenantReview/TenantReviewRbacTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`. - [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files. - [x] T024 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no new assets were registered, no new globally searchable resource behavior was introduced, no new parallel polling loop was added, and the full precedence chain `phased > composite > counted > activity` still holds with counted mode requiring truthful `processed` plus `total`. +- [x] T025 [P] [Bugfix] Update `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php`, baseline profile actions, and canonical operation-detail resume actions so workspace/canonical baseline capture, compare, and resume starts pass the concrete tenant id into the existing `run-enqueued` event. +- [x] T026 [P] [Bugfix] Update `apps/platform/app/Services/Baselines/BaselineCaptureService.php` and `apps/platform/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` so newly created human-initiated baseline capture and evidence-resume runs use `OperationRunService::dispatchOrFail(..., emitQueuedNotification: true)`. +- [x] T027 [P] [Bugfix] Extend `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/OperationRunResumeCaptureActionTest.php` to prove tenant-bound progress refresh events and queued sidebar database notifications for baseline capture, compare, and resume starts. --- @@ -189,4 +192,4 @@ ## Deferred Follow-Ups / Non-Goals - review-pack or evidence-snapshot non-counted progress overlap with Spec 271 - child-run graph persistence through `child_run_ids` or `operation_run_ids` - `273 - Tenant Dashboard Active Operations Summary Card` -- any workflow-engine or AI-generated progress explanation layer \ No newline at end of file +- any workflow-engine or AI-generated progress explanation layer