Spec 123: operations auto-refresh pass #149

Merged
ahmido merged 2 commits from 123-operations-auto-refresh into dev 2026-03-08 11:11:27 +00:00
17 changed files with 1142 additions and 161 deletions
Showing only changes of commit f746373113 - Show all commits

View File

@ -46,6 +46,8 @@ ## Active Technologies
- PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4 (122-empty-state-consistency)
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
- PostgreSQL primary app database (123-operations-auto-refresh)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -65,8 +67,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 123-operations-auto-refresh: Added PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail
- 122-empty-state-consistency: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4
- 121-workspace-switch-fix: Added PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4
- 120-secret-redaction-integrity: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -21,6 +21,8 @@
class TenantReviewPackCard extends Widget
{
private const string ACTIVE_POLLING_INTERVAL = '10s';
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.tenant-review-pack-card';
@ -139,6 +141,7 @@ protected function getViewData(): array
'tenant' => $tenant,
'pack' => null,
'statusEnum' => null,
'pollingInterval' => null,
'canView' => $canView,
'canManage' => $canManage,
'downloadUrl' => null,
@ -165,6 +168,7 @@ protected function getViewData(): array
'tenant' => $tenant,
'pack' => $latestPack,
'statusEnum' => $statusEnum,
'pollingInterval' => self::resolvePollingInterval($latestPack),
'canView' => $canView,
'canManage' => $canManage,
'downloadUrl' => $downloadUrl,
@ -172,6 +176,16 @@ protected function getViewData(): array
];
}
public static function resolvePollingInterval(?ReviewPack $pack): ?string
{
$status = ReviewPackStatus::tryFrom((string) $pack?->status);
return match ($status) {
ReviewPackStatus::Queued, ReviewPackStatus::Generating => self::ACTIVE_POLLING_INTERVAL,
default => null,
};
}
/**
* @return array<string, mixed>
*/
@ -181,6 +195,7 @@ private function emptyState(): array
'tenant' => null,
'pack' => null,
'statusEnum' => null,
'pollingInterval' => null,
'canView' => false,
'canManage' => false,
'downloadUrl' => null,

View File

