feat: implement operation run phase composite progress and baseline capture resume
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 57s

This commit is contained in:
Ahmed Darrazi 2026-06-08 05:27:30 +02:00
parent 58c0064cb0
commit 4097778d96
10 changed files with 149 additions and 21 deletions

View File

@ -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])

View File

@ -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])

View File

@ -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];

View File

@ -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(

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -1,15 +1,20 @@
<?php
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Livewire\BulkOperationProgress;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Notifications\OperationRunQueued;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -52,6 +57,7 @@
->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');
});

View File

@ -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.
- **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.

View File

@ -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.
- 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.

View File

@ -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
- any workflow-engine or AI-generated progress explanation layer