@ -20,7 +20,8 @@ public static function interval(OperationRun $run): ?string
return null;
}
$ageSeconds = now()->diffInSeconds($run->created_at ?? now());
$ageSeconds = $run->created_at?->diffInSeconds(now()) ?? 0;
$ageSeconds = abs((int) $ageSeconds);
if ($ageSeconds < 10) {
return '1s';

View File

@ -16,7 +16,6 @@ ## Inbox
- Dashboard trend visualizations (sparklines, compliance gauge, drift-over-time chart)
- Dashboard "Needs Attention" should be visually louder (alert color, icon, severity weighting)
- Operations table should show duration + affected policy count
- First-run onboarding wizard or checklist for new tenants
- Density control / comfortable view toggle for admin tables
- Inventory landing page may be redundant — consider pure navigation section
- Monitoring hub calmer polling / less UI noise
@ -101,6 +100,15 @@ ### Dashboard Polish (Enterprise-grade)
- **Dependencies**: Baseline governance (101), alerts (099), drift engine (119) stable
- **Priority**: medium
### Support Intake with Context (MVP)
- **Type**: feature
- **Source**: Product design, operator feedback
- **Problem**: Nutzer haben keinen strukturierten Weg, Probleme direkt aus dem Produkt zu melden. Bei technischen Fehlern fehlen Run-/Tenant-/Provider-Details; bei Access-/UX-Problemen fehlen Route-/RBAC-Kontext. Folge: ineffiziente Support-Schleifen und Rückfragen. Ein vollwertiges Ticketsystem ist falsch priorisiert.
- **Why it matters**: Reduziert Support-Reibung, erhöht Erfassungsqualität, steigert wahrgenommene Produktreife. Schafft typed intake layer für spätere Webhook-/PSA-/Ticketing-Erweiterungen, ohne jetzt ein Helpdesk einzuführen.
- **Proposed direction**: Neues `SupportRequest`-Modell (kein Ticket/Case) mit `source_type` (operation_run, provider_connection, access_denied, generic) und `issue_kind` (technical_problem, access_problem, ux_feedback, other). Drei Entry Paths: (1) Context-bound aus failed OperationRun, (2) Access-Denied/403-Kontext, (3) generischer Feedback-Einstieg (User-Menü). Automatischer Context-Snapshot per `SupportRequestContextBuilder` je source_type. Persistierung vor Delivery. E-Mail-Delivery an konfigurierte Support-Adresse. Fingerprint-basierter Spam-Guard. Audit-Events. RBAC via `support.request.create` Capability. Scope-Isolation. Secret-Redaction in context_jsonb.
- **Dependencies**: OperationRun-Domain stabil, RBAC/Capability-System (066+), Workspace-/Tenant-Scoping
- **Priority**: medium
---
## Planned

View File

@ -6,6 +6,7 @@
/** @var ?\App\Models\Tenant $tenant */
/** @var ?\App\Models\ReviewPack $pack */
/** @var ?ReviewPackStatus $statusEnum */
/** @var ?string $pollingInterval */
/** @var bool $canView */
/** @var bool $canManage */
/** @var ?string $downloadUrl */
@ -14,86 +15,150 @@
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
@endphp
<x-filament::section heading="Review Pack">
@if (! $pack)
{{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center">
<x-heroicon-o-document-arrow-down class="h-8 w-8 text-gray-400 dark:text-gray-500" />
<div class="text-sm text-gray-500 dark:text-gray-400">
No review pack generated yet.
</div>
@if ($canManage)
<x-filament::button
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
>
Generate pack
</x-filament::button>
@endif
</div>
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
{{-- State 2: Queued / Generating --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-filament::loading-indicator class="h-4 w-4" />
Generation in progress&hellip;
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
@elseif ($statusEnum === ReviewPackStatus::Ready)
{{-- State 3: Ready --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-gray-500 dark:text-gray-400">Generated</dt>
<dd>{{ $pack->generated_at?->format('M j, Y H:i') ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Expires</dt>
<dd>{{ $pack->expires_at?->format('M j, Y') ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Size</dt>
<dd>{{ $pack->file_size ? Number::fileSize($pack->file_size) : '—' }}</dd>
</dl>
<div class="flex items-center gap-2">
@if ($canView && $downloadUrl)
<x-filament::button
size="sm"
tag="a"
:href="$downloadUrl"
target="_blank"
icon="heroicon-o-arrow-down-tray"
>
Download
</x-filament::button>
@endif
<div
@if ($pollingInterval)
wire:poll.{{ $pollingInterval }}
@endif
>
<x-filament::section heading="Review Pack">
@if (! $pack)
{{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center">
<x-heroicon-o-document-arrow-down class="h-8 w-8 text-gray-400 dark:text-gray-500" />
<div class="text-sm text-gray-500 dark:text-gray-400">
No review pack generated yet.
</div>
@if ($canManage)
<x-filament::button
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
>
Generate pack
</x-filament::button>
@endif
</div>
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
{{-- State 2: Queued / Generating --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-filament::loading-indicator class="h-4 w-4" />
Generation in progress&hellip;
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
@elseif ($statusEnum === ReviewPackStatus::Ready)
{{-- State 3: Ready --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-gray-500 dark:text-gray-400">Generated</dt>
<dd>{{ $pack->generated_at?->format('M j, Y H:i') ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Expires</dt>
<dd>{{ $pack->expires_at?->format('M j, Y') ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Size</dt>
<dd>{{ $pack->file_size ? Number::fileSize($pack->file_size) : '—' }}</dd>
</dl>
<div class="flex items-center gap-2">
@if ($canView && $downloadUrl)
<x-filament::button
size="sm"
tag="a"
:href="$downloadUrl"
target="_blank"
icon="heroicon-o-arrow-down-tray"
>
Download
</x-filament::button>
@endif
@if ($canManage)
<x-filament::button
size="sm"
color="gray"
wire:click="generatePack"
wire:loading.attr="disabled"
>
Generate new
</x-filament::button>
@endif
</div>
</div>
@elseif ($statusEnum === ReviewPackStatus::Failed)
{{-- State 4: Failed --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
@if ($failedReason)
<div class="text-sm text-danger-600 dark:text-danger-400">
{{ $failedReason }}
</div>
@endif
<div class="text-xs text-gray-400 dark:text-gray-500">
{{ $pack->updated_at?->diffForHumans() ?? '—' }}
</div>
@if ($canManage)
<x-filament::button
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
>
Retry
</x-filament::button>
@endif
</div>
@elseif ($statusEnum === ReviewPackStatus::Expired)
{{-- State 5: Expired --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
Expired {{ $pack->expires_at?->diffForHumans() ?? '—' }}
</div>
@if ($canManage)
<x-filament::button
size="sm"
color="gray"
wire:click="generatePack"
wire:loading.attr="disabled"
>
@ -101,64 +166,6 @@
</x-filament::button>
@endif
</div>
</div>
@elseif ($statusEnum === ReviewPackStatus::Failed)
{{-- State 4: Failed --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
@if ($failedReason)
<div class="text-sm text-danger-600 dark:text-danger-400">
{{ $failedReason }}
</div>
@endif
<div class="text-xs text-gray-400 dark:text-gray-500">
{{ $pack->updated_at?->diffForHumans() ?? '—' }}
</div>
@if ($canManage)
<x-filament::button
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
>
Retry
</x-filament::button>
@endif
</div>
@elseif ($statusEnum === ReviewPackStatus::Expired)
{{-- State 5: Expired --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<x-filament::badge
:color="$badgeSpec?->color"
:icon="$badgeSpec?->icon"
>
{{ $badgeSpec?->label ?? '—' }}
</x-filament::badge>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
Expired {{ $pack->expires_at?->diffForHumans() ?? '—' }}
</div>
@if ($canManage)
<x-filament::button
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
>
Generate new
</x-filament::button>
@endif
</div>
@endif
</x-filament::section>
@endif
</x-filament::section>
</div>

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Operations Auto-Refresh Pass
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-08
**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 user-facing monitoring behavior, bounded scope, and measurable refresh outcomes.
- Constitution-alignment sections describe unchanged operational and authorization guardrails without expanding feature scope.

View File

@ -0,0 +1,55 @@
schema_version: 1
feature: operations-auto-refresh-pass
contracts:
- name: tenantless-operation-run-viewer
kind: ui-surface
surface_class: App\Filament\Pages\Operations\TenantlessOperationRunViewer
host_route: /admin/operations/{run}
method: GET
authorization:
scope: workspace-canonical monitoring view
unchanged_from_existing: true
polling:
source_of_truth: App\Support\OpsUx\RunDetailPolling::interval
active_states:
- queued
- running
terminal_states:
- succeeded
- partial
- failed
cadence:
- age_lt_10s: 1s
- age_lt_60s: 5s
- steady_state: 10s
suppression_conditions:
- browser_tab_hidden
- filament_actions_mounted
response_shape:
content_type: text/html
behavior: renders run detail and emits polling only while the run remains active
- name: tenant-review-pack-card
kind: ui-component
surface_class: App\Filament\Widgets\Tenant\TenantReviewPackCard
host_route: intentionally reusable tenant-scoped embedded widget surfaces (component-scoped; no standalone route is owned by the widget itself)
method: GET
authorization:
scope: tenant-scoped review pack surface
unchanged_from_existing: true
polling:
source_of_truth: latest ReviewPack status for current tenant
active_states:
- queued
- generating
terminal_states:
- ready
- failed
- expired
cadence:
- steady_state: 10s
suppression_conditions:
- no_review_pack_exists
response_shape:
content_type: text/html
behavior: renders widget state and emits polling only while review pack generation is active

View File

@ -0,0 +1,98 @@
# Data Model — Operations Auto-Refresh Pass
## Overview
This feature does not add new tables or columns. It reuses existing persisted state to decide whether two UI surfaces should continue polling.
## Entities
### OperationRun
- **Purpose**: Canonical record for queued, running, and completed operational work shown in Monitoring → Operations.
- **Relevant fields**:
- `id`
- `workspace_id`
- `tenant_id` (nullable for tenantless/canonical monitoring)
- `type`
- `status`
- `outcome`
- `context`
- `summary_counts`
- `failure_summary`
- `created_at`
- `started_at`
- `completed_at`
- **Relationships**:
- Belongs to `Workspace`
- Belongs to `Tenant` (optional)
- Belongs to initiating `User` (optional)
- **Polling rule**:
- Poll while UX-normalized status is `queued` or `running`
- Stop polling for normalized terminal states such as `succeeded`, `partial`, or `failed`
- **Derived constraints**:
- Polling on run-detail surfaces must continue to respect hidden-tab and mounted-action guards
- No direct lifecycle mutation is added; `OperationRunService` remains the only lifecycle transition owner
### ReviewPack
- **Purpose**: Persisted output of tenant review pack generation for a tenant-scoped admin surface.
- **Relevant fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `operation_run_id`
- `initiated_by_user_id`
- `status`
- `options`
- `summary`
- `generated_at`
- `expires_at`
- `file_path`
- `file_disk`
- `file_size`
- `created_at`
- `updated_at`
- **Relationships**:
- Belongs to `Workspace`
- Belongs to `Tenant`
- Belongs to `OperationRun`
- Belongs to initiating `User`
- **Polling rule**:
- Poll while `status` is `queued` or `generating`
- Stop polling when `status` is `ready`, `failed`, or `expired`
- Do not poll when no review pack exists for the tenant
## Derived View State
### Run detail polling state
- **Source**: `OperationRun` + `RunDetailPolling::interval()`
- **Output**: `1s`, `5s`, `10s`, or `null`
- **Rules**:
- `null` when the run is terminal
- `null` when the tab is hidden
- `null` when Filament action modals are mounted
### Review pack card polling state
- **Source**: latest `ReviewPack` for the current tenant
- **Output**: `10s` or `null`
- **Rules**:
- `10s` when the latest pack is `queued` or `generating`
- `null` otherwise
## State Transitions
### OperationRun lifecycle relevant to polling
1. `queued` → active polling enabled
2. `running` → active polling enabled
3. `completed` + terminal outcome (`succeeded`, `failed`, `partially_succeeded`) → polling disabled
4. Any unrecognized or missing status normalization → treated as non-active for polling decisions
### ReviewPack lifecycle relevant to polling
1. `queued` → polling enabled
2. `generating` → polling enabled
3. `ready` → polling disabled
4. `failed` → polling disabled
5. `expired` → polling disabled
## Validation Rules
- Polling must never start for unauthorized users; existing page and widget access checks remain authoritative.
- Polling must stop automatically once terminal state is reached.
- Polling decisions must be derived from persisted state only; no external API call is required during render.
- Terminal-state rendering must remain stable and must not trigger repeated refresh loops.

View File

@ -0,0 +1,161 @@
# Implementation Plan: Operations Auto-Refresh Pass
**Branch**: `[123-operations-auto-refresh]` | **Date**: 2026-03-08 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/123-operations-auto-refresh/spec.md`
**Note**: This plan is filled in by the `/speckit.plan` workflow.
## Summary
Add conditional auto-refresh to the canonical tenantless operation run viewer and intentionally reusable tenant-scoped review pack card surfaces by reusing existing product polling patterns instead of inventing new infrastructure. The run viewer will stay aligned with the shared run-detail polling helper and its existing hidden-tab / modal-open safeguards, while the embedded review pack widget will emit a simple `10s` Livewire polling interval only when the latest review pack is in an active generation state.
## Technical Context
**Language/Version**: PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2`
**Primary Dependencies**: Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail
**Storage**: PostgreSQL primary app database
**Testing**: Pest feature tests run through `vendor/bin/sail artisan test --compact`
**Target Platform**: Web application rendered through Filament panels on Laravel Sail / containerized deployment
**Project Type**: Single Laravel web application
**Performance Goals**: Active monitoring surfaces should refresh automatically without manual input while avoiding unnecessary polling once work is terminal
**Constraints**: Preserve DB-only render behavior for monitoring surfaces; no WebSockets, no new backend events, no global polling rewrite, no polling on terminal states
**Scale/Scope**: Two existing UI surfaces, no schema changes, no dependency changes, targeted regression coverage only
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **Inventory-first**: PASS — no inventory or snapshot semantics change.
- **Read/write separation**: PASS — no new write workflow is introduced; only passive refresh behavior changes.
- **Graph contract path**: PASS — no Graph calls or contract registry changes are involved.
- **Deterministic capabilities**: PASS — no capability derivation changes.
- **RBAC-UX plane separation**: PASS — `/admin/operations/{run}` remains a canonical Monitoring route and the intentionally reusable tenant-scoped review widget remains tenant-plane only on every host surface, with existing authorization rules unchanged.
- **Workspace isolation**: PASS — the tenantless operation viewer continues to rely on existing workspace membership checks and referenced-tenant entitlement checks for tenant-bound runs.
- **Destructive confirmations**: PASS — no new destructive actions are added.
- **Global search safety**: PASS — feature does not touch global search.
- **Tenant isolation**: PASS — review pack polling remains tenant-scoped and uses existing access checks.
- **Run observability**: PASS — existing `OperationRun` records remain the sole source for operation progress; no new run type or lifecycle mutation path is introduced.
- **Ops-UX 3-surface feedback**: PASS — queued toast, progress surfaces, and terminal notification behavior are unchanged.
- **Ops-UX lifecycle ownership**: PASS — this feature reads run state only; `OperationRunService` remains the only lifecycle transition owner.
- **Ops-UX summary counts**: PASS — unchanged.
- **Ops-UX guards**: PASS — existing helper guard coverage is reused and extended where needed.
- **Ops-UX system runs**: PASS — unchanged.
- **Automation / idempotency**: PASS — unchanged.
- **Data minimization / safe logging**: PASS — unchanged.
- **Badge semantics (BADGE-001)**: PASS — existing badge mappings stay centralized; polling only re-renders them.
- **Filament Action Surface Contract**: PASS WITH EXEMPTION — affected surfaces change passive refresh behavior only, not action definitions.
- **Filament UX-001**: PASS WITH EXEMPTION — no layout or information-architecture changes are introduced.
- **Livewire v4.0+ compliance**: PASS — Filament v5 / Livewire v4 stack remains unchanged.
- **Provider registration location**: PASS — no panel-provider changes; Laravel 12 keeps providers in `bootstrap/providers.php`.
## Phase 0 — Research Summary
Research findings are recorded in [research.md](./research.md).
- Reuse `App\Support\OpsUx\RunDetailPolling::interval()` for tenantless run detail behavior instead of introducing a second run-detail polling policy.
- Preserve the existing run-detail suppression guards for hidden browser tabs and mounted Filament actions.
- Treat review-pack `queued` and `generating` as the only active widget states; `ready`, `failed`, `expired`, and empty state are terminal/non-polling.
- Attach review-pack polling at the widget Blade root using the same `pollingInterval``wire:poll.{interval}` pattern already used by lightweight dashboard widgets.
- Extend existing Pest coverage rather than building new test infrastructure.
## Phase 1 — Design & Contracts
### Data Model
Design details are recorded in [data-model.md](./data-model.md).
Key design points:
- No schema changes are required.
- `OperationRun` remains the source of truth for run-detail polling decisions.
- `ReviewPack` remains the source of truth for review-pack-card polling decisions.
- Polling state is derived view state (`interval string | null`), not persisted state.
### Contracts
Internal UI behavior contracts are recorded in [contracts/polling-contracts.yaml](./contracts/polling-contracts.yaml).
Contract scope:
- Tenantless operation run viewer at `/admin/operations/{run}`
- Intentionally reusable tenant-scoped embedded `TenantReviewPackCard` widget surfaces (component-scoped; no standalone route is owned by the widget itself)
### Quickstart
Implementation and verification steps are recorded in [quickstart.md](./quickstart.md).
### Post-Design Constitution Re-check
- **Inventory-first**: PASS — no data-ownership boundary changes.
- **Read/write separation**: PASS — polling remains read-only.
- **Graph contract path**: PASS — no remote integrations added.
- **RBAC-UX**: PASS — existing 404/403 behavior and capability checks remain authoritative.
- **Workspace isolation**: PASS — tenantless run viewer continues to authorize against the run workspace and referenced tenant scope when the run is tenant-bound.
- **Tenant isolation**: PASS — review-pack-card polling is based on the current tenants latest pack only.
- **Run observability**: PASS — `OperationRun` reuse remains intact; no inline work added to render surfaces.
- **Ops-UX lifecycle ownership**: PASS — plan introduces no direct `status` / `outcome` mutation path.
- **BADGE-001**: PASS — no new badge semantics introduced.
- **Filament Action Surface Contract**: PASS WITH EXEMPTION — no action-surface mutation.
- **UX-001**: PASS WITH EXEMPTION — no layout change required for passive refresh.
## Project Structure
### Documentation (this feature)
```text
specs/123-operations-auto-refresh/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── polling-contracts.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/Operations/TenantlessOperationRunViewer.php
│ ├── Resources/OperationRunResource.php
│ └── Widgets/
│ ├── Dashboard/NeedsAttention.php
│ └── Tenant/TenantReviewPackCard.php
├── Models/
│ ├── OperationRun.php
│ └── ReviewPack.php
├── Services/
│ └── ReviewPackService.php
└── Support/OpsUx/
├── ActiveRuns.php
├── OperationStatusNormalizer.php
└── RunDetailPolling.php
resources/views/
├── filament/pages/operations/tenantless-operation-run-viewer.blade.php
├── filament/widgets/dashboard/needs-attention.blade.php
└── filament/widgets/tenant/tenant-review-pack-card.blade.php
tests/Feature/
├── Operations/TenantlessOperationRunViewerTest.php
├── OpsUx/RunDetailPollingStopsOnTerminalTest.php
└── ReviewPack/ReviewPackWidgetTest.php
```
**Structure Decision**: Keep the implementation inside the existing Laravel / Filament structure. Reuse current polling helpers and Blade widget patterns in place, with targeted updates limited to the two affected surfaces and their directly related tests.
## Testing Strategy
- Extend helper-level coverage for run-detail active-versus-terminal polling behavior only if the shared helper changes.
- Add page/widget assertions that active surfaces emit polling and terminal surfaces do not.
- Keep verification targeted to:
- `tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php`
- `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
- `tests/Feature/ReviewPack/ReviewPackWidgetTest.php`
- `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- `tests/Feature/ReviewPack/ReviewPackRbacTest.php` for unchanged tenant-scope access regression coverage
- Manual QA will cover one active and one terminal example for each affected surface.
## Complexity Tracking
No constitution violations or complexity exemptions are required for this plan.

View File

@ -0,0 +1,45 @@
# Quickstart — Operations Auto-Refresh Pass
## Goal
Implement conditional auto-refresh for the tenantless operation run viewer and the tenant review pack card so active work updates without manual refresh and terminal states remain stable.
## Prerequisites
- Laravel Sail services are running.
- A queue worker is available so active states can advance during manual QA.
- Seed or factory data exists for one active and one terminal example of each surface.
## Implementation Steps
1. Reuse the canonical run-detail polling behavior for `TenantlessOperationRunViewer` rather than introducing a second run-detail polling policy.
2. Add review-pack-card polling that emits a `10s` interval only when the latest tenant review pack is `queued` or `generating`.
3. Keep terminal review-pack states (`ready`, `failed`, `expired`) and empty-state rendering non-polling.
4. Add or update Pest coverage for active-versus-terminal polling behavior on both surfaces.
## Automated Verification
Run the smallest relevant test set through Sail:
- `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackWidgetTest.php`
Then format touched files:
- `vendor/bin/sail bin pint --dirty --format agent`
## Manual QA
### Tenantless operation run viewer
1. Open a queued or running operation at `/admin/operations/{run}`.
2. Keep the page open and confirm the run detail refreshes while the run remains active.
3. Let the run finish and confirm the page settles into a stable terminal state without further refresh behavior.
4. Open an already-completed run and confirm it loads without active polling.
### Tenant review pack card
1. Open the tenant-scoped page that renders `TenantReviewPackCard`.
2. Trigger review-pack generation and confirm the card shows in-progress state without manual refresh.
3. Wait for the pack to finish and confirm the card settles into `Ready` or `Failed` without continuing to poll.
4. Re-open a terminal review pack state and confirm no polling markup is present.
## Rollback
- Remove the new polling condition from the review pack card.
- Revert any tenantless viewer changes so it falls back to the previous non-refreshing behavior.
- Re-run the targeted Pest files to confirm the rollback is clean.

View File

@ -0,0 +1,47 @@
# Research — Operations Auto-Refresh Pass
## Decision: Reuse the shared run-detail polling helper for the tenantless operation run viewer
### Rationale
- The canonical operation detail surface already centralizes conditional polling in `App\Support\OpsUx\RunDetailPolling`.
- `OperationRunResource::infolist()` already applies the established safeguards: stop polling when the browser tab is hidden and when Filament action modals are mounted.
- `TenantlessOperationRunViewer` already delegates its infolist rendering to `OperationRunResource`, so the least-risk implementation path is to keep the tenantless page aligned with that shared detail-view contract instead of introducing a second polling policy.
- Existing tests already cover the helpers active-versus-terminal behavior, which reduces the amount of new logic that needs fresh regression coverage.
### Alternatives considered
- **Hard-code a new fixed `10s` interval inside `TenantlessOperationRunViewer`** — rejected because it would fork the run-detail behavior from the canonical operation detail pattern and risk drifting from the shared hidden-tab and modal-open guards.
- **Duplicate the `RunDetailPolling` logic inside the page class** — rejected because it would create a second source of truth for active run polling.
## Decision: Treat review pack `queued` and `generating` as the only active card states
### Rationale
- `ReviewPackStatus` defines a card-specific lifecycle: `queued`, `generating`, `ready`, `failed`, `expired`.
- The widget view already renders `queued` and `generating` as the same in-progress state and renders `ready`, `failed`, and `expired` as terminal states.
- `ReviewPackService` creates new records in `queued`, and `GenerateReviewPackJob` advances them into `generating` before landing in `ready` or `failed`.
- Using the review-pack lifecycle directly avoids over-polling when unrelated tenant operation runs are active.
### Alternatives considered
- **Drive the card from tenant-wide `ActiveRuns::existForTenant()`** — rejected because this would keep the card polling for unrelated operations even when review-pack generation is not active.
- **Poll whenever any review pack exists** — rejected because terminal states must remain stable and stop refreshing.
## Decision: Attach review-pack polling at the widget root using the existing simple-widget Blade pattern
### Rationale
- `TenantReviewPackCard` is a plain Filament `Widget`, not a table or stats widget.
- The closest established pattern is `NeedsAttention`, which computes a `pollingInterval` in PHP and conditionally emits `wire:poll.{interval}` on the root Blade element.
- A root-level `wire:poll.10s` keeps the implementation small, readable, and consistent with the other lightweight dashboard widgets in the product.
### Alternatives considered
- **Introduce a new shared global polling framework** — rejected because the spec explicitly excludes a broader polling rewrite.
- **Move review-pack polling into JavaScript or browser events** — rejected because the product already relies on Livewire/Filament polling for similar surfaces and the requested scope is intentionally small.
## Decision: Extend existing helper and widget tests instead of inventing new verification infrastructure
### Rationale
- `tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php` already verifies the canonical helper stops polling on terminal operation states and remains active for `queued`/`running` runs.
- `tests/Feature/ReviewPack/ReviewPackWidgetTest.php` already covers the widgets empty, active, and terminal render states and is the most direct place to add polling assertions.
- `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` already covers tenantless access semantics and is the right page-level companion if markup-level assertions are needed.
### Alternatives considered
- **Add a brand-new standalone polling test suite** — rejected because the affected behavior already belongs to well-scoped existing helper and widget tests.
- **Rely on manual QA only** — rejected because the spec requires automated coverage for active-versus-terminal polling behavior.

View File

@ -0,0 +1,132 @@
# Feature Specification: Operations Auto-Refresh Pass
**Feature Branch**: `[123-operations-auto-refresh]`
**Created**: 2026-03-08
**Status**: Draft
**Input**: User description: "Conditional polling for tenantless operation run viewer and tenant review pack generation so active operations update live without manual refresh and stop refreshing once they reach a terminal state."
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**: `/admin/operations/{run}` for the tenantless canonical Monitoring run detail view; intentionally reusable tenant-scoped embedded `TenantReviewPackCard` widget surfaces (component-scoped, with no standalone route of their own)
- **Data Ownership**: existing tenant-owned `OperationRun` operational artifacts viewed through the canonical Monitoring route, plus existing tenant-owned `ReviewPack` generation records; no new ownership rules are introduced
- **RBAC**: existing workspace membership and tenant entitlement checks remain unchanged; the canonical Monitoring route must continue to enforce workspace access and tenant entitlement before revealing tenant-bound runs, and every tenant-scoped host surface that renders the review pack widget must continue to honor tenant membership plus review-pack capability checks
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: No new list filtering is introduced. The canonical route remains record-specific at `/admin/operations/{run}`. If a user opens a run from tenant context, the viewer shows only that requested run and does not broaden the query to other tenant records.
- **Explicit entitlement checks preventing cross-tenant leakage**: The tenantless Monitoring route must continue to authorize against the run workspace and, for tenant-bound runs, against the referenced tenant scope before rendering any record details. Any tenant-scoped host surface that renders the review pack widget must continue to rely on the active tenant context and existing review-pack view/manage capability checks.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Watch an active operation complete (Priority: P1)
An operator viewing an in-progress operation run can keep the page open and trust that status, progress, and final outcome will update on their own while the run remains active.
**Why this priority**: This is the primary trust gap called out in the request. Operations monitoring loses credibility when active work looks stale.
**Independent Test**: Start with an active operation run, open the tenantless run viewer, and confirm the displayed state updates automatically until the run reaches a terminal outcome without any manual refresh.
**Acceptance Scenarios**:
1. **Given** an operator is viewing an active operation run, **When** the run status changes while the page remains open, **Then** the viewer refreshes automatically within the polling window and shows the updated status.
2. **Given** an operator is viewing an active operation run, **When** the run reaches a terminal state, **Then** the viewer stops auto-refreshing and the terminal state remains stable on screen.
---
### User Story 2 - Monitor review pack generation from the card (Priority: P2)
A user who starts tenant review pack generation can stay on the card and see progress or completion without manually refreshing the page.
**Why this priority**: Review pack generation is another asynchronous workflow where stale feedback weakens confidence, but it is secondary to the core operation run viewer.
**Independent Test**: Trigger review pack generation, keep the card visible, and confirm the card refreshes automatically while work is active and stops once generation completes or fails.
**Acceptance Scenarios**:
1. **Given** review pack generation is in progress, **When** the generation state changes, **Then** the card refreshes automatically within the polling window and reflects the latest state.
2. **Given** review pack generation has completed or failed, **When** the user keeps the page open, **Then** the card remains stable and no longer auto-refreshes.
---
### User Story 3 - Avoid noisy refresh behavior on completed work (Priority: P3)
An admin revisiting completed work sees a stable terminal state rather than a screen that keeps refreshing after the outcome is already known.
**Why this priority**: Stopping refresh at the right time is necessary to preserve perceived quality and avoid unnecessary noise or load.
**Independent Test**: Open one completed operation run and one completed review pack result, and confirm neither surface resumes automatic refresh behavior.
**Acceptance Scenarios**:
1. **Given** a user opens a surface for work that is already in a terminal state, **When** the screen loads, **Then** the surface shows the terminal state without entering an active polling cycle.
### Edge Cases
- If a run or generation completes between polling cycles, the next refresh must show the terminal state and stop further polling.
- If the surface loads with an unrecognized or missing status, it must default to non-polling behavior rather than refreshing indefinitely.
- If a single refresh attempt fails while the work is still active, the current visible state should remain stable and the next scheduled refresh may try again without duplicating terminal messaging.
- If a user opens the surface after the work has already finished, the terminal state must appear immediately and remain stable.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce new outbound integrations, new change operations, new scheduled work, or new operation types. It improves freshness on existing progress surfaces only. Contract registry entries, safety gates, tenant isolation rules, and audit behavior remain unchanged.
**Constitution alignment (OPS-UX):** This feature reuses existing long-running operation feedback behavior and only changes how often the progress surfaces refresh while work is active. Toast intent behavior, progress surfaces, and terminal notifications remain unchanged. Operation run status and outcome transitions remain service-owned, and this feature only reads those states to decide whether automatic refresh continues. Summary-count rules and scheduled/system-run behavior are unchanged. Regression coverage must confirm active-versus-terminal refresh behavior on the affected surfaces.
**Constitution alignment (RBAC-UX):** No authorization rules change. The same workspace and tenant entitlements that already control visibility continue to apply. The canonical Monitoring route remains deny-as-not-found for users not entitled to the workspace or referenced tenant scope, while the tenant review widget keeps its existing tenant membership and capability checks. This feature adds no new actions, no new mutations, and no new 403 or 404 semantics.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable to this feature because it does not modify authentication handshake behavior.
**Constitution alignment (BADGE-001):** Existing centralized status and outcome semantics remain the source of truth. Auto-refresh must continue to display those centralized meanings consistently after each refresh cycle.
**Constitution alignment (Filament Action Surfaces):** The action surface contract remains satisfied because this feature does not add, remove, or change any user-triggered actions. It adds passive refresh behavior only.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing layouts, cards, and terminal-state presentations remain in place. The only UX change is passive refresh while work is active, and it must preserve a stable, non-noisy presentation.
### Functional Requirements
- **FR-001**: The system MUST automatically refresh the tenantless operation run viewer while the displayed run is in an active, non-terminal state.
- **FR-002**: The system MUST automatically refresh the tenant review pack card while review pack generation is in progress.
- **FR-003**: The system MUST stop automatic refresh as soon as the displayed work reaches a terminal state, including successful completion, failure, cancellation, or any equivalent final outcome already recognized by the product.
- **FR-004**: The default automatic refresh interval MUST be 10 seconds unless the established shared polling standard used by comparable operation surfaces already defines a different interval for consistency.
- **FR-005**: The system MUST reuse the established conditional polling pattern already trusted on comparable operation-monitoring surfaces so that users experience consistent behavior.
- **FR-006**: The system MUST avoid indefinite refresh cycles for work that is already terminal, unknown, or no longer actively progressing.
- **FR-007**: The system MUST preserve visible state stability during automatic refresh so that operators do not experience distracting flicker, duplicate progress messaging, or noisy terminal transitions.
- **FR-008**: The feature MUST not change existing permissions, routes, operation ownership semantics, or backend state-transition rules.
- **FR-009**: Automated coverage MUST verify that active states enable refresh behavior and terminal states disable it for both affected surfaces.
## UI Action Matrix *(mandatory when Filament is changed)*
This feature changes passive refresh behavior on existing admin surfaces but does not change the available actions.
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenantless operation run viewer | Operations monitoring surface | No action changes in scope | Existing inspect flow unchanged | None changed | None changed | Unchanged | No action changes in scope | Not applicable | No change | Passive auto-refresh only; no new mutation or destructive action |
| Tenant review pack card | Tenant review workflow surface | No action changes in scope | Existing card visibility unchanged | No action changes in scope | None changed | Unchanged | Not applicable | Not applicable | No change | Passive auto-refresh only; generation actions and audit behavior remain as-is |
### Key Entities *(include if feature involves data)*
- **Operation Run**: A long-running unit of work whose status and outcome determine whether the tenantless viewer should continue refreshing.
- **Tenant Review Pack Generation**: A review-pack production lifecycle whose current state determines whether the tenant review card should continue refreshing.
## Assumptions
- The product already defines which run and generation states are considered active versus terminal.
- An existing polling pattern already exists on comparable monitoring surfaces and can be reused without redesigning global behavior.
- Users viewing these surfaces already have permission to see the underlying operation or review pack status.
## Dependencies
- Reliable persisted status semantics for operation runs and review pack generation.
- Existing conditional polling behavior used elsewhere in the product as the consistency baseline.
- Existing QA ability to observe one active example and one terminal example for each affected surface.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In manual QA, users observing an active operation run see the displayed state update automatically within 10 seconds of the persisted status changing, without manually refreshing the page.
- **SC-002**: In manual QA, users observing active review pack generation see the displayed state update automatically within 10 seconds of the persisted generation state changing, without manually refreshing the page.
- **SC-003**: In automated and manual verification, 100% of covered terminal-state examples on the affected surfaces stop automatic refresh after the terminal state is recognized.
- **SC-004**: During manual observation of at least three consecutive refresh cycles on each active surface, reviewers observe no excessive flicker, duplicate state messages, or unstable terminal-state rendering.

View File

@ -0,0 +1,199 @@
# Tasks: Operations Auto-Refresh Pass
**Input**: Design documents from `/specs/123-operations-auto-refresh/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/polling-contracts.yaml, quickstart.md
**Tests**: Tests are REQUIRED for this runtime behavior change. Use Pest feature tests via Sail.
**Operations**: No new `OperationRun` type or lifecycle work is required for this feature; tasks must reuse existing `OperationRun` observability semantics and preserve the existing tenantless Monitoring route.
**RBAC**: No authorization model changes are planned; tasks must preserve existing workspace membership checks for the tenantless run viewer and existing tenant capability checks for every tenant-scoped host surface that renders the review pack card.
**Filament UI Action Surfaces**: The affected Filament surfaces change passive refresh behavior only; no new actions, destructive flows, or action-matrix rows are introduced beyond the approved spec exemption.
**Filament UI UX-001**: No Create/Edit/View layout changes are required; tasks must preserve existing viewer and card layouts while adding non-noisy auto-refresh behavior.
**Organization**: Tasks are grouped by user story so each story remains independently testable.
## Phase 1: Setup (Shared Context)
**Purpose**: Align implementation with the approved polling contract and current codebase patterns before editing runtime code.
- [X] T001 [P] Review the approved polling behavior in specs/123-operations-auto-refresh/spec.md, specs/123-operations-auto-refresh/research.md, specs/123-operations-auto-refresh/contracts/polling-contracts.yaml, and specs/123-operations-auto-refresh/quickstart.md
- [X] T002 [P] Inspect the current shared polling implementations in app/Support/OpsUx/RunDetailPolling.php, app/Filament/Resources/OperationRunResource.php, app/Filament/Widgets/Dashboard/NeedsAttention.php, and resources/views/filament/widgets/dashboard/needs-attention.blade.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Confirm the active-vs-terminal state sources that both polling surfaces depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 Confirm polling state ownership in app/Support/OpsUx/OperationStatusNormalizer.php, app/Support/ReviewPackStatus.php, app/Models/ReviewPack.php, app/Services/ReviewPackService.php, and app/Jobs/GenerateReviewPackJob.php so active and terminal semantics stay consistent across both surfaces
**Checkpoint**: Shared polling semantics are confirmed and user-story implementation can begin.
---
## Phase 3: User Story 1 - Watch an active operation complete (Priority: P1) 🎯 MVP
**Goal**: Make the tenantless operation run viewer update automatically while a run remains active.
**Independent Test**: Open `/admin/operations/{run}` for queued and running runs and verify the page emits auto-refresh behavior; open the same page for a completed run and verify it does not rely on manual refresh for active monitoring.
### Tests for User Story 1 ⚠️
> **NOTE**: Add or update these tests first and ensure they fail before implementation.
- [X] T004 [P] [US1] Extend tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php to cover the shared queued/running polling contract and age-based interval expectations in app/Support/OpsUx/RunDetailPolling.php
- [X] T005 [P] [US1] Extend tests/Feature/Operations/TenantlessOperationRunViewerTest.php to assert queued and running runs render polling markup on the `/admin/operations/{run}` viewer while preserving the existing workspace-membership and tenant-entitlement access semantics on that canonical route
### Implementation for User Story 1
- [X] T006 [US1] Ensure app/Filament/Pages/Operations/TenantlessOperationRunViewer.php and resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php surface the shared polling contract from app/Filament/Resources/OperationRunResource.php without breaking the existing hidden-tab and mounted-action guards in app/Support/OpsUx/RunDetailPolling.php
**Checkpoint**: User Story 1 should now allow operators to watch active tenantless runs update automatically.
---
## Phase 4: User Story 2 - Monitor review pack generation from the card (Priority: P2)
**Goal**: Make the tenant review pack card refresh itself while generation is queued or in progress.
**Independent Test**: Open any tenant-scoped host surface that renders `TenantReviewPackCard`, trigger review-pack generation, and verify the card refreshes itself until the pack finishes.
### Tests for User Story 2 ⚠️
- [X] T007 [P] [US2] Extend tests/Feature/ReviewPack/ReviewPackWidgetTest.php to assert queued and generating review pack states render `wire:poll.10s` on the card while preserving the widgets existing tenant membership and review-pack capability behavior
- [X] T008 [P] [US2] Extend tests/Feature/ReviewPack/ReviewPackGenerationTest.php to keep `queued` and `generating` aligned as the only active lifecycle states that should drive card polling
### Implementation for User Story 2
- [X] T009 [US2] Add a review-pack polling interval resolver in app/Filament/Widgets/Tenant/TenantReviewPackCard.php that returns `10s` only when the latest pack status is `queued` or `generating`
- [X] T010 [US2] Apply conditional root-level polling markup in resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php using the interval exposed by app/Filament/Widgets/Tenant/TenantReviewPackCard.php
**Checkpoint**: User Story 2 should now let users observe review pack generation without manual refresh.
---
## Phase 5: User Story 3 - Avoid noisy refresh behavior on completed work (Priority: P3)
**Goal**: Stop polling cleanly for terminal or unknown states so completed surfaces remain stable.
**Independent Test**: Open completed/failed tenantless runs plus ready/failed/expired/no-pack review card states and confirm none of them emit polling markup or restart refresh loops.
### Tests for User Story 3 ⚠️
- [X] T011 [P] [US3] Extend tests/Feature/Operations/TenantlessOperationRunViewerTest.php to assert completed and failed tenantless runs render without polling markup when loaded directly in a terminal state, without regressing the routes existing 404 and 403 behavior
- [X] T012 [P] [US3] Extend tests/Feature/ReviewPack/ReviewPackWidgetTest.php to assert ready, failed, expired, and no-pack card states render without polling markup
### Implementation for User Story 3
- [X] T013 [US3] Harden non-active fallback logic in app/Support/OpsUx/RunDetailPolling.php and app/Filament/Widgets/Tenant/TenantReviewPackCard.php so terminal or unrecognized states always resolve to `null` polling without visible UI churn
**Checkpoint**: User Story 3 should now guarantee stable terminal states across both surfaces.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final verification, manual QA, and formatting across all stories.
- [ ] T014 Perform the manual QA flow from specs/123-operations-auto-refresh/quickstart.md for one active and one terminal tenantless run plus one active and one terminal review pack generation lifecycle
- [X] T015 Run focused Pest coverage with `vendor/bin/sail artisan test --compact` for tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php, tests/Feature/ReviewPack/ReviewPackWidgetTest.php, and tests/Feature/ReviewPack/ReviewPackGenerationTest.php
- [X] T016 Run `vendor/bin/sail bin pint --dirty --format agent`
- [X] T017 Run existing non-regression authorization coverage with `vendor/bin/sail artisan test --compact` for tests/Feature/Operations/TenantlessOperationRunViewerTest.php and tests/Feature/ReviewPack/ReviewPackRbacTest.php to confirm routes, ownership semantics, and access rules remain unchanged
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the MVP.
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can proceed after US1 or in parallel once shared polling semantics are confirmed.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from US1/US2 polling hooks being in place.
- **Polish (Phase 6)**: Depends on completion of the desired user stories.
### User Story Dependencies
- **US1**: No dependency on other stories; delivers the primary operator-facing value.
- **US2**: No dependency on US1; it depends only on the shared polling semantics confirmed in Phase 2.
- **US3**: Depends on the active-polling implementation paths from US1 and US2 so terminal-state behavior can be finalized and verified.
### Within Each User Story
- Tests should be added or updated first and observed failing before implementation.
- Shared helper or resolver changes should land before Blade markup changes that consume them.
- Story-specific verification should complete before moving to the next priority.
### Parallel Opportunities
- T001 and T002 can run in parallel.
- T004 and T005 can run in parallel.
- T007 and T008 can run in parallel.
- T011 and T012 can run in parallel.
- After Phase 2, US1 and US2 can be implemented in parallel if different contributors own the run viewer and review pack card.
---
## Parallel Example: User Story 1
```bash
Task: "Extend tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php to cover the shared queued/running polling contract and age-based interval expectations"
Task: "Extend tests/Feature/Operations/TenantlessOperationRunViewerTest.php to assert queued and running runs render polling markup on the /admin/operations/{run} viewer"
```
---
## Parallel Example: User Story 2
```bash
Task: "Extend tests/Feature/ReviewPack/ReviewPackWidgetTest.php to assert queued and generating review pack states render wire:poll.10s on the card"
Task: "Extend tests/Feature/ReviewPack/ReviewPackGenerationTest.php to keep queued and generating aligned as the only active lifecycle states that should drive card polling"
```
---
## Parallel Example: User Story 3
```bash
Task: "Extend tests/Feature/Operations/TenantlessOperationRunViewerTest.php to assert completed and failed tenantless runs render without polling markup"
Task: "Extend tests/Feature/ReviewPack/ReviewPackWidgetTest.php to assert ready, failed, expired, and no-pack card states render without polling markup"
```
---
## 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 queued and running tenantless operation runs auto-refresh correctly
5. Stop and review before broadening the change to the tenant review pack card if needed
### Incremental Delivery
1. Ship the tenantless run viewer polling improvement in US1
2. Add review pack card polling in US2
3. Harden terminal-state stability and non-active fallback behavior in US3
4. Finish with focused Sail tests, manual QA, and Pint
### Parallel Team Strategy
With multiple contributors:
1. One contributor owns tenantless run viewer tests and implementation (US1)
2. One contributor owns review pack card tests and implementation (US2)
3. One contributor validates terminal/noisy-state regression coverage (US3)
4. Recombine for focused Sail test execution and formatting
---
## Notes
- [P] tasks touch different files and can be executed in parallel.
- User story labels map each task to the corresponding story in spec.md.
- No migrations, no new routes, and no dependency changes are expected.
- Manual QA evidence belongs in review artifacts, not committed files.

View File

@ -9,6 +9,7 @@
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\TenantRole;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Http;
@ -142,3 +143,75 @@
->assertSee('permission_denied')
->assertSee($failureMessage);
});
it('renders shared polling markup for active tenantless runs', function (string $status, int $ageSeconds): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->forget(WorkspaceContext::SESSION_KEY);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => null,
'type' => 'provider.connection.check',
'status' => $status,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subSeconds($ageSeconds),
]);
$expectedInterval = RunDetailPolling::interval($run);
expect($expectedInterval)->not->toBeNull();
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->assertSuccessful()
->assertSee("wire:poll.{$expectedInterval}", escape: false);
})->with([
'queued runs poll every second at startup' => [
OperationRunStatus::Queued->value,
5,
],
'running runs slow to five seconds after startup' => [
OperationRunStatus::Running->value,
30,
],
]);
it('does not render polling markup for terminal tenantless runs', function (string $outcome): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->forget(WorkspaceContext::SESSION_KEY);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => null,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'created_at' => now()->subMinutes(2),
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->assertSuccessful()
->assertDontSee('wire:poll.1s', escape: false)
->assertDontSee('wire:poll.5s', escape: false)
->assertDontSee('wire:poll.10s', escape: false);
})->with([
'succeeded runs stay stable' => OperationRunOutcome::Succeeded->value,
'failed runs stay stable' => OperationRunOutcome::Failed->value,
]);

View File

@ -3,25 +3,76 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use Illuminate\Support\Carbon;
it('disables run-detail polling once the run is terminal', function (): void {
$run = OperationRun::factory()->create([
'status' => 'completed',
'outcome' => 'succeeded',
it('returns the expected interval for active runs based on age', function (string $status, int $ageSeconds, string $expectedInterval): void {
$referenceTime = now();
Carbon::setTestNow($referenceTime);
try {
$run = OperationRun::factory()->make([
'status' => $status,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => $referenceTime->copy()->subSeconds($ageSeconds),
]);
expect(RunDetailPolling::interval($run))->toBe($expectedInterval);
} finally {
Carbon::setTestNow();
}
})->with([
'queued runs younger than 10 seconds poll every second' => [
OperationRunStatus::Queued->value,
9,
'1s',
],
'queued runs at 10 seconds slow to 5 seconds' => [
OperationRunStatus::Queued->value,
10,
'5s',
],
'running runs younger than 60 seconds poll every 5 seconds' => [
OperationRunStatus::Running->value,
59,
'5s',
],
'running runs at 60 seconds slow to 10 seconds' => [
OperationRunStatus::Running->value,
60,
'10s',
],
])->group('ops-ux');
it('disables run-detail polling once the run is terminal or unrecognized', function (string $status, string $outcome): void {
$run = OperationRun::factory()->make([
'status' => $status,
'outcome' => $outcome,
]);
expect(RunDetailPolling::interval($run))->toBeNull();
})->group('ops-ux');
it('enables run-detail polling while the run is queued or running', function (string $status): void {
$run = OperationRun::factory()->create([
'status' => $status,
'outcome' => 'pending',
]);
expect(RunDetailPolling::interval($run))->not->toBeNull();
})->with([
'queued' => 'queued',
'running' => 'running',
'completed succeeded' => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Succeeded->value,
],
'completed partially succeeded' => [
OperationRunStatus::Completed->value,
OperationRunOutcome::PartiallySucceeded->value,
],
'completed failed' => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
],
'legacy failed status' => [
'failed',
OperationRunOutcome::Pending->value,
],
'unknown status fallback' => [
'stalled',
OperationRunOutcome::Pending->value,
],
])->group('ops-ux');

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Jobs\GenerateReviewPackJob;
use App\Models\Finding;
use App\Models\OperationRun;
@ -26,6 +27,51 @@
Storage::fake('exports');
});
it('treats only queued and generating review packs as active for card polling', function (string $status, ?string $expectedInterval): void {
$tenant = Tenant::factory()->create();
$pack = match ($status) {
ReviewPackStatus::Queued->value => ReviewPack::factory()->queued()->make([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]),
ReviewPackStatus::Generating->value => ReviewPack::factory()->generating()->make([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]),
ReviewPackStatus::Ready->value => ReviewPack::factory()->ready()->make([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]),
ReviewPackStatus::Failed->value => ReviewPack::factory()->failed()->make([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]),
ReviewPackStatus::Expired->value => ReviewPack::factory()->expired()->make([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]),
default => ReviewPack::factory()->make([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => $status,
]),
};
expect(TenantReviewPackCard::resolvePollingInterval($pack))->toBe($expectedInterval);
})->with([
'queued pack' => [ReviewPackStatus::Queued->value, '10s'],
'generating pack' => [ReviewPackStatus::Generating->value, '10s'],
'ready pack' => [ReviewPackStatus::Ready->value, null],
'failed pack' => [ReviewPackStatus::Failed->value, null],
'expired pack' => [ReviewPackStatus::Expired->value, null],
'unknown status' => ['stalled', null],
]);
it('does not poll the review pack card when no pack exists', function (): void {
expect(TenantReviewPackCard::resolvePollingInterval(null))->toBeNull();
});
// ─── Helper ──────────────────────────────────────────────────
function seedTenantWithData(Tenant $tenant): void

View File

@ -28,7 +28,8 @@
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('No review pack generated yet')
->assertSee('Generate');
->assertSee('Generate')
->assertDontSee('wire:poll.10s', escape: false);
});
// ─── Ready State ─────────────────────────────────────────────
@ -54,7 +55,8 @@
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Download')
->assertSee('Generate new');
->assertSee('Generate new')
->assertDontSee('wire:poll.10s', escape: false);
});
// ─── Generating State ────────────────────────────────────────
@ -74,7 +76,8 @@
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Generation in progress');
->assertSee('Generation in progress')
->assertSee('wire:poll.10s', escape: false);
});
// ─── Queued State ────────────────────────────────────────────
@ -94,7 +97,8 @@
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Generation in progress');
->assertSee('Generation in progress')
->assertSee('wire:poll.10s', escape: false);
});
// ─── Failed State ────────────────────────────────────────────
@ -114,7 +118,8 @@
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Retry');
->assertSee('Retry')
->assertDontSee('wire:poll.10s', escape: false);
});
// ─── Expired State ───────────────────────────────────────────
@ -134,7 +139,8 @@
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Generate new');
->assertSee('Generate new')
->assertDontSee('wire:poll.10s', escape: false);
});
// ─── Generate Pack Livewire Action ──────────────────────────