057-filament-v5-upgrade #66

Merged
ahmido merged 23 commits from 057-filament-v5-upgrade into dev 2026-01-20 21:19:28 +00:00
89 changed files with 5827 additions and 526 deletions
Showing only changes of commit 94f8719e09 - Show all commits

View File

@ -2,6 +2,7 @@ dist/
build/
public/build/
node_modules/
vendor/
*.log
.env
.env.*

View File

@ -1,16 +1,11 @@
<!--
Sync Impact Report
- Version change: 1.0.0 → 1.1.0
- Version change: 1.2.0 → 1.2.1
- Modified principles:
- Safety-First Restore → Read/Write Separation by Default
- Auditability & Tenant Isolation → Tenant Isolation is Non-negotiable (+ Least Privilege)
- Graph Abstraction & Contracts → Single Contract Path to Graph
- Added principles:
- Inventory-first, Snapshots-second
- Deterministic Capabilities
- Automation must be Idempotent & Observable
- Data Minimization & Safe Logging
- Operations / Run Observability Standard (clarify AuditLog vs OperationRun)
- Added sections: None
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
@ -48,11 +43,29 @@ ### Tenant Isolation is Non-negotiable
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
### Automation must be Idempotent & Observable
### Operations / Run Observability Standard
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
- An action MUST create/reuse a canonical `OperationRun` and execute asynchronously when any of the following applies:
1. It can take > 2 seconds under normal conditions.
2. It performs remote/external calls (e.g., Microsoft Graph).
3. It is queued or scheduled.
4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”).
- Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`.
- If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry
including actor, tenant, action, target, before/after, and timestamp.
- The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures),
even if implemented by multiple jobs/steps (“umbrella run”).
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
- Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
confirm + “View run”.
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
in failures or notifications.
- Graph calls are allowed only via explicit user interaction and only when delegated auth is present; never as a render side-effect (restore group mapping is intentionally DB-only).
- Monitoring → Operations is reserved for `OperationRun`-tracked operations.
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
- Long-running operations MUST create run records with status, counts, and stable error codes.
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
- Failures MUST be visible and actionable (no silent best-effort).
### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
@ -83,4 +96,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-07
**Version**: 1.2.1 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-17

View File

@ -36,7 +36,8 @@ ## Constitution Check
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Automation: queued/scheduled ops are locked, idempotent, observable; handle 429/503 with backoff+jitter
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
## Project Structure

View File

@ -77,8 +77,10 @@ ### Edge Cases
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls or any write/change behavior,
the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests.
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
<!--
ACTION REQUIRED: The content in this section represents placeholders.

View File

@ -9,6 +9,9 @@ # Tasks: [FEATURE NAME]
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.

View File

@ -830,3 +830,10 @@ ### Replaced Utilities
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines>
## Active Technologies
- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416)
- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416)
## Recent Changes
- 054-unify-runs-suitewide-session-1768601416: Added PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3

View File

@ -0,0 +1,219 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
{--tenant=* : Limit to tenant_id/external_id}
{--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
public function handle(OperationRunService $operationRunService, BulkOperationService $bulkOperationService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query()
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) {
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
}
if ($tenantIdentifiers !== []) {
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
if ($tenantIds === []) {
$this->info('No tenants matched the provided identifiers.');
return self::SUCCESS;
}
$query->whereIn('tenant_id', $tenantIds);
}
$reconciled = 0;
$skipped = 0;
$failed = 0;
foreach ($query->cursor() as $operationRun) {
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
if (! is_numeric($backupScheduleRunId)) {
$skipped++;
continue;
}
$scheduleRun = BackupScheduleRun::query()
->whereKey((int) $backupScheduleRunId)
->where('tenant_id', $operationRun->tenant_id)
->first();
if (! $scheduleRun) {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
failures: [
[
'code' => 'RUN_NOT_FOUND',
'message' => $bulkOperationService->sanitizeFailureReason('Backup schedule run not found.'),
],
],
);
}
$failed++;
continue;
}
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
if (! $dryRun) {
$operationRunService->updateRun($operationRun, 'running', 'pending');
if ($scheduleRun->started_at) {
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
}
}
$reconciled++;
continue;
}
$outcome = match ($scheduleRun->status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
BackupScheduleRun::STATUS_SKIPPED,
BackupScheduleRun::STATUS_CANCELED => 'cancelled',
default => 'failed',
};
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
$syncFailures = $summary['sync_failures'] ?? [];
$summaryCounts = [
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
'backup_set_id' => $scheduleRun->backup_set_id ? (int) $scheduleRun->backup_set_id : null,
'policies_total' => (int) ($summary['policies_total'] ?? 0),
'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0),
'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0,
];
$summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null);
$failures = [];
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
$failures[] = [
'code' => (string) ($scheduleRun->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
'message' => $bulkOperationService->sanitizeFailureReason((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
];
}
if (is_array($syncFailures)) {
foreach ($syncFailures as $failure) {
if (! is_array($failure)) {
continue;
}
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$errors = $failure['errors'] ?? null;
$firstErrorMessage = null;
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
$firstErrorMessage = $errors[0]['message'] ?? null;
}
$message = $status !== null
? "{$policyType}: Graph returned {$status}"
: "{$policyType}: Graph request failed";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$message .= ' - '.trim($firstErrorMessage);
}
$failures[] = [
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
'message' => $bulkOperationService->sanitizeFailureReason($message),
];
}
}
if (! $dryRun) {
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
]),
]);
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
$operationRun->forceFill([
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
])->save();
}
$reconciled++;
}
$this->info(sprintf(
'Reconciled %d run(s), skipped %d, failed %d.',
$reconciled,
$skipped,
$failed,
));
if ($dryRun) {
$this->comment('Dry-run: no changes written.');
}
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->forTenant($identifier)
->first();
if ($tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -2,20 +2,23 @@
namespace App\Filament\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\RunIdempotency;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
@ -43,6 +46,8 @@ class DriftLanding extends Page
public ?string $currentFinishedAt = null;
public ?int $operationRunId = null;
public ?int $bulkOperationRunId = null;
/** @var array<string, int>|null */
@ -98,6 +103,19 @@ public function mount(): void
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
$existingOperationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
->where('context->scope_key', $scopeKey)
->where('context->baseline_run_id', (int) $baseline->getKey())
->where('context->current_run_id', (int) $current->getKey())
->latest('id')
->first();
if ($existingOperationRun instanceof OperationRun) {
$this->operationRunId = (int) $existingOperationRun->getKey();
}
$idempotencyKey = RunIdempotency::buildKey(
tenantId: (int) $tenant->getKey(),
operationType: 'drift.generate',
@ -184,6 +202,41 @@ public function mount(): void
return;
}
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'drift.generate',
inputs: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
initiator: $user
);
$this->operationRunId = (int) $opRun->getKey();
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
$this->state = 'generating'; // Reflect generating state in UI if idempotency hit
// Optionally, we could find the related BulkOpRun to link, but the UI might just need state.
Notification::make()
->title('Drift generation already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
// ----------------------------------------------
$bulkOperationService = app(BulkOperationService::class);
$run = $bulkOperationService->createRun(
tenant: $tenant,
@ -203,28 +256,31 @@ public function mount(): void
$this->state = 'generating';
$this->bulkOperationRunId = (int) $run->getKey();
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $baseline, $current, $scopeKey, $run, $opRun): void {
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
operationRun: $opRun
);
});
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'bulk_operation',
'run_id' => (int) $run->getKey(),
'status' => 'queued',
'counts' => [
'total' => (int) $run->total_items,
'processed' => (int) $run->processed_items,
'succeeded' => (int) $run->succeeded,
'failed' => (int) $run->failed,
'skipped' => (int) $run->skipped,
],
]));
Notification::make()
->title('Drift generation queued')
->body('Drift generation has been queued. Monitor progress in Monitoring → Operations.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
}
public function getFindingsUrl(): string
@ -250,12 +306,12 @@ public function getCurrentRunUrl(): ?string
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
}
public function getBulkRunUrl(): ?string
public function getOperationRunUrl(): ?string
{
if (! is_int($this->bulkOperationRunId)) {
if (! is_int($this->operationRunId)) {
return null;
}
return BulkOperationRunResource::getUrl('view', ['record' => $this->bulkOperationRunId], tenant: Tenant::current());
return OperationRunLinks::view($this->operationRunId, Tenant::current());
}
}

View File

@ -11,6 +11,8 @@
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Action as HintAction;
@ -150,17 +152,52 @@ protected function getHeaderActions(): array
}
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'inventory.sync',
inputs: $computed['selection'],
initiator: $user
);
// If run is already active (was recently created or re-used), and we want to enforce re-use:
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
// Just notify and exit (Idempotency)
Notification::make()
->title('Inventory sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
// ----------------------------------------------
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Sync already running')
->body('An inventory sync is already running for this tenant. Check the progress widget for status.')
->title('Inventory sync already active')
->body('A matching inventory sync run is already pending or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
@ -201,19 +238,27 @@ protected function getHeaderActions(): array
Notification::make()
->title('Inventory sync started')
->body('Sync dispatched. Check the bottom-right progress widget for status.')
->body('Sync dispatched. Check the progress widget or Monitoring.')
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
bulkRunId: (int) $bulkRun->getKey(),
inventorySyncRunId: (int) $run->getKey(),
);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $bulkRun, $opRun): void {
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id,
bulkRunId: (int) $bulkRun->getKey(),
operationRun: $opRun
);
});
}),
];
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Filament\Pages\Monitoring;
use App\Models\OperationRun;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class Operations extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $title = 'Operations';
// Must be non-static
protected string $view = 'filament.pages.monitoring.operations';
public function table(Table $table): Table
{
return $table
->query(
OperationRun::query()
->where('tenant_id', Filament::getTenant()->id)
->latest('created_at')
)
->columns([
TextColumn::make('type')
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->colors([
'secondary' => 'queued',
'warning' => 'running',
'success' => 'completed',
]),
TextColumn::make('outcome')
->badge()
->colors([
'gray' => 'pending',
'success' => 'succeeded',
'warning' => 'partially_succeeded',
'danger' => 'failed',
'secondary' => 'cancelled',
]),
TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->label('Started'),
TextColumn::make('duration')
->getStateUsing(function (OperationRun $record) {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '-';
}),
])
->filters([
SelectFilter::make('outcome')
->options([
'succeeded' => 'Succeeded',
'partially_succeeded' => 'Partially Succeeded',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
'pending' => 'Pending',
]),
SelectFilter::make('type')
->options(
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
->distinct()
->pluck('type', 'type')
->toArray()
),
Filter::make('created_at')
->form([
DatePicker::make('created_from'),
DatePicker::make('created_until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['created_from'],
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['created_until'],
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
);
}),
])
->actions([
// View action handled by opening a modal or side-peek
]);
}
}

View File

@ -15,6 +15,8 @@
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\TenantRole;
use BackedEnum;
use Carbon\CarbonImmutable;
@ -30,7 +32,6 @@
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Events\DatabaseNotificationsSent;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
@ -295,6 +296,32 @@ public static function table(Table $table): Table
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $userModel
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Run already queued')
->body('This schedule already has a queued or running backup.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
@ -319,11 +346,38 @@ public static function table(Table $table): Table
->title('Run already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
@ -355,17 +409,19 @@ public static function table(Table $table): Table
->id;
}
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
});
$notification = Notification::make()
->title('Run dispatched')
->body('The backup run has been queued.')
->success();
if ($userModel instanceof User) {
$userModel->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($userModel);
}
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
]);
$notification->send();
}),
@ -382,6 +438,32 @@ public static function table(Table $table): Table
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.retry',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $userModel
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Retry already queued')
->body('This schedule already has a queued or running retry.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
@ -406,11 +488,38 @@ public static function table(Table $table): Table
->title('Retry already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
@ -442,17 +551,19 @@ public static function table(Table $table): Table
->id;
}
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
});
$notification = Notification::make()
->title('Retry dispatched')
->body('A new backup run has been queued.')
->success();
if ($userModel instanceof User) {
$userModel->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($userModel);
}
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
]);
$notification->send();
}),
@ -479,6 +590,8 @@ public static function table(Table $table): Table
$tenant = Tenant::current();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$bulkRun = null;
if ($user) {
@ -496,6 +609,19 @@ public static function table(Table $table): Table
/** @var BackupSchedule $record */
foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $user
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
@ -516,11 +642,33 @@ public static function table(Table $table): Table
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
@ -538,7 +686,9 @@ public static function table(Table $table): Table
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
}, emitQueuedNotification: false);
}
$notification = Notification::make()
@ -552,8 +702,11 @@ public static function table(Table $table): Table
}
if ($user instanceof User) {
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
$notification->actions([
Action::make('view_runs')
->label('View in Operations')
->url(OperationRunLinks::index($tenant)),
])->sendToDatabase($user);
}
$notification->send();
@ -573,6 +726,8 @@ public static function table(Table $table): Table
$tenant = Tenant::current();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$bulkRun = null;
if ($user) {
@ -590,6 +745,19 @@ public static function table(Table $table): Table
/** @var BackupSchedule $record */
foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.retry',
inputs: [
'backup_schedule_id' => (int) $record->getKey(),
],
initiator: $user
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
@ -610,11 +778,33 @@ public static function table(Table $table): Table
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
@ -632,7 +822,9 @@ public static function table(Table $table): Table
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
}, emitQueuedNotification: false);
}
$notification = Notification::make()
@ -646,8 +838,11 @@ public static function table(Table $table): Table
}
if ($user instanceof User) {
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
$notification->actions([
Action::make('view_runs')
->label('View in Operations')
->url(OperationRunLinks::index($tenant)),
])->sendToDatabase($user);
}
$notification->send();

View File

@ -3,8 +3,12 @@
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
use App\Filament\Resources\PolicyResource;
use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem;
use App\Services\Intune\AuditLogger;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
@ -18,10 +22,30 @@ class BackupItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
public ?int $pollUntil = null;
protected $listeners = [
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
];
public function closeAddPoliciesModal(): void
{
$this->unmountAction();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}
public function shouldPollTable(): bool
{
return $this->pollUntil !== null && now()->getTimestamp() < $this->pollUntil;
}
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->poll(fn (): ?string => (filled($this->mountedActions) || ! $this->shouldPollTable()) ? null : '2s')
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Item')
@ -128,30 +152,77 @@ public function table(Table $table): Table
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record, AuditLogger $auditLogger) {
$record->delete();
->action(function (BackupItem $record): void {
$backupSet = $this->getOwnerRecord();
if ($record->backupSet) {
$record->backupSet->update([
'item_count' => $record->backupSet->items()->count(),
]);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.item_removed',
resourceType: 'backup_set',
resourceId: (string) $record->backup_set_id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id]]
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $user->canSyncTenant($tenant)) {
abort(403);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(403);
}
$backupItemIds = [(int) $record->getKey()];
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
}
});
Notification::make()
->title('Policy removed from backup')
->title('Removal queued')
->body('A background job has been queued. You can monitor progress in the run details or progress widget.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
@ -162,42 +233,93 @@ public function table(Table $table): Table
->icon('heroicon-o-x-mark')
->color('danger')
->requiresConfirmation()
->action(function (Collection $records, AuditLogger $auditLogger) {
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
if ($records->isEmpty()) {
return;
}
$backupSet = $this->getOwnerRecord();
$records->each(fn (BackupItem $record) => $record->delete());
$backupSet->update([
'item_count' => $backupSet->items()->count(),
]);
$tenant = $records->first()?->tenant;
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->id,
status: 'success',
context: [
'metadata' => [
'removed_count' => $records->count(),
'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(),
'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(),
],
]
);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $user->canSyncTenant($tenant)) {
abort(403);
}
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
abort(403);
}
$backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) {
return;
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.remove_policies',
inputs: [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
RemovePoliciesFromBackupSetJob::dispatch(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: $backupItemIds,
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
});
Notification::make()
->title('Policies removed from backup')
->title('Removal queued')
->body('A background job has been queued. You can monitor progress in the run details or progress widget.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
$this->resetTable();
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
}),
]),
]);

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
@ -24,6 +25,8 @@ class BulkOperationRunResource extends Resource
protected static ?string $model = BulkOperationRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
@ -39,6 +42,18 @@ public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (BulkOperationRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Run')
->schema([
TextEntry::make('user.name')

View File

@ -8,9 +8,11 @@
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords
@ -72,6 +74,32 @@ protected function getHeaderActions(): array
$selectionKey = EntraGroupSelection::allGroupsV1();
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => $selectionKey],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
// ----------------------------------------------
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
@ -80,14 +108,17 @@ protected function getHeaderActions(): array
->first();
if ($existing instanceof EntraGroupSyncRun) {
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $existing->getKey(),
'status' => $normalizedStatus,
]));
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
return;
}
@ -105,14 +136,20 @@ protected function getHeaderActions(): array
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
operationRun: $opRun
));
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
Notification::make()
->title('Group sync started')
->body('Sync dispatched.')
->success()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
}),
];
}

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
@ -23,6 +24,8 @@ class EntraGroupSyncRunResource extends Resource
protected static ?string $model = EntraGroupSyncRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory';
@ -38,6 +41,18 @@ public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Sync Run')
->schema([
TextEntry::make('initiator.name')

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
@ -21,6 +22,8 @@ class InventorySyncRunResource extends Resource
{
protected static ?string $model = InventorySyncRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
@ -34,6 +37,18 @@ public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Sync Run')
->schema([
TextEntry::make('user.name')

View File

@ -0,0 +1,245 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class OperationRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = OperationRun::class;
protected static ?string $slug = 'operations';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Run')
->schema([
TextEntry::make('type')->badge(),
TextEntry::make('status')
->badge()
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
TextEntry::make('outcome')
->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
TextEntry::make('initiator_name')->label('Initiator'),
TextEntry::make('created_at')->dateTime(),
TextEntry::make('started_at')->dateTime()->placeholder('—'),
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
])
->columns(2)
->columnSpanFull(),
Section::make('Counts')
->schema([
ViewEntry::make('summary_counts')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
->columnSpanFull(),
Section::make('Failures')
->schema([
ViewEntry::make('failure_summary')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(),
Section::make('Context')
->schema([
ViewEntry::make('context')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->context ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
Tables\Columns\TextColumn::make('type')
->label('Operation')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->label('Started')
->since()
->sortable(),
Tables\Columns\TextColumn::make('duration')
->getStateUsing(function (OperationRun $record): string {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '—';
}),
Tables\Columns\TextColumn::make('outcome')
->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
])
->filters([
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
if (! $tenantId) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->select('type')
->distinct()
->orderBy('type')
->pluck('type', 'type')
->all();
}),
Tables\Filters\SelectFilter::make('status')
->options([
OperationRunStatus::Queued->value => 'Queued',
OperationRunStatus::Running->value => 'Running',
OperationRunStatus::Completed->value => 'Completed',
]),
Tables\Filters\SelectFilter::make('outcome')
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
if (! $tenantId) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->whereNotNull('initiator_name')
->select('initiator_name')
->distinct()
->orderBy('initiator_name')
->pluck('initiator_name', 'initiator_name')
->all();
})
->searchable(),
Tables\Filters\Filter::make('created_at')
->label('Created')
->form([
DatePicker::make('created_from')
->label('From'),
DatePicker::make('created_until')
->label('Until'),
])
->default(fn (): array => [
'created_from' => now()->subDays(30),
'created_until' => now(),
])
->query(function (Builder $query, array $data): Builder {
$from = $data['created_from'] ?? null;
if ($from) {
$query->whereDate('created_at', '>=', $from);
}
$until = $data['created_until'] ?? null;
if ($until) {
$query->whereDate('created_at', '<=', $until);
}
return $query;
}),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('user')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListOperationRuns::route('/'),
'view' => Pages\ViewOperationRun::route('/{record}'),
];
}
private static function statusColor(?string $status): string
{
return match ($status) {
'queued' => 'gray',
'running' => 'info',
'completed' => 'success',
default => 'gray',
};
}
private static function outcomeColor(?string $outcome): string
{
return match ($outcome) {
'succeeded' => 'success',
'partially_succeeded' => 'warning',
'failed' => 'danger',
'cancelled' => 'gray',
default => 'gray',
};
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Resources\OperationRunResource;
use Filament\Resources\Pages\ListRecords;
class ListOperationRuns extends ListRecords
{
protected static string $resource = OperationRunResource::class;
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Str;
class ViewOperationRun extends ViewRecord
{
protected static string $resource = OperationRunResource::class;
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return [];
}
/** @var OperationRun $run */
$run = $this->getRecord();
$related = OperationRunLinks::related($run, $tenant);
$actions = [];
foreach ($related as $label => $url) {
$actions[] = Actions\Action::make(Str::slug($label, '_'))
->label($label)
->url($url)
->openUrlInNewTab();
}
if (empty($actions)) {
return [];
}
return [
Actions\ActionGroup::make($actions)
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray'),
];
}
}

View File

@ -6,12 +6,15 @@
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Jobs\BulkPolicySyncJob;
use App\Jobs\BulkPolicyUnignoreJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -371,15 +374,77 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at === null)
->visible(function (Policy $record): bool {
if ($record->ignored_at !== null) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
return $user->canSyncTenant($tenant);
})
->action(function (Policy $record) {
$tenant = Tenant::current();
$user = auth()->user();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1);
if (! $user instanceof User) {
abort(403);
}
BulkPolicySyncJob::dispatchSync($run->id);
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync_one',
inputs: [
'scope' => 'one',
'policy_id' => (int) $record->getKey(),
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
SyncPoliciesJob::dispatch(
tenantId: (int) $tenant->getKey(),
policyIds: [(int) $record->getKey()],
operationRun: $opRun
);
Notification::make()
->title('Policy sync queued')
->body('The sync has been queued. You can monitor progress in Monitoring → Operations.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
}),
Actions\Action::make('export')
->label('Export to Backup')
@ -501,6 +566,18 @@ public static function table(Table $table): Table
->color('primary')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
return true;
}
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
@ -510,26 +587,67 @@ public static function table(Table $table): Table
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count);
if (! $user instanceof User) {
abort(403);
}
if ($count >= 20) {
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
abort(403);
}
$ids = $records
->pluck('id')
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'subset',
'policy_ids' => $ids,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Bulk sync started')
->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
BulkPolicySyncJob::dispatch($run->id);
} else {
BulkPolicySyncJob::dispatchSync($run->id);
return;
}
SyncPoliciesJob::dispatch(
tenantId: (int) $tenant->getKey(),
policyIds: $ids,
operationRun: $opRun
);
Notification::make()
->title('Policy sync queued')
->body("The sync has been queued for {$count} policies. You can monitor progress in Monitoring → Operations.")
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
})
->deselectRecordsAfterCompletion(),

View File

@ -3,8 +3,11 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Services\Intune\PolicySyncService;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
@ -21,53 +24,82 @@ protected function getHeaderActions(): array
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->action(function () {
try {
$tenant = Tenant::current();
->visible(function (): bool {
$user = auth()->user();
/** @var PolicySyncService $service */
$service = app(PolicySyncService::class);
$result = $service->syncPoliciesWithReport($tenant);
$syncedCount = count($result['synced'] ?? []);
$failureCount = count($result['failures'] ?? []);
$body = $syncedCount.' policies synced';
if ($failureCount > 0) {
$first = $result['failures'][0] ?? [];
$firstType = $first['policy_type'] ?? 'unknown';
$firstStatus = $first['status'] ?? null;
$firstErrorMessage = null;
$firstErrors = $first['errors'] ?? null;
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
}
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$suffix .= ' - '.trim($firstErrorMessage);
}
$body .= " ({$failureCount} failed; {$suffix})";
}
Notification::make()
->title('Policy sync completed')
->body($body)
->success()
->sendToDatabase(auth()->user())
->send();
} catch (\Throwable $e) {
Notification::make()
->title('Policy sync failed')
->body($e->getMessage())
->danger()
->sendToDatabase(auth()->user())
->send();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
return $user->canSyncTenant($tenant);
})
->action(function () {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
abort(403);
}
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
sort($requestedTypes);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
SyncPoliciesJob::dispatch(
tenantId: (int) $tenant->getKey(),
types: $requestedTypes,
operationRun: $opRun
);
});
Notification::make()
->title('Policy sync queued')
->body('The sync has been queued. You can monitor progress in Monitoring → Operations.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
}),
];
}

View File

@ -149,7 +149,7 @@ public static function form(Schema $schema): Schema
'sourceGroupId' => $groupId,
]))
)
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
->hintAction(
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
->label('Sync Groups')
@ -399,7 +399,7 @@ public static function getWizardSteps(): array
'sourceGroupId' => $groupId,
]))
)
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
->hintAction(
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
->label('Sync Groups')

View File

@ -6,7 +6,6 @@
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
@ -17,6 +16,8 @@
use App\Services\Intune\RbacOnboardingService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\TenantRole;
use BackedEnum;
use Filament\Actions;
@ -40,6 +41,7 @@
class TenantResource extends Resource
{
// ... [Properties Omitted for Brevity] ...
protected static ?string $model = Tenant::class;
protected static bool $isScopedToTenant = false;
@ -50,6 +52,7 @@ class TenantResource extends Resource
public static function form(Schema $schema): Schema
{
// ... [Schema Omitted - No Change] ...
return $schema
->schema([
Forms\Components\TextInput::make('name')
@ -91,6 +94,7 @@ public static function form(Schema $schema): Schema
public static function getEloquentQuery(): Builder
{
// ... [Query Omitted - No Change] ...
$user = auth()->user();
if (! $user instanceof User) {
@ -194,7 +198,32 @@ public static function table(Table $table): Table
return $user->canSyncTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger): void {
SyncPoliciesJob::dispatch($record->getKey());
// Phase 3: Canonical Operation Run Start
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: ['scope' => 'full'],
initiator: auth()->user()
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
return;
}
SyncPoliciesJob::dispatch($record->getKey(), null, $opRun);
$auditLogger->log(
tenant: $record,
@ -211,6 +240,11 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->sendToDatabase(auth()->user())
->send();
}),
@ -421,7 +455,21 @@ public static function table(Table $table): Table
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
foreach ($eligible as $tenant) {
SyncPoliciesJob::dispatch($tenant->getKey());
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations.
// Bulk operation across tenants is a higher level concept.
// Keeping it as is for now or migrating individually.
// If we want each tenant sync to show in its Monitoring, we should create opRun for each.
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id],
initiator: $user
);
SyncPoliciesJob::dispatch($tenant->getKey(), null, null, $opRun);
$auditLogger->log(
tenant: $tenant,
@ -454,6 +502,7 @@ public static function table(Table $table): Table
public static function infolist(Schema $schema): Schema
{
// ... [Infolist Omitted - No Change] ...
return $schema
->schema([
Infolists\Components\TextEntry::make('name'),
@ -542,6 +591,7 @@ public static function getPages(): array
public static function rbacAction(): Actions\Action
{
// ... [RBAC Action Omitted - No Change] ...
return Actions\Action::make('setup_rbac')
->label('Setup Intune RBAC')
->icon('heroicon-o-shield-check')
@ -587,7 +637,9 @@ public static function rbacAction(): Actions\Action
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('scope') === 'scope_group')
->required(fn (Get $get) => $get('scope') === 'scope_group')
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
->noSearchResultsMessage('No security groups found')
@ -614,7 +666,9 @@ public static function rbacAction(): Actions\Action
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('group_mode') === 'existing')
->required(fn (Get $get) => $get('group_mode') === 'existing')
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
->noSearchResultsMessage('No security groups found')
@ -985,7 +1039,7 @@ public static function groupSearchHelper(?Tenant $tenant): ?string
return null;
}
return 'Uses cached directory groups only (no live Graph lookups). Run “Sync Groups” if results are empty.';
return static::delegatedToken($tenant) ? null : 'Login to search groups';
}
/**
@ -997,14 +1051,43 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra
return [];
}
return EntraGroup::query()
->where('tenant_id', $tenant->getKey())
->where('display_name', 'ilike', '%'.str_replace('%', '\\%', $search).'%')
->orderBy('display_name')
->limit(20)
->get(['entra_id', 'display_name'])
->mapWithKeys(fn (EntraGroup $group) => [
(string) $group->entra_id => EntraGroupLabelResolver::formatLabel($group->display_name, (string) $group->entra_id),
$token = static::delegatedToken($tenant);
if (! $token) {
return [];
}
$filter = sprintf(
"securityEnabled eq true and startswith(displayName,'%s')",
static::escapeOdataValue($search)
);
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups',
[
'query' => [
'$select' => 'id,displayName',
'$top' => 20,
'$filter' => $filter,
],
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
return [];
}
if ($response->failed()) {
return [];
}
return collect($response->data['value'] ?? [])
->filter(fn (array $group) => filled($group['id'] ?? null))
->mapWithKeys(fn (array $group) => [
(string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']),
])
->all();
}
@ -1015,7 +1098,32 @@ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?s
return $groupId;
}
return app(EntraGroupLabelResolver::class)->resolveOne($tenant, $groupId);
$token = static::delegatedToken($tenant);
if (! $token) {
return $groupId;
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups/'.$groupId,
[] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
return $groupId;
}
if ($response->failed()) {
return $groupId;
}
return EntraGroupLabelResolver::formatLabel(
$response->data['displayName'] ?? null,
$response->data['id'] ?? $groupId
);
}
public static function verifyTenant(

View File

@ -3,15 +3,19 @@
namespace App\Jobs;
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\FoundationSnapshotService;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\SnapshotValidator;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -28,13 +32,23 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $bulkRunId,
public int $backupSetId,
public bool $includeAssignments,
public bool $includeScopeTags,
public bool $includeFoundations,
) {}
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
BulkOperationService $bulkOperationService,
@ -111,6 +125,12 @@ public function handle(
if ($policyIds === []) {
$bulkOperationService->complete($run);
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun($this->operationRun, 'completed', 'succeeded', ['policy_ids' => 0]);
}
return;
}
@ -402,6 +422,30 @@ public function handle(
$bulkOperationService->complete($run);
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opOutcome = match (true) {
$run->status === 'completed' => 'succeeded',
$run->status === 'completed_with_errors' => 'partially_succeeded',
$run->status === 'failed' => 'failed',
default => 'failed'
};
$opService->updateRun(
$this->operationRun,
'completed',
$opOutcome,
[
'policies_added' => $backupSetItemMutations,
'foundations_added' => $foundationMutations,
'failures' => count($newBackupFailures),
],
$newBackupFailures
);
}
if (! $run->user) {
return;
}
@ -432,7 +476,7 @@ public function handle(
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
]);
if ($partial) {
@ -460,6 +504,7 @@ public function handle(
reason: $throwable->getMessage(),
);
// TrackOperationRun will catch this throw
throw $throwable;
}
}
@ -523,6 +568,18 @@ private function markRunFailed(
$run->update(['status' => 'failed']);
}
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
'failed',
['failure_reason' => $reason],
[['code' => $reasonCode, 'message' => $reason]]
);
}
$this->notifyRunFailed($run, $tenant, $reason);
}
@ -540,7 +597,7 @@ private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
]);
}

View File

@ -2,10 +2,13 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Directory\EntraGroupSyncService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -18,12 +21,22 @@ class EntraGroupSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public string $selectionKey,
public ?string $slotKey = null,
public ?int $runId = null,
) {}
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void
{
@ -35,6 +48,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
$run = $this->resolveRun($tenant);
if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) {
// Already ran?
return;
}
@ -81,6 +95,31 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
'finished_at' => CarbonImmutable::now('UTC'),
]);
// Update OperationRun with stats
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opOutcome = match ($terminalStatus) {
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
EntraGroupSyncRun::STATUS_FAILED => 'failed',
default => 'failed'
};
$opService->updateRun(
$this->operationRun,
'completed',
$opOutcome,
[
'fetched' => $result['items_observed_count'],
'upserted' => $result['items_upserted_count'],
'errors' => $result['error_count'],
],
$result['error_summary'] ? [['code' => $result['error_code'] ?? 'ERR', 'message' => json_encode($result['error_summary'])]] : []
);
}
$auditLogger->log(
tenant: $tenant,
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED

View File

@ -2,12 +2,17 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftFindingGenerator;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -21,6 +26,8 @@ class GenerateDriftFindingsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
@ -28,7 +35,15 @@ public function __construct(
public int $currentRunId,
public string $scopeKey,
public int $bulkOperationRunId,
) {}
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
/**
* Execute the job.
@ -88,6 +103,17 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
$bulkOperationService->recordSuccess($run);
$bulkOperationService->complete($run);
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
'succeeded',
['findings_created' => $created]
);
}
$this->notifyStatus($run->refresh());
} catch (Throwable $e) {
Log::error('GenerateDriftFindingsJob: failed', [
@ -107,6 +133,14 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
);
$bulkOperationService->fail($run, $e->getMessage());
// TrackOperationRun middleware might catch this, but explicit fail ensures structure
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->failRun($this->operationRun, $e);
}
$this->notifyStatus($run->refresh());
throw $e;
@ -124,55 +158,50 @@ private function notifyStatus(BulkOperationRun $run): void
return;
}
$status = 'failed';
$tenant = Tenant::query()->find((int) $run->tenant_id);
try {
$status = $run->statusBucket();
} catch (Throwable) {
$failureEntries = $run->failures ?? [];
$hasNonSkippedFailure = false;
foreach ($failureEntries as $entry) {
if (! is_array($entry)) {
continue;
}
if (($entry['type'] ?? 'failed') !== 'skipped') {
$hasNonSkippedFailure = true;
break;
}
}
$failedCount = (int) ($run->failed ?? 0);
$succeededCount = (int) ($run->succeeded ?? 0);
$hasFailures = $failedCount > 0 || $hasNonSkippedFailure;
if ($succeededCount > 0 && $hasFailures) {
$status = 'partially succeeded';
} elseif ($succeededCount === 0 && $hasFailures) {
$status = 'failed';
} else {
$status = match ($run->status) {
'pending' => 'queued',
'running' => 'running',
'completed', 'completed_with_errors' => 'succeeded',
default => 'failed',
};
}
if (! $tenant instanceof Tenant) {
return;
}
$run->user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $run->tenant_id,
'run_type' => 'bulk_operation',
'run_id' => (int) $run->getKey(),
'status' => $status,
'counts' => [
'total' => (int) $run->total_items,
'processed' => (int) $run->processed_items,
'succeeded' => (int) $run->succeeded,
'failed' => (int) $run->failed,
'skipped' => (int) $run->skipped,
],
]));
$status = $run->statusBucket();
$title = match ($status) {
'queued' => 'Drift generation queued',
'running' => 'Drift generation started',
'succeeded' => 'Drift generation completed',
'partially succeeded' => 'Drift generation completed (partial)',
default => 'Drift generation failed',
};
$body = sprintf(
'Total: %d, processed: %d, succeeded: %d, failed: %d, skipped: %d.',
(int) $run->total_items,
(int) $run->processed_items,
(int) $run->succeeded,
(int) $run->failed,
(int) $run->skipped,
);
$notification = Notification::make()
->title($title)
->body($body)
->actions([
Action::make('view_run')
->label('View run')
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : OperationRunLinks::index($tenant)),
]);
match ($status) {
'succeeded' => $notification->success(),
'partially succeeded' => $notification->warning(),
'queued', 'running' => $notification->info(),
default => $notification->danger(),
};
$notification
->sendToDatabase($run->user)
->send();
} catch (Throwable $e) {
Log::warning('GenerateDriftFindingsJob: status notification failed', [
'tenant_id' => (int) $run->tenant_id,

View File

@ -0,0 +1,61 @@
<?php
namespace App\Jobs\Middleware;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Closure;
class TrackOperationRun
{
/**
* Process the queued job.
*
* @param mixed $job
* @param callable $next
* @return mixed
*/
public function handle($job, Closure $next)
{
// Check if the job has an 'operationRun' property or method
$run = null;
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
} elseif (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
}
if (! $run instanceof OperationRun) {
return $next($job);
}
/** @var OperationRunService $service */
$service = app(OperationRunService::class);
// Mark as running
$service->updateRun($run, 'running');
try {
$response = $next($job);
// If the job was released back onto the queue (retry / delay), do not mark the run as completed.
if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) {
return $response;
}
// If the job didn't already mark it as completed/failed, we do it here.
// Re-fetch to check current status
$run->refresh();
if ($run->status === 'running') {
$service->updateRun($run, 'completed', 'succeeded');
}
return $response;
} catch (\Throwable $e) {
$service->failRun($run, $e);
throw $e;
}
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Jobs;
use App\Models\OperationRun;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PruneOldOperationRunsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $retentionDays = 90
) {}
/**
* Execute the job.
*/
public function handle(): void
{
OperationRun::where('created_at', '<', now()->subDays($this->retentionDays))
->delete();
}
}

View File

@ -0,0 +1,301 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class RemovePoliciesFromBackupSetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int,int> $backupItemIds
*/
public function __construct(
public int $backupSetId,
public array $backupItemIds,
public int $initiatorUserId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
AuditLogger $auditLogger,
BulkOperationService $bulkOperationService,
): void {
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
if (! $backupSet instanceof BackupSet) {
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
'failed',
['backup_set_id' => $this->backupSetId],
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
);
}
return;
}
$tenant = $backupSet->tenant;
$initiator = User::query()->find($this->initiatorUserId);
$requestedIds = collect($this->backupItemIds)
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
$requestedCount = count($requestedIds);
$failures = [];
try {
/** @var \Illuminate\Database\Eloquent\Collection<int, BackupItem> $items */
$items = BackupItem::query()
->where('backup_set_id', $backupSet->getKey())
->whereIn('id', $requestedIds)
->get();
$foundIds = $items->pluck('id')->map(fn (mixed $value): int => (int) $value)->all();
$missingIds = array_values(array_diff($requestedIds, $foundIds));
foreach ($missingIds as $missingId) {
$failures[] = [
'code' => 'backup_item.not_found',
'message' => $bulkOperationService->sanitizeFailureReason("Backup item {$missingId} not found (already removed?)."),
];
}
$removed = 0;
$policyIds = [];
$policyIdentifiers = [];
foreach ($items as $item) {
$item->delete();
$removed++;
if ($item->policy_id) {
$policyIds[] = (int) $item->policy_id;
}
if ($item->policy_identifier) {
$policyIdentifiers[] = (string) $item->policy_identifier;
}
}
$backupSet->update([
'item_count' => $backupSet->items()->count(),
]);
if ($tenant instanceof Tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->getKey(),
status: 'success',
context: [
'metadata' => [
'removed_count' => $removed,
'requested_count' => $requestedCount,
'missing_count' => count($missingIds),
'policy_ids' => array_values(array_unique($policyIds)),
'policy_identifiers' => array_values(array_unique($policyIdentifiers)),
'backup_set_id' => (int) $backupSet->getKey(),
'initiator_user_id' => $initiator?->getKey(),
],
],
actorId: $initiator?->getKey(),
);
}
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$outcome = 'succeeded';
if ($removed === 0) {
$outcome = 'failed';
} elseif ($failures !== []) {
$outcome = 'partially_succeeded';
}
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
[
'backup_set_id' => (int) $backupSet->getKey(),
'requested' => $requestedCount,
'removed' => $removed,
'missing' => count($missingIds),
'remaining' => (int) $backupSet->item_count,
],
$failures,
);
}
$this->notifyCompleted(
initiator: $initiator,
tenant: $tenant instanceof Tenant ? $tenant : null,
removed: $removed,
requested: $requestedCount,
missing: count($missingIds),
outcome: $outcome,
);
} catch (Throwable $throwable) {
if ($tenant instanceof Tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->getKey(),
status: 'failed',
context: [
'metadata' => [
'requested_count' => $requestedCount,
'backup_set_id' => (int) $backupSet->getKey(),
],
],
actorId: $initiator?->getKey(),
);
}
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->failRun($this->operationRun, $throwable);
}
$this->notifyFailed(
initiator: $initiator,
tenant: $tenant instanceof Tenant ? $tenant : null,
reason: $bulkOperationService->sanitizeFailureReason($throwable->getMessage()),
);
throw $throwable;
}
}
private function notifyCompleted(
?User $initiator,
?Tenant $tenant,
int $removed,
int $requested,
int $missing,
?string $outcome,
): void {
if (! $initiator instanceof User) {
return;
}
if (! $this->operationRun) {
return;
}
$message = "Removed {$removed} policies";
if ($missing > 0) {
$message .= " ({$missing} missing)";
}
if ($requested !== $removed && $missing === 0) {
$skipped = max(0, $requested - $removed);
if ($skipped > 0) {
$message .= " ({$skipped} not removed)";
}
}
$message .= '.';
$partial = in_array((string) $outcome, ['partially_succeeded'], true) || $missing > 0;
$failed = in_array((string) $outcome, ['failed'], true);
$notification = Notification::make()
->title($failed ? 'Removal failed' : ($partial ? 'Removal completed (partial)' : 'Removal completed'))
->body($message);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
}
if ($failed) {
$notification->danger();
} elseif ($partial) {
$notification->warning();
} else {
$notification->success();
}
$notification
->sendToDatabase($initiator)
->send();
}
private function notifyFailed(?User $initiator, ?Tenant $tenant, string $reason): void
{
if (! $initiator instanceof User) {
return;
}
if (! $this->operationRun) {
return;
}
$notification = Notification::make()
->title('Removal failed')
->body($reason);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
]);
}
$notification
->danger()
->sendToDatabase($initiator)
->send();
}
}

View File

@ -2,9 +2,12 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
@ -12,8 +15,10 @@
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Carbon\CarbonImmutable;
use Filament\Notifications\Events\DatabaseNotificationsSent;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -30,10 +35,20 @@ class RunBackupScheduleJob implements ShouldQueue
public int $tries = 3;
public ?OperationRun $operationRun = null;
public function __construct(
public int $backupScheduleRunId,
public ?int $bulkRunId = null,
) {}
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
PolicySyncService $policySyncService,
@ -49,9 +64,41 @@ public function handle(
->find($this->backupScheduleRunId);
if (! $run) {
if ($this->operationRun) {
$this->markOperationRunFailed(
run: $this->operationRun,
bulkOperationService: $bulkOperationService,
summaryCounts: [],
reasonCode: 'RUN_NOT_FOUND',
reason: 'Backup schedule run not found.',
);
}
return;
}
$tenant = $run->tenant;
if ($tenant instanceof Tenant) {
$this->resolveOperationRunFromContext($tenant, $run);
}
if ($this->operationRun) {
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $run->backup_schedule_id,
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
if ($this->operationRun->status === 'queued') {
$operationRunService->updateRun($this->operationRun, 'running');
}
}
$bulkRun = $this->bulkRunId
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
: null;
@ -77,11 +124,22 @@ public function handle(
'finished_at' => CarbonImmutable::now('UTC'),
]);
if ($this->operationRun) {
$this->markOperationRunFailed(
run: $this->operationRun,
bulkOperationService: $bulkOperationService,
summaryCounts: [
'backup_schedule_id' => (int) $run->backup_schedule_id,
'backup_schedule_run_id' => (int) $run->getKey(),
],
reasonCode: 'SCHEDULE_NOT_FOUND',
reason: 'Schedule not found.',
);
}
return;
}
$tenant = $run->tenant;
if (! $tenant) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
@ -90,6 +148,19 @@ public function handle(
'finished_at' => CarbonImmutable::now('UTC'),
]);
if ($this->operationRun) {
$this->markOperationRunFailed(
run: $this->operationRun,
bulkOperationService: $bulkOperationService,
summaryCounts: [
'backup_schedule_id' => (int) $run->backup_schedule_id,
'backup_schedule_run_id' => (int) $run->getKey(),
],
reasonCode: 'TENANT_NOT_FOUND',
reason: 'Tenant not found.',
);
}
return;
}
@ -107,6 +178,13 @@ public function handle(
bulkRunId: $this->bulkRunId,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
bulkOperationService: $bulkOperationService,
);
return;
}
@ -153,6 +231,13 @@ public function handle(
bulkRunId: $this->bulkRunId,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
bulkOperationService: $bulkOperationService,
);
return;
}
@ -212,6 +297,13 @@ public function handle(
bulkRunId: $this->bulkRunId,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
bulkOperationService: $bulkOperationService,
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_finished',
@ -232,6 +324,12 @@ public function handle(
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
if ($mapped['shouldRetry']) {
if ($this->operationRun) {
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
}
$this->release($mapped['delay']);
return;
@ -251,6 +349,13 @@ public function handle(
bulkRunId: $this->bulkRunId,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
bulkOperationService: $bulkOperationService,
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_failed',
@ -281,10 +386,14 @@ private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedu
$notification = Notification::make()
->title('Backup started')
->body(sprintf('Schedule "%s" has started.', $schedule->name))
->info();
->info()
->actions([
Action::make('view_run')
->label('View run')
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
]);
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
$notification->sendToDatabase($user);
}
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
@ -316,8 +425,148 @@ private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $sched
default => $notification->danger(),
};
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
$notification
->actions([
Action::make('view_run')
->label('View run')
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
])
->sendToDatabase($user);
}
private function syncOperationRunFromRun(
Tenant $tenant,
BackupSchedule $schedule,
BackupScheduleRun $run,
BulkOperationService $bulkOperationService,
): void {
if (! $this->operationRun) {
return;
}
$outcome = match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
// Note: 'cancelled' is a reserved OperationRun outcome token.
// We treat schedule SKIPPED/CANCELED as terminal failures with a failure entry.
BackupScheduleRun::STATUS_SKIPPED,
BackupScheduleRun::STATUS_CANCELED => 'failed',
default => 'failed',
};
$summary = is_array($run->summary) ? $run->summary : [];
$syncFailures = $summary['sync_failures'] ?? [];
$summaryCounts = [
'backup_schedule_id' => (int) $schedule->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
'policies_total' => (int) ($summary['policies_total'] ?? 0),
'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0),
'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0,
];
$summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null);
$failures = [];
if (filled($run->error_message) || filled($run->error_code)) {
$failures[] = [
'code' => (string) ($run->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
'message' => $bulkOperationService->sanitizeFailureReason((string) ($run->error_message ?: 'Backup schedule run failed.')),
];
}
if (is_array($syncFailures)) {
foreach ($syncFailures as $failure) {
if (! is_array($failure)) {
continue;
}
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$errors = $failure['errors'] ?? null;
$firstErrorMessage = null;
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
$firstErrorMessage = $errors[0]['message'] ?? null;
}
$message = $status !== null
? "{$policyType}: Graph returned {$status}"
: "{$policyType}: Graph request failed";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$message .= ' - '.trim($firstErrorMessage);
}
$failures[] = [
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
'message' => $bulkOperationService->sanitizeFailureReason($message),
];
}
}
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $schedule->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
}
private function markOperationRunFailed(
OperationRun $run,
BulkOperationService $bulkOperationService,
array $summaryCounts,
string $reasonCode,
string $reason,
): void {
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRunService->updateRun(
$run,
status: 'completed',
outcome: 'failed',
summaryCounts: $summaryCounts,
failures: [
[
'code' => $reasonCode,
'message' => $bulkOperationService->sanitizeFailureReason($reason),
],
],
);
}
private function resolveOperationRunFromContext(Tenant $tenant, BackupScheduleRun $run): void
{
if ($this->operationRun) {
return;
}
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->whereIn('status', ['queued', 'running'])
->where('context->backup_schedule_run_id', (int) $run->getKey())
->latest('id')
->first();
if ($operationRun instanceof OperationRun) {
$this->operationRun = $operationRun;
}
}
private function finishRun(

View File

@ -2,13 +2,17 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -21,6 +25,8 @@ class RunInventorySyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* Create a new job instance.
*/
@ -29,7 +35,20 @@ public function __construct(
public int $userId,
public int $bulkRunId,
public int $inventorySyncRunId,
) {}
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
/**
* Execute the job.
@ -60,6 +79,11 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
$processedPolicyTypes = [];
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
// It will also handle success completion if no exceptions thrown.
// However, InventorySyncService execution logic might be complex with partial failures.
// We might want to explicitly update the OperationRun if partial failures occur.
$run = $inventorySyncService->executePendingRun(
$run,
$tenant,
@ -81,9 +105,31 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
}
// --- Helper to update OperationRun with rich context ---
$updateOpRun = function (string $outcome, array $counts = [], array $failures = []) {
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
$counts,
$failures
);
}
};
// -----------------------------------------------------
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
$bulkOperationService->complete($bulkRun);
// Update Operation Run explicitly to provide counts
$updateOpRun('succeeded', [
'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_count,
]);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.completed',
@ -107,6 +153,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
->title('Inventory sync completed')
->body('Inventory sync finished successfully.')
->success()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
@ -116,6 +167,15 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
$bulkOperationService->complete($bulkRun);
$updateOpRun('partially_succeeded', [
'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_count,
'errors' => $run->errors_count,
], [
// Minimal error summary
['code' => 'PARTIAL_SYNC', 'message' => "Errors: {$run->errors_count}"],
]);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.partial',
@ -141,6 +201,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
->title('Inventory sync completed with errors')
->body('Inventory sync finished with some errors. Review the run details for error codes.')
->warning()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
@ -155,6 +220,8 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
}
$bulkOperationService->complete($bulkRun);
$updateOpRun('failed', [], [['code' => 'SKIPPED', 'message' => $reason]]);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.skipped',
@ -177,6 +244,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
->title('Inventory sync skipped')
->body('Inventory sync could not start due to locks or concurrency limits.')
->warning()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
@ -192,6 +264,8 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
$bulkOperationService->complete($bulkRun);
$updateOpRun('failed', [], [['code' => 'FAILED', 'message' => $reason]]);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.failed',
@ -215,6 +289,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
->title('Inventory sync failed')
->body('Inventory sync finished with errors.')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}

View File

@ -2,8 +2,13 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -14,24 +19,162 @@ class SyncPoliciesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, string>|null $types
* @param array<int, int>|null $policyIds
*/
public function __construct(
public readonly int $tenantId,
public readonly ?array $types = null,
) {}
public readonly ?array $policyIds = null,
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
}
public function handle(PolicySyncService $service): void
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(PolicySyncService $service, BulkOperationService $bulkOperationService): void
{
$tenant = Tenant::findOrFail($this->tenantId);
$supported = config('tenantpilot.supported_policy_types');
if ($this->policyIds !== null) {
$ids = collect($this->policyIds)
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$syncedCount = 0;
$skippedCount = 0;
$failureSummary = [];
foreach ($ids as $policyId) {
$policy = Policy::query()
->whereKey($policyId)
->where('tenant_id', $tenant->getKey())
->first();
if (! $policy) {
$failureSummary[] = [
'code' => 'policy.not_found',
'message' => $bulkOperationService->sanitizeFailureReason("Policy {$policyId} not found"),
];
continue;
}
if ($policy->ignored_at !== null) {
$skippedCount++;
continue;
}
try {
$service->syncPolicy($tenant, $policy);
$syncedCount++;
} catch (\Throwable $e) {
$failureSummary[] = [
'code' => 'policy.sync_failed',
'message' => $bulkOperationService->sanitizeFailureReason($e->getMessage()),
];
}
}
$failureCount = count($failureSummary);
$outcome = match (true) {
$failureCount === 0 => 'succeeded',
$syncedCount > 0 => 'partially_succeeded',
default => 'failed',
};
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
[
'policies_total' => $ids->count(),
'policies_synced' => $syncedCount,
'policies_skipped' => $skippedCount,
'policies_failed' => $failureCount,
],
$failureSummary
);
}
return;
}
$supported = config('tenantpilot.supported_policy_types', []);
if ($this->types !== null) {
$supported = array_values(array_filter($supported, fn ($type) => in_array($type['type'], $this->types, true)));
}
$service->syncPolicies($tenant, $supported);
$result = $service->syncPoliciesWithReport($tenant, $supported);
$syncedCount = count($result['synced'] ?? []);
$failures = $result['failures'] ?? [];
$failureCount = count($failures);
$outcome = match (true) {
$failureCount === 0 => 'succeeded',
$syncedCount > 0 => 'partially_succeeded',
default => 'failed',
};
$failureSummary = [];
foreach ($failures as $failure) {
if (! is_array($failure)) {
continue;
}
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$errors = $failure['errors'] ?? null;
$firstErrorMessage = null;
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
$firstErrorMessage = $errors[0]['message'] ?? null;
}
$message = $status !== null
? "{$policyType}: Graph returned {$status}"
: "{$policyType}: Graph request failed";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$message .= ' - '.trim($firstErrorMessage);
}
$failureSummary[] = [
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
'message' => $bulkOperationService->sanitizeFailureReason($message),
];
}
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
[
'policy_types_total' => count($supported),
'policies_synced' => $syncedCount,
'policy_types_failed' => $failureCount,
],
$failureSummary
);
}
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Listeners;
use App\Models\RestoreRun;
use App\Services\OperationRunService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SyncRestoreRunToOperation implements ShouldQueue
{
use InteractsWithQueue;
public function __construct(
protected OperationRunService $service
) {}
public function handle(object $event): void
{
// We assume we might listen to Eloquent events directly via an observer or manually dispatched events.
// For now, let's assume we bind this to "RestoreRunCreated" and "RestoreRunUpdated" if they exist,
// or we treat this class as a generic handler invoked by an Observer.
// If the event itself HAS a restore run property:
$restoreRun = null;
if (property_exists($event, 'restoreRun')) {
$restoreRun = $event->restoreRun;
} elseif ($event instanceof RestoreRun) {
$restoreRun = $event;
}
if (! $restoreRun) {
return;
}
$inputs = [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $restoreRun->backup_set_id,
];
// Ensure Run (Idempotent)
$opRun = $this->service->ensureRun(
tenant: $restoreRun->tenant,
type: 'restore.execute',
inputs: $inputs,
initiator: $restoreRun->user ?? null, // Assuming RestoreRun has user relation
initiatorName: $restoreRun->user->name ?? 'System'
);
// Map status
// RestoreRun status -> OperationRun status
$statusMap = [
'pending' => 'queued',
'queued' => 'queued',
'running' => 'running',
'completed' => 'completed',
'failed' => 'completed',
'cancelled' => 'completed',
'partially_succeeded' => 'completed',
];
$outcomeMap = [
'completed' => 'succeeded',
'failed' => 'failed',
'cancelled' => 'cancelled',
'partially_succeeded' => 'partially_succeeded',
];
$newStatus = $statusMap[$restoreRun->status] ?? 'running';
$newOutcome = $outcomeMap[$restoreRun->status] ?? 'pending';
$this->service->updateRun(
run: $opRun,
status: $newStatus,
outcome: $newOutcome,
// We could map counts/failures here if available on RestoreRun
summaryCounts: $restoreRun->summary_counts ?? [],
failures: $restoreRun->failures ?? []
);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Listeners;
use App\Models\RestoreRun;
use App\Services\OperationRunService;
use App\Support\RestoreRunStatus;
class SyncRestoreRunToOperationRun
{
public function __construct(
public OperationRunService $service
) {}
public function handle(RestoreRun $restoreRun): void
{
$status = RestoreRunStatus::fromString($restoreRun->status);
if (! $status) {
return;
}
// Adapter row becomes visible from "previewed" onward.
if (! in_array($status, [
RestoreRunStatus::Previewed,
RestoreRunStatus::Pending,
RestoreRunStatus::Queued,
RestoreRunStatus::Running,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
], true)) {
return;
}
$inputs = [
'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $restoreRun->backup_set_id,
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
];
$opRun = $this->service->ensureRun(
tenant: $restoreRun->tenant,
type: 'restore.execute',
inputs: $inputs,
initiator: null
);
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
$summaryCounts = [
'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(),
'assignments_failed' => $restoreRun->getFailedAssignmentsCount(),
'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(),
];
$this->service->updateRun(
$opRun,
status: $opStatus,
outcome: $opOutcome,
summaryCounts: $summaryCounts,
failures: $failures
);
}
/**
* @return array{0: string, 1: string, 2: array<int, array{code: string, message: string}>}
*/
protected function mapStatus(RestoreRunStatus $status): array
{
return match ($status) {
RestoreRunStatus::Previewed => ['queued', 'pending', []],
RestoreRunStatus::Pending => ['queued', 'pending', []],
RestoreRunStatus::Queued => ['queued', 'pending', []],
RestoreRunStatus::Running => ['running', 'pending', []],
RestoreRunStatus::Completed => ['completed', 'succeeded', []],
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => ['completed', 'partially_succeeded', []],
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => ['completed', 'failed', []],
RestoreRunStatus::Cancelled => ['completed', 'failed', [
['code' => 'restore.cancelled', 'message' => 'Restore run was cancelled.'],
]],
default => ['running', 'pending', []],
};
}
}

View File

@ -2,7 +2,6 @@
namespace App\Livewire;
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\AddPoliciesToBackupSetJob;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
@ -10,6 +9,8 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\RunIdempotency;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification;
@ -277,6 +278,40 @@ public function table(Table $table): Table
],
);
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'backup_set.add_policies',
inputs: [
'backup_set_id' => $backupSet->id,
'policy_ids' => $policyIds,
'options' => [
'include_assignments' => (bool) $this->include_assignments,
'include_scope_tags' => (bool) $this->include_scope_tags,
'include_foundations' => (bool) $this->include_foundations,
],
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
return;
}
// ----------------------------------------------
$existingRun = RunIdempotency::findActiveBulkOperationRun(
tenantId: (int) $tenant->getKey(),
idempotencyKey: $idempotencyKey,
@ -289,7 +324,7 @@ public function table(Table $table): Table
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
@ -331,7 +366,7 @@ public function table(Table $table): Table
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
@ -343,13 +378,18 @@ public function table(Table $table): Table
throw $exception;
}
AddPoliciesToBackupSetJob::dispatch(
bulkRunId: (int) $run->getKey(),
backupSetId: (int) $backupSet->getKey(),
includeAssignments: (bool) $this->include_assignments,
includeScopeTags: (bool) $this->include_scope_tags,
includeFoundations: (bool) $this->include_foundations,
);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->dispatchOrFail($opRun, function () use ($run, $backupSet, $opRun): void {
AddPoliciesToBackupSetJob::dispatch(
bulkRunId: (int) $run->getKey(),
backupSetId: (int) $backupSet->getKey(),
includeAssignments: (bool) $this->include_assignments,
includeScopeTags: (bool) $this->include_scope_tags,
includeFoundations: (bool) $this->include_foundations,
operationRun: $opRun
);
});
$notificationTitle = $this->include_foundations
? 'Backup items queued'
@ -361,13 +401,16 @@ public function table(Table $table): Table
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->success()
->sendToDatabase($user)
->send();
$this->resetTable();
$this->dispatch('backup-set-policy-picker:close')
->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class);
}),
]);
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Monitoring;
use App\Models\OperationRun;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class OperationsDetail extends Component implements HasForms
{
use InteractsWithForms;
public OperationRun $run;
public function mount(OperationRun $run): void
{
// Ensure tenant scope
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
$this->run = $run;
}
public function render(): View
{
return view('livewire.monitoring.operations-detail');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OperationRun extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'summary_counts' => 'array',
'failure_summary' => 'array',
'context' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->whereIn('status', ['queued', 'running']);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class OperationRunCompleted extends Notification
{
use Queueable;
public function __construct(
public OperationRun $run
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$tenant = $this->run->tenant;
$status = match ((string) $this->run->outcome) {
'succeeded' => 'success',
'partially_succeeded' => 'warning',
default => 'danger',
};
return FilamentNotification::make()
->title('Operation completed')
->body("{$this->run->type} ({$this->run->outcome})")
->status($status)
->actions([
\Filament\Actions\Action::make('view')
->label('View run')
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
])
->getDatabaseMessage();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class OperationRunQueued extends Notification
{
use Queueable;
public function __construct(
public OperationRun $run
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
$tenant = $this->run->tenant;
return FilamentNotification::make()
->title('Operation queued')
->body($this->run->type)
->info()
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
])
->getDatabaseMessage();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Observers;
use App\Listeners\SyncRestoreRunToOperationRun;
use App\Models\RestoreRun;
class RestoreRunObserver
{
/**
* Handle the RestoreRun "created" event.
*/
public function created(RestoreRun $restoreRun): void
{
$this->sync($restoreRun);
}
/**
* Handle the RestoreRun "updated" event.
*/
public function updated(RestoreRun $restoreRun): void
{
$this->sync($restoreRun);
}
protected function sync(RestoreRun $restoreRun): void
{
/** @var SyncRestoreRunToOperationRun $syncer */
$syncer = app(SyncRestoreRunToOperationRun::class);
$syncer->handle($restoreRun);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class OperationRunPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
return $user->canAccessTenant($tenant);
}
public function view(User $user, OperationRun $run): bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return (int) $run->tenant_id === (int) $tenant->getKey();
}
}

View File

@ -7,14 +7,18 @@
use App\Models\EntraGroup;
use App\Models\EntraGroupSyncRun;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Observers\RestoreRunObserver;
use App\Policies\BackupSchedulePolicy;
use App\Policies\BulkOperationRunPolicy;
use App\Policies\EntraGroupPolicy;
use App\Policies\EntraGroupSyncRunPolicy;
use App\Policies\FindingPolicy;
use App\Policies\OperationRunPolicy;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient;
@ -83,6 +87,8 @@ public function register(): void
*/
public function boot(): void
{
RestoreRun::observe(RestoreRunObserver::class);
Event::listen(TenantSet::class, function (TenantSet $event): void {
static $hasPreferencesTable;
@ -119,5 +125,6 @@ public function boot(): void
Gate::policy(Finding::class, FindingPolicy::class);
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
Gate::policy(OperationRun::class, OperationRunPolicy::class);
}
}

View File

@ -40,7 +40,9 @@ public function panel(Panel $panel): Panel
])
->renderHook(
PanelsRenderHook::BODY_END,
fn () => view('livewire.bulk-operation-progress-wrapper')->render()
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
)
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')

View File

@ -0,0 +1,282 @@
<?php
namespace App\Services;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Database\QueryException;
use InvalidArgumentException;
use Throwable;
class OperationRunService
{
public function ensureRun(
Tenant $tenant,
string $type,
array $inputs,
?User $initiator = null
): OperationRun {
$hash = $this->calculateHash($tenant->id, $type, $inputs);
// Idempotency Check (Fast Path)
// We check specific status to match the partial unique index
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('run_identity_hash', $hash)
->whereIn('status', OperationRunStatus::values())
->where('status', '!=', OperationRunStatus::Completed->value)
->first();
if ($existing) {
return $existing;
}
// Create new run (race-safe via partial unique index)
try {
return OperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => $initiator?->id,
'initiator_name' => $initiator?->name ?? 'System',
'type' => $type,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => $hash,
'context' => $inputs,
]);
} catch (QueryException $e) {
// Unique violation (active-run dedupe):
// - PostgreSQL: 23505
// - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed)
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
throw $e;
}
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('run_identity_hash', $hash)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->first();
if ($existing) {
return $existing;
}
throw $e;
}
}
public function updateRun(
OperationRun $run,
string $status,
?string $outcome = null,
array $summaryCounts = [],
array $failures = []
): OperationRun {
$previousStatus = (string) $run->status;
if (! in_array($status, OperationRunStatus::values(), true)) {
throw new InvalidArgumentException('Invalid OperationRun status: '.$status);
}
if ($outcome !== null) {
if (! in_array($outcome, OperationRunOutcome::values(), true)) {
throw new InvalidArgumentException('Invalid OperationRun outcome: '.$outcome);
}
// Reserved/future: MUST NOT be produced by feature 054.
if ($outcome === OperationRunOutcome::Cancelled->value) {
$outcome = OperationRunOutcome::Failed->value;
$failures[] = [
'code' => 'run.cancelled',
'message' => 'Run cancelled (reserved outcome mapped to failed).',
];
}
}
$updateData = [
'status' => $status,
];
if ($outcome) {
$updateData['outcome'] = $outcome;
}
if (! empty($summaryCounts)) {
$updateData['summary_counts'] = $summaryCounts;
}
if (! empty($failures)) {
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
}
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
$updateData['started_at'] = now();
}
if ($status === OperationRunStatus::Completed->value && is_null($run->completed_at)) {
$updateData['completed_at'] = now();
}
$run->update($updateData);
$run->refresh();
if ($previousStatus !== OperationRunStatus::Completed->value
&& $run->status === OperationRunStatus::Completed->value
&& $run->user instanceof User
) {
$run->user->notify(new OperationRunCompletedNotification($run));
}
return $run;
}
/**
* Dispatch a queued operation safely.
*
* If dispatch fails synchronously (misconfiguration, serialization errors, etc.),
* the OperationRun is marked terminal failed so we do not leave a misleading queued run behind.
*/
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
{
try {
$dispatcher();
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
$run->user->notify(new OperationRunQueuedNotification($run));
}
} catch (Throwable $e) {
$this->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'dispatch.failed',
'message' => $e->getMessage(),
],
],
);
throw $e;
}
}
public function failRun(OperationRun $run, Throwable $e): OperationRun
{
return $this->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'exception.unhandled',
'message' => $e->getMessage(),
],
]
);
}
protected function calculateHash(int $tenantId, string $type, array $inputs): string
{
$normalizedInputs = $this->normalizeInputs($inputs);
$json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return hash('sha256', $tenantId.'|'.$type.'|'.$json);
}
/**
* Normalize inputs for stable identity hashing.
*
* - Associative arrays: sorted by key.
* - Lists: elements normalized and then sorted by a stable JSON representation.
*/
protected function normalizeInputs(array $value): array
{
if ($this->isListArray($value)) {
$items = array_map(function ($item) {
return is_array($item) ? $this->normalizeInputs($item) : $item;
}, $value);
usort($items, function ($a, $b): int {
$aJson = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$bJson = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return strcmp((string) $aJson, (string) $bJson);
});
return array_values($items);
}
ksort($value);
foreach ($value as $key => $item) {
if (is_array($item)) {
$value[$key] = $this->normalizeInputs($item);
}
}
return $value;
}
protected function isListArray(array $array): bool
{
if ($array === []) {
return true;
}
return array_keys($array) === range(0, count($array) - 1);
}
/**
* @param array<int, array{code?: mixed, message?: mixed}> $failures
* @return array<int, array{code: string, message: string}>
*/
protected function sanitizeFailures(array $failures): array
{
$sanitized = [];
foreach ($failures as $failure) {
$code = (string) ($failure['code'] ?? 'unknown');
$message = (string) ($failure['message'] ?? '');
$sanitized[] = [
'code' => $this->sanitizeFailureCode($code),
'message' => $this->sanitizeMessage($message),
];
}
return $sanitized;
}
protected function sanitizeFailureCode(string $code): string
{
$code = strtolower(trim($code));
if ($code === '') {
return 'unknown';
}
return substr($code, 0, 80);
}
protected function sanitizeMessage(string $message): string
{
$message = trim(str_replace(["\r", "\n"], ' ', $message));
// Redact obvious bearer tokens / secrets.
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;
// Redact long opaque blobs that look token-like.
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
return substr($message, 0, 500);
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Support;
use App\Filament\Pages\DriftLanding;
use App\Filament\Pages\InventoryLanding;
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\RestoreRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
final class OperationRunLinks
{
public static function index(Tenant $tenant): string
{
return OperationRunResource::getUrl('index', tenant: $tenant);
}
public static function view(OperationRun|int $run, Tenant $tenant): string
{
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
}
/**
* @return array<string, string>
*/
public static function related(OperationRun $run, Tenant $tenant): array
{
$context = is_array($run->context) ? $run->context : [];
$links = [];
$links['Operations'] = self::index($tenant);
if ($run->type === 'inventory.sync') {
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
}
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
$policyId = $context['policy_id'] ?? null;
if (is_numeric($policyId)) {
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
}
}
if ($run->type === 'directory_groups.sync') {
$links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant);
}
if ($run->type === 'drift.generate') {
$links['Drift'] = DriftLanding::getUrl(tenant: $tenant);
}
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null;
if (is_numeric($backupSetId)) {
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
}
}
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
}
if ($run->type === 'restore.execute') {
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
$restoreRunId = $context['restore_run_id'] ?? null;
if (is_numeric($restoreRunId)) {
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
}
}
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Support;
enum OperationRunOutcome: string
{
case Pending = 'pending';
case Succeeded = 'succeeded';
case PartiallySucceeded = 'partially_succeeded';
case Failed = 'failed';
/**
* Reserved for future use. MUST NOT be produced by feature 054.
*/
case Cancelled = 'cancelled';
public static function values(bool $includeReserved = true): array
{
$cases = self::cases();
if (! $includeReserved) {
$cases = array_filter($cases, static fn (self $case): bool => $case !== self::Cancelled);
}
return array_map(static fn (self $case): string => $case->value, $cases);
}
public static function uiLabels(bool $includeReserved = false): array
{
$labels = [
self::Pending->value => 'Pending',
self::Succeeded->value => 'Succeeded',
self::PartiallySucceeded->value => 'Partially succeeded',
self::Failed->value => 'Failed',
];
if ($includeReserved) {
$labels[self::Cancelled->value] = 'Cancelled';
}
return $labels;
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Support;
enum OperationRunStatus: string
{
case Queued = 'queued';
case Running = 'running';
case Completed = 'completed';
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Support;
enum OperationRunType: string
{
case InventorySync = 'inventory.sync';
case PolicySync = 'policy.sync';
case PolicySyncOne = 'policy.sync_one';
case DirectoryGroupsSync = 'directory_groups.sync';
case DriftGenerate = 'drift.generate';
case BackupSetAddPolicies = 'backup_set.add_policies';
case BackupSetRemovePolicies = 'backup_set.remove_policies';
case BackupScheduleRunNow = 'backup_schedule.run_now';
case BackupScheduleRetry = 'backup_schedule.retry';
case RestoreExecute = 'restore.execute';
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -318,6 +318,8 @@
'bulk_operations' => [
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true),
],
'inventory_sync' => [

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OperationRun>
*/
class OperationRunFactory extends Factory
{
protected $model = OperationRun::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'initiator_name' => fake()->name(),
'type' => fake()->randomElement(OperationRunType::values()),
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => fake()->sha256(),
'summary_counts' => [],
'failure_summary' => [],
'context' => [],
'started_at' => null,
'completed_at' => null,
];
}
}

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('operation_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('initiator_name');
$table->string('type');
$table->string('status');
$table->string('outcome')->default('pending');
$table->string('run_identity_hash');
$table->jsonb('summary_counts')->default('{}');
$table->jsonb('failure_summary')->default('[]');
$table->jsonb('context')->default('{}');
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'type', 'created_at']);
$table->index(['tenant_id', 'created_at']);
});
// Partial unique index for idempotency
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique ON operation_runs (tenant_id, run_identity_hash) WHERE status IN ('queued', 'running')");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('operation_runs');
}
};

View File

@ -54,10 +54,10 @@
Drift generation has been queued. Refresh this page once it finishes.
</div>
@if ($this->getBulkRunUrl())
@if ($this->getOperationRunUrl())
<div class="text-sm">
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
View run #{{ $bulkOperationRunId }}
<a class="text-primary-600 hover:underline" href="{{ $this->getOperationRunUrl() }}">
View run #{{ $operationRunId }}
</a>
</div>
@endif
@ -72,10 +72,10 @@
</div>
@endif
@if ($this->getBulkRunUrl())
@if ($this->getOperationRunUrl())
<div class="text-sm">
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
View run #{{ $bulkOperationRunId }}
<a class="text-primary-600 hover:underline" href="{{ $this->getOperationRunUrl() }}">
View run #{{ $operationRunId }}
</a>
</div>
@endif

View File

@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -1,4 +1,7 @@
<div wire:poll.{{ $pollSeconds }}s="loadRuns">
@php($runs = $runs ?? collect())
@php($interval = $runs->isEmpty() ? max((int) $pollSeconds, 10) : (int) $pollSeconds)
<div wire:poll.{{ $interval }}s="loadRuns">
<!-- Bulk Operation Progress Component: {{ $runs->count() }} active runs -->
@if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">

View File

@ -0,0 +1,60 @@
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Summary</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<dt class="text-gray-600">Type:</dt>
<dd>{{ $run->type }}</dd>
<dt class="text-gray-600">Status:</dt>
<dd>{{ $run->status }}</dd>
<dt class="text-gray-600">Outcome:</dt>
<dd>{{ $run->outcome }}</dd>
<dt class="text-gray-600">Initiator:</dt>
<dd>{{ $run->initiator_name }}</dd>
</dl>
</div>
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Timing</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<dt class="text-gray-600">Created:</dt>
<dd>{{ $run->created_at }}</dd>
<dt class="text-gray-600">Started:</dt>
<dd>{{ $run->started_at ?? '-' }}</dd>
<dt class="text-gray-600">Completed:</dt>
<dd>{{ $run->completed_at ?? '-' }}</dd>
</dl>
</div>
</div>
@if(!empty($run->summary_counts))
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Counts</h3>
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
@if(!empty($run->failure_summary))
<div class="bg-white p-4 rounded shadow border-l-4 border-red-500">
<h3 class="font-bold text-lg mb-2 text-red-700">Failures</h3>
<div class="space-y-2">
@foreach($run->failure_summary as $failure)
<div class="bg-red-50 p-2 rounded">
<div class="font-mono text-xs text-red-800">{{ $failure['code'] ?? 'UNKNOWN' }}</div>
<div class="text-sm text-red-900">{{ $failure['message'] ?? 'Unknown error' }}</div>
</div>
@endforeach
</div>
</div>
@endif
<div class="bg-white p-4 rounded shadow">
<h3 class="font-bold text-lg mb-2">Context</h3>
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->context, JSON_PRETTY_PRINT) }}</pre>
</div>
</div>

View File

@ -1,5 +1,6 @@
<?php
use App\Jobs\PruneOldOperationRunsJob;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@ -10,3 +11,8 @@
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute();
Schedule::job(new PruneOldOperationRunsJob)
->daily()
->name(PruneOldOperationRunsJob::class)
->withoutOverlapping();

View File

@ -54,7 +54,7 @@ ### Decision: Default selection = “full inventory”
### Decision: Attribute initiator on run record and audit trail
- **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record.
- **Rationale**: Improves traceability and aligns with constitution principle “Automation must be Idempotent & Observable”.
- **Rationale**: Improves traceability and aligns with constitution principle “Operations / Run Observability Standard”.
- **Alternatives considered**:
- Audit log only — rejected (you chose C).

View File

@ -42,7 +42,7 @@ ### 3) Idempotency & de-duplication
- Behavior: if an identical run is `queued`/`running`, reuse it and return/link to it; allow a new run only after terminal.
**Rationale:**
- Matches the constitution (“Automation must be Idempotent & Observable”) and aligns with existing patterns (inventory selection hash + schedule locks).
- Matches the constitution (“Operations / Run Observability Standard”) and aligns with existing patterns (inventory selection hash + schedule locks).
**Alternatives considered:**
- **Cache-only locks** (`Cache::lock(...)`) without persisted keys.
@ -75,4 +75,3 @@ ## Clarifications resolved
- **SC-003 includes “canceled”** while Phase 1 explicitly has “no cancel”.
- Resolution for Phase 1 planning: treat “canceled” as out-of-scope (Phase 2+) and map “aborted” (if present) into the `failed` bucket for SC accounting.

View File

@ -80,7 +80,7 @@ ### 6) Idempotency & de-duplication
- Race reduction: rely on the existing partial unique index for active runs and handle collisions by finding and reusing the existing run.
**Rationale:**
- Aligns with the constitution (“Automation must be Idempotent & Observable”).
- Aligns with the constitution (“Operations / Run Observability Standard”).
- Durable across restarts and observable in the database.
**Alternatives considered:**

View File

@ -6,6 +6,8 @@ ## Phase 1 Adoption Set
- [x] `directory_groups.sync` (Directory → Groups “Sync groups”) covered in spec
- [x] `drift.generate` (Drift “Generate drift now”) covered in spec
- [x] `backup_set.add_policies` (Backup Sets “Add selected”) covered in spec
- [x] `backup_schedule.run_now` (Backup Schedules “Run now”) covered in implementation
- [x] `backup_schedule.retry` (Backup Schedules “Retry”) covered in implementation
- [x] `restore.execute` (adapter mode) covered in spec
## Critical Clarifications (Pinned)

View File

@ -0,0 +1,61 @@
# Spec Review Checklist: Unified Operations Runs Suitewide (054)
**Purpose**: Validate that `spec.md` is PR-reviewable and implementable by checking requirement quality (clarity, completeness, consistency, and testability), with emphasis on Audit-only vs OperationRun boundaries and tenant isolation/privacy/sanitization.
**Created**: 2026-01-17
**Feature**: `specs/054-unify-runs-suitewide/spec.md`
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
## Requirement Completeness
- [x] CHK001 Are Phase 1 adoption-set operations explicitly enumerated and scoped? [Completeness] Evidence: `spec.md` §Scope & Assumptions (“Phase 1 adoption set”)
- [x] CHK002 Are required Phase 1 run types explicitly listed and stable (including restore adapter and backup schedules)? [Completeness] Evidence: `spec.md` §FR-003
- [x] CHK003 Are mandatory run record fields specified (initiator, type, status/timestamps, outcome, counts, failures, identity, context)? [Completeness] Evidence: `spec.md` §FR-001
- [x] CHK004 Are lifecycle states and outcome buckets defined with allowed values? [Completeness] Evidence: `spec.md` §FR-011FR-012
- [x] CHK005 Are idempotency identity rules specified for each Phase 1 run type (effective inputs included/excluded)? [Completeness] Evidence: `spec.md` §FR-010
- [x] CHK006 Are role/permission requirements defined for viewing runs vs starting operations? [Completeness] Evidence: `spec.md` §FR-018 + §User Story 1 (Scenario 5) + §User Story 2 (Scenario 2)
- [x] CHK007 Are notification requirements defined for queued and terminal outcomes (recipient, summary, “View run” link)? [Completeness] Evidence: `spec.md` §FR-015 + §User Story 2 (Scenario 3)
## Audit-only vs OperationRun Boundaries
- [x] CHK008 Is the eligibility criteria for Audit-only actions fully specified (DB-only, ≤2s, no remote calls, no background work)? [Clarity] Evidence: `spec.md` §FR-019
- [x] CHK009 Is it unambiguous when an action may remain Audit-only versus must be an OperationRun (e.g., operational relevance vs queued/remote)? [Clarity] Evidence: `spec.md` §FR-019 + §Rule (Run vs Audit-only)
- [x] CHK010 Are “security-relevant” / “operational behavior change” triggers for mandatory AuditLog entries defined beyond examples (so classification is reviewable)? [Clarity] Evidence: `spec.md` §FR-019 (“Trigger guidance”)
- [x] CHK011 Are required AuditLog fields complete and unambiguous (tenant, actor, stable action ID, target, before/after or diff, timestamp)? [Completeness] Evidence: `spec.md` §FR-019
- [x] CHK012 Does the spec define sanitization expectations for `before/after/diff` in AuditLog (what must be excluded) rather than assuming it? [Privacy] Evidence: `spec.md` §FR-019 (“Sanitization (AuditLog before/after/diff)”)
- [x] CHK013 Does the adoption matrix cover the required feature areas and assign a stable `run_type` or audit action id for each? [Completeness] Evidence: `spec.md` §Run vs Audit-only Adoption Matrix (Phase 1)
- [x] CHK014 Are the adoption matrix audit action identifiers consistent with the “stable action identifier” requirement for AuditLog entries? [Consistency] Evidence: `spec.md` §FR-019 + §Run vs Audit-only Adoption Matrix
- [x] CHK015 Are FR-019 acceptance checks sufficient and non-contradictory (no OperationRun; exactly one AuditLog; tenant-scoped; cross-tenant forbidden)? [Acceptance Criteria] Evidence: `spec.md` §FR-019 (Acceptance checks)
## Tenant Isolation & Privacy / Sanitization
- [x] CHK016 Are tenant isolation requirements explicitly stated for run list access and run detail access? [Completeness] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenarios 1, 6)
- [x] CHK017 Is “cross-tenant access is denied without disclosing run details” sufficiently specified to be reviewable (what must not be exposed)? [Clarity] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenario 6)
- [x] CHK018 Are start-surface authorization requirements explicit enough to prevent unauthorized run creation (especially Readonly)? [Completeness] Evidence: `spec.md` §FR-007 + §FR-018 + §User Story 2 (Scenario 2)
- [x] CHK019 Are persisted failure requirements explicit about what MUST NOT be stored (tokens/credentials/PII/raw payload dumps)? [Clarity] Evidence: `spec.md` §FR-013 + §SC-004
- [x] CHK020 Are “stable reason codes” and “short sanitized messages” defined enough to be objectively reviewable (format expectations or examples)? [Clarity] Evidence: `spec.md` §FR-013 (Reason codes + Messages)
- [x] CHK021 Does the spec define what may be stored in run `context` and require it to be safe/sanitized (no secrets/PII)? [Privacy] Evidence: `spec.md` §FR-001 (“Context safety”)
- [x] CHK022 Are Monitoring render-time constraints explicit (DB-only; no external calls; no remote fetches at render time)? [Completeness] Evidence: `spec.md` §FR-017 + §FR-008
## Requirement Consistency
- [x] CHK023 Are the terms “status” and “outcome bucket” used consistently (and are queued/running treated consistently across the doc)? [Consistency] Evidence: `spec.md` §FR-001 (Status/Outcome semantics) + §FR-011 (Run state presentation)
- [x] CHK024 Are run type naming rules consistent across taxonomy, Phase 1 run list, and the adoption matrix (spelling/casing)? [Consistency] Evidence: `spec.md` §FR-002 + §FR-003 + §Run vs Audit-only Adoption Matrix
- [x] CHK025 Is restore adapter behavior described consistently across Clarifications, Scope (“Restore visibility”), FR-003, and Key Entities? [Consistency] Evidence: `spec.md` §Clarifications (restore) + §Scope & Assumptions (“Restore visibility”) + §FR-003 + §Key Entities
- [x] CHK026 Are retention and default monitoring window expectations consistent (retention vs default list time range)? [Consistency] Evidence: `spec.md` §Clarifications (retention) + §Assumptions + §FR-004
## Acceptance Criteria Quality
- [x] CHK027 Are success criteria measurable and objectively verifiable without implementation details? [Measurability] Evidence: `spec.md` §SC-001SC-004
- [x] CHK028 Is the ≥99% dedupe target defined with a measurement scope (what counts as an attempt; “normal conditions” definition)? [Clarity] Evidence: `spec.md` §SC-003 (Measurement scope) + §FR-009
- [x] CHK029 Is “no secrets/PII” defined with an explicit boundary sufficient for reviewers to validate completeness? [Clarity] Evidence: `spec.md` §SC-004 + §FR-013
## Scenario Coverage
- [x] CHK030 Are primary, forbidden, and “background unavailable” scenarios covered with explicit, testable outcomes (including “must not claim queued”)? [Coverage] Evidence: `spec.md` §User Stories 13 + §User Story 2 (Scenario 4) + §Edge Cases
## Notes
- Check items off as completed: `[x]`
- Add findings inline (e.g., under a checklist item) with links to the relevant spec section
- This checklist evaluates requirements quality, not implementation correctness

View File

@ -12,7 +12,7 @@ servers:
- url: /
paths:
/admin/t/{tenantExternalId}/bulk-operation-runs:
/admin/t/{tenantExternalId}/operations:
get:
operationId: monitoringOperationsIndex
summary: Monitoring → Operations (tenant-scoped)
@ -28,7 +28,7 @@ paths:
'302':
description: Redirect to login when unauthenticated.
/admin/t/{tenantExternalId}/bulk-operation-runs/{bulkOperationRunId}:
/admin/t/{tenantExternalId}/operations/{operationRunId}:
get:
operationId: monitoringOperationsView
summary: Operation run detail (tenant-scoped)
@ -38,7 +38,7 @@ paths:
required: true
schema:
type: string
- name: bulkOperationRunId
- name: operationRunId
in: path
required: true
schema:
@ -52,4 +52,3 @@ paths:
description: Forbidden when attempting cross-tenant access.
components: {}

View File

@ -3,16 +3,12 @@ # Routes & URLs
## Monitoring UI
### List Operations
- **Route**: `tenant.monitoring.operations.index`
- **URL**: `/tenants/{tenant}/monitoring/operations`
- **Controller**: Livewire Component (`App\Livewire\Monitoring\OperationsList`)
- **URL**: `/admin/t/{tenantExternalId}/operations`
- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (index)
### View Operation
- **Route**: `tenant.monitoring.operations.show`
- **URL**: `/tenants/{tenant}/monitoring/operations/{run}`
- **Controller**: Livewire Component (`App\Livewire\Monitoring\OperationsDetail`)
- **URL**: `/admin/t/{tenantExternalId}/operations/{operationRunId}`
- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (view)
## Deep Links
- **Drift**: `/tenants/{tenant}/drift/history/{id}`
- **Inventory**: `/tenants/{tenant}/inventory` (General, or specific timestamp if supported)
- **Restore**: `/tenants/{tenant}/restore/{id}`
- Use Filament URL helpers (`Resource::getUrl(...)`, `Page::getUrl(...)`) to generate tenant-scoped links back to owning feature surfaces/results.

View File

@ -20,6 +20,10 @@ ### `ensureRun`
3. If found, return it.
4. If not found, create new `queued` run.
5. Return run.
6. If an existing active run is returned (dedupe), the initiator (`user_id`, `initiator_name`) MUST NOT be replaced.
- **Dispatch failure**:
- If queue dispatch fails after a run was created, the system MUST NOT leave misleading queued runs; instead complete the run immediately as `failed` (e.g., failure code `queue.dispatch_failed`) and show a clear UI message.
### `updateRun`
Updates the status/outcome of a run.
@ -44,5 +48,6 @@ ### `failRun`
## `App\Jobs\Middleware\TrackOperationRun`
Middleware for Jobs to automatically handle `running` -> `completed`/`failed` transitions if bound to a run.
## `App\Listeners\SyncRestoreRunToOperation`
Listener for `RestoreRun` events to update the shadow `OperationRun`.
## `App\Listeners\SyncRestoreRunToOperationRun`
Listener for `RestoreRun` events to update the shadow `OperationRun`.
The adapter row is created/visible only once a restore run reaches `previewed` (or later).

View File

@ -16,7 +16,7 @@ ### `OperationRun`
| `outcome` | String | Yes | Result bucket: `pending`, `succeeded`, `partially_succeeded`, `failed`, `cancelled`. |
| `run_identity_hash` | String | Yes | Deterministic hash for idempotency. |
| `summary_counts` | JSONB | No | `{ "total": 10, "success": 8, "failed": 2, "skipped": 0 }` |
| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "GraphError", "message": "Throttled", "count": 1 }]` |
| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "graph.throttled", "message": "Throttled (retrying)", "count": 1 }]` |
| `context` | JSONB | No | Run-specific metadata. e.g., `{ "restore_run_id": 123, "selection": [...] }` |
| `started_at` | Timestamp | No | When execution began. |
| `completed_at` | Timestamp | No | When execution finished. |
@ -31,7 +31,9 @@ ### `OperationRun`
### `RestoreRun` (Existing)
Remains the domain source of truth for Restore.
- Linked via `OperationRun.context['restore_run_id']`.
- `OperationRun` mirrors `RestoreRun` status/outcome.
- Adapter row is created/visible only once `RestoreRunStatus=previewed` (or later).
- When `RestoreRunStatus=previewed`, the adapter uses `OperationRun.status=queued` and `OperationRun.outcome=pending`.
- `OperationRun` mirrors the restore execution lifecycle for Monitoring visibility (restore domain history remains owned by `RestoreRun`).
## Enums
@ -45,7 +47,26 @@ ### `OperationRunOutcome`
- `succeeded`
- `partially_succeeded`
- `failed`
- `cancelled`
- `cancelled` (reserved/future; MUST NOT be produced by 054)
**UI label mapping** (display-only):
- `pending` → “Pending”
- `succeeded` → “Succeeded”
- `partially_succeeded` → “Partially succeeded”
- `failed` → “Failed”
- `cancelled` → “Cancelled” (reserved)
## State Transitions
`OperationRun.status` transitions:
- `queued``running``completed`
`OperationRun.outcome` transitions:
- `pending` while `status` is `queued` or `running`
- one of `succeeded`, `partially_succeeded`, `failed`, `cancelled` when `status` is `completed`
## Relationships
- `OperationRun` belongs to `Tenant`.

View File

@ -1,76 +1,98 @@
# Implementation Plan: Unified Operations Runs Suitewide
# Implementation Plan: Unified Operations Runs Suitewide (054)
**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-16 | **Spec**: [Spec Link](spec.md)
**Input**: Feature specification from `specs/054-unify-runs-suitewide/spec.md`
**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-17 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md` ([spec.md](spec.md))
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md`
**Note**: This plan is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
This feature unifies long-running tenant operations (e.g., Inventory Sync, Drift Generation) into a single canonical `operation_runs` table. This enables a consistent "Monitoring -> Operations" view for all tenant activities. Legacy run tables will be maintained in parallel for now (Parallel Write Transition). `RestoreRun` remains a domain-specific record but will be mirrored into `operation_runs` via an adapter pattern.
Eliminate “run sprawl” by adopting a single tenant-scoped canonical run record (`operation_runs`) for long-running and
operationally relevant actions across the product, surfaced consistently in Monitoring → Operations (list + detail).
Key behaviors:
- Start surfaces are enqueue-only: authorize → create/reuse canonical run (dedupe) → dispatch → confirm + “View run”.
- Legacy per-module run tables remain in parallel where they exist; Monitoring/Operations uses canonical runs.
- Restore remains a domain workflow record, but is mirrored into canonical runs via an adapter row (`restore.execute`)
created from `RestoreRunStatus=previewed` onward (`status=queued`, `outcome=pending` until execution begins).
## Technical Context
**Language/Version**: PHP 8.4
**Primary Dependencies**: Filament v4, Laravel v12, Livewire v3
**Storage**: PostgreSQL (`operation_runs` table + JSONB)
**Testing**: Pest v4 (Feature tests for Service, Livewire tests for UI)
**Target Platform**: Linux server (Docker/Dokploy)
**Project Type**: Web Application (Laravel Monolith)
**Performance Goals**: Start operation < 2s. List runs < 200ms.
**Constraints**: Tenant isolation is paramount. No cross-tenant data leakage.
**Scale/Scope**: ~50-100 runs/day per tenant. Retention 90 days.
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v4, Livewire v3
**Storage**: PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe)
**Testing**: Pest v4 (PHPUnit v12)
**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy)
**Project Type**: web
**Performance Goals**: Start surfaces confirm within 2 seconds and provide a “View run” link; Monitoring/Operations list is usable with default last-30-days window and filters.
**Constraints**: Tenant isolation is non-negotiable; Monitoring is DB-only at render time; no remote work inline; failures are stable codes + sanitized messages (no secrets/tokens/raw payload dumps).
**Scale/Scope**: Tenant-scoped run history across modules; retention defaults to 90 days.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- [x] Inventory-first: N/A (this is about tracking operations, not inventory state itself)
- [x] Read/write separation: Monitoring is read-only. Starts are explicit writes.
- [x] Graph contract path: N/A (this feature tracks runs, doesn't call Graph directly)
- [x] Deterministic capabilities: N/A
- [x] Tenant isolation: `operation_runs` has `tenant_id`. Policies ensure scope.
- [x] Automation: Idempotency enforced via DB index.
- [x] Data minimization: No secrets in `failure_summary`.
- Inventory-first: PASS (Monitoring uses persisted run records; does not redefine Inventory semantics).
- Read/write separation: PASS (Monitoring/Operations is view-only; writes remain in their owning UIs with explicit confirmation/audit where applicable).
- Graph contract path: PASS (Monitoring makes no Graph calls; start surfaces MUST NOT perform remote work inline).
- Deterministic capabilities: N/A (no new capability resolver introduced in this feature).
- Tenant isolation: PASS (all run access is tenant-scoped; cross-tenant access is forbidden).
- Run observability: PASS (queued/remote/scheduled work is tracked as canonical runs and links to a single Monitoring hub).
- Automation: PASS (active-run de-duplication via deterministic identity + partial unique index).
- Data minimization: PASS (failure summaries are sanitized and stable; no secrets/tokens/raw payload dumps in persisted failures/notifications).
**Gate status (pre-Phase 0)**: PASS (no violations).
**Gate status (post-Phase 1)**: PASS (design artifacts present: `research.md`, `data-model.md`, `contracts/`, `quickstart.md`).
## Project Structure
### Documentation (this feature)
```text
specs/054-unify-runs-suitewide/
├── plan.md # This file
├── research.md # Research findings
├── data-model.md # Database schema
├── quickstart.md # Dev guide
├── contracts/ # Service interfaces & routes
└── tasks.md # Task breakdown
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/
├── plan.md # This file (/speckit.plan command output)
├── spec.md # Feature specification (input)
├── checklists/
│ └── requirements.md # Spec quality checklist
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
├── Models/
│ └── OperationRun.php
├── Services/
│ └── OperationRunService.php
├── Livewire/
│ └── Monitoring/
│ ├── OperationsList.php
│ └── OperationsDetail.php
├── Filament/
│ ├── Pages/
│ └── Resources/
├── Jobs/
│ └── Middleware/
│ └── TrackOperationRun.php
└── Listeners/
└── SyncRestoreRunToOperation.php
├── Listeners/
├── Models/
├── Notifications/
├── Observers/
├── Policies/
├── Services/
└── Support/
database/migrations/
└── YYYY_MM_DD_create_operation_runs_table.php
database/
└── migrations/
routes/
└── console.php
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Standard Laravel Service/Model/Livewire pattern.
**Structure Decision**: Laravel web application. Implement canonical runs via Eloquent (`OperationRun`) + a small service layer for idempotent creation and lifecycle updates, instrument background jobs via middleware, and surface runs in Filament Monitoring/Operations (tenant-scoped, view-only).
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | | |
| None | | |

View File

@ -1,7 +1,7 @@
# Quickstart: Adding a New Operation
## 1. Register Run Type
Add your new type constant to `App\Enums\OperationRunType` (if using Enums) or just use the string convention `resource.action`.
Add your new type constant to `App\Support\OperationRunType` (if using Enums) or just use the string convention `resource.action`.
## 2. Implement Idempotency Inputs
Define what makes a run "unique" for your feature.
@ -16,34 +16,36 @@ ## 3. Use `OperationRunService`
// 2. Dispatch Job (if new)
if ($run->wasRecentlyCreated) {
MyJob::dispatch($run, $inputs);
$service->dispatchOrFail($run, function () use ($run, $inputs): void {
MyJob::dispatch($run, $inputs);
});
}
// 3. Return View Link
return redirect()->route('tenant.monitoring.operations.show', [$tenant, $run]);
return redirect(\App\Support\OperationRunLinks::viewUrl($tenant, $run));
```
## 4. Instrument Job
In your Job:
```php
public function handle()
public function handle(\App\Services\OperationRunService $service)
{
// Update to Running
$this->run->updateStatus(status: 'running');
try {
// ... do work ...
// Success
$this->run->updateStatus(
status: 'completed',
outcome: 'succeeded',
summary: ['processed' => 100]
);
$service->updateRun($this->run, status: 'completed', outcome: 'succeeded', summaryCounts: [
'processed' => 100,
]);
} catch (\Throwable $e) {
// Failure
$this->run->fail($e);
$service->failRun($this->run, $e);
}
}
```
## Notes: Monitoring & Run-Standard (Graph safety)
- **RBAC Wizard** (`TenantResource`): group search is **delegated-Graph-based** and the picker is **disabled without a delegated token**.
- **Restore Wizard** (`RestoreRunResource`): group mapping stays **DB-only** (Directory Cache / `entra_groups`) during the mapping phase — **no Graph calls** there; fallback helper text is always visible.

View File

@ -4,9 +4,11 @@ ## 1. Technical Context & Unknowns
**Unknowns Resolved**:
- **Transition Strategy**: Parallel write. We will maintain existing legacy tables (e.g., `inventory_sync_runs`, `restore_runs`) for now but strictly use `operation_runs` for the Monitoring UI.
- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` record will be created as a "shadow" or "adapter" record. This requires hooking into `RestoreRun` lifecycle events or the service layer to keep them in sync.
- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` adapter row will be created once a restore run reaches `previewed` (and later statuses), and will be kept in sync via `RestoreRun` lifecycle events or service-layer wrapping.
- **Run Logic Location**: Existing jobs like `RunInventorySyncJob` will be updated to manage the `OperationRun` state.
- **Concurrency**: Enforced by partial unique index on `(tenant_id, run_identity_hash)` where status is active (`queued`, `running`).
- **Dispatch Failure Semantics**: If queue dispatch fails, the system will immediately complete the run as `failed` (e.g., `queue.dispatch_failed`) and show a clear UI message (never leaving misleading queued runs).
- **Notifications on Dedupe**: Only the original initiator (`operation_runs.user_id`) receives queued/terminal notifications; reusers of an active run do not get additional notifications.
## 2. Technology Choices
@ -22,16 +24,24 @@ ## 3. Implementation Patterns
### Canonical Run Lifecycle
1. **Start Request**:
- Compute `run_identity_hash` from inputs.
- Attempt `INSERT` into `operation_runs` (ignore conflict if active).
- If active run exists, return it (Idempotency).
- If new, dispatch Job.
- Attempt `INSERT` into `operation_runs` (idempotent; enforced by partial unique index for active runs).
- If an active run exists, return it (Idempotency).
- If new, dispatch the background Job.
- If dispatch fails, immediately mark the run `status=completed`, `outcome=failed` with a safe failure code such as `queue.dispatch_failed`.
2. **Job Execution**:
- Update status to `running`.
- Perform work.
- Update status to `succeeded`/`failed`.
- Update status to `completed` and set terminal outcome (`succeeded` / `partially_succeeded` / `failed` / `cancelled`).
3. **Restore Adapter**:
- When `RestoreRun` is created, create `OperationRun` (queued/running).
- When `RestoreRun` updates (status change), update `OperationRun`.
- Create the adapter row only once `RestoreRunStatus=previewed` (or later) is reached.
- Map `RestoreRunStatus=previewed` to `OperationRun.status=queued` and `OperationRun.outcome=pending`.
- Keep the adapter updated as the restore progresses:
- `queued``status=queued`, `outcome=pending`
- `running``status=running`, `outcome=pending`
- `completed``status=completed`, `outcome=succeeded`
- `partial``status=completed`, `outcome=partially_succeeded`
- `failed``status=completed`, `outcome=failed`
- `cancelled``status=completed`, `outcome=cancelled`
### Data Model
```sql
@ -63,3 +73,5 @@ ## 4. Risks & Mitigations
- **Mitigation**: Use model observers or service-layer wrapping to ensure atomic-like updates, or accept slight eventual consistency (Monitoring might lag ms behind Restore UI).
- **Risk**: Legacy runs not appearing.
- **Mitigation**: We are NOT backfilling legacy runs. Only new runs after deployment will appear in the new Monitoring UI. This is acceptable for "Phase 1".
- **Risk**: Confusion about `queued` for restore `previewed`.
- **Mitigation**: Document that `restore.execute` appears from `previewed` onward and uses `queued/pending` until execution begins; Monitoring remains view-only and links to the restore domain detail.

View File

@ -12,9 +12,19 @@ ### Session 2026-01-16
- Q: Welche Default-Retention soll 054 für canonical Operation Runs festlegen? → A: 90 days
- Q: Transition-Strategie in 054: schreiben wir canonical Runs parallel zu Legacy-Run-Tabellen, oder ersetzen wir sofort? → A: Parallel write (canonical + legacy)
- Q: For `restore.execute`, the spec mentions it acts as an "adapter entry" linking to the restore domain record. How should this be implemented? → A: Physical Row (Create a physical row in `operation_runs` that points to the restore record).
- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where outcome is `queued` or `running`).
- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where status is `queued` or `running`).
- Q: How should the `initiator` be modeled to support both users and system processes (FR-001)? → A: Nullable FK + Name Snapshot (`user_id` nullable FK + required `initiator_name` string).
### Session 2026-01-17
- Q: Sollen `backup_schedule.run_now` und `backup_schedule.retry` in 054 zur Phase-1-Adoption (must be implemented) gehören? → A: Yes — both are Phase 1 in 054 (OperationRun producers + worker tracking).
- Q: Wenn Queue-Dispatch fehlschlägt (Background Processing unavailable), sollen wir trotzdem einen `OperationRun` anlegen und ihn sofort als fehlgeschlagen abschließen? → A: Yes — create an `OperationRun` and immediately complete it as `failed` (e.g., failure code `queue.dispatch_failed`); show a clear error and MAY include a “View run” link.
- Q: Wenn ein Start deduped wird (Run wird wiederverwendet), wer soll die InApp Notifications (“queued” + terminal outcome) bekommen? → A: Only the original initiator (`operation_runs.user_id`); no additional notifications are sent to the second starter on reuse.
- Q: Für `restore.execute`: In welchen `RestoreRunStatus`-Phasen soll überhaupt ein `OperationRun`-AdapterRow erzeugt/angezeigt werden? → A: From `previewed` onwards (previewed + execution statuses); no adapter row for `draft`/`scoped`/`checked`.
- Q: Wenn der `restore.execute` Adapter bereits ab `RestoreRunStatus=previewed` sichtbar ist: welchen `OperationRun`-State sollen wir für diese Phase setzen? → A: `status=queued`, `outcome=pending` (until `running`, then `completed` + terminal outcome).
- Q: RBAC Wizard (`TenantResource`) wie funktioniert Group Search? → A: Group search is delegated-Graph-based and the picker MUST be disabled without delegated auth.
- Q: Restore Wizard (`RestoreRunResource`) Group Mapping Phase: Graph oder DB-only? → A: DB-only via Directory Cache (`entra_groups`), no Graph calls during mapping; helper text is always shown (fallback included).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
@ -27,10 +37,10 @@ ### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
**Acceptance Scenarios**:
1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, outcome bucket, time range, and initiator.
1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, run state (queued/running/terminal outcome), time range, and initiator.
2. **Given** multiple run types exist, **When** I filter to `inventory.sync`, **Then** only inventory sync runs are shown.
3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, outcome bucket, timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results.
4. **Given** restore execution exists, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record).
3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, run state (queued/running/terminal outcome), timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results.
4. **Given** a restore run has reached `previewed` or later, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record).
5. **Given** I am a `Readonly` user in tenant A, **When** I view Monitoring → Operations, **Then** I can view runs and details but I do not see any start/rerun/cancel/delete controls.
6. **Given** I attempt to access a run from another tenant (direct link or list), **When** I request it, **Then** access is denied and no run details are disclosed.
@ -50,6 +60,7 @@ ### User Story 2 - Start Operations Without Blocking (Priority: P2)
2. **Given** I am a `Readonly` user in tenant A, **When** I attempt to start any Phase 1 operation, **Then** the system denies the request and does not create a new run.
3. **Given** the run reaches a terminal outcome, **When** that occurs, **Then** the initiating user receives an in-app notification including a short summary and a “View run” link.
4. **Given** background processing is unavailable, **When** I attempt to start an operation, **Then** I receive a clear message and the system MUST NOT claim it was queued.
- If an `OperationRun` record was created during the attempt, it MUST be completed immediately with outcome `failed` (never left `queued`) and MAY be linked via “View run”.
---
@ -68,7 +79,7 @@ ### User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3)
### Edge Cases
- Background execution unavailable: start fails fast with a clear message; the system MUST NOT create misleading “queued” runs.
- Background execution unavailable: start fails fast with a clear message; if an `OperationRun` record was created, it MUST be immediately completed as `failed` (e.g., `queue.dispatch_failed`) and MUST NOT be left `queued`.
- Partial processing: at least one success and at least one failure yields “partially succeeded”, with per-item failures when applicable.
- Large run history: Monitoring remains usable with filters and defaults (recent runs, last 30 days).
- Permissions revoked mid-run: the run continues; visibility is evaluated at time of access.
@ -87,10 +98,14 @@ ### Scope & Assumptions
- `directory_groups.sync` (Directory → Groups “Sync groups”)
- `drift.generate` (Drift “Generate drift now” / auto-on-open when eligible)
- `backup_set.add_policies` (Backup Sets “Add selected” / “Add policies”)
- `backup_schedule.run_now` (Backup Schedules “Run now”)
- `backup_schedule.retry` (Backup Schedules “Retry”)
**Restore visibility (adapter only):**
- `restore.execute` appears as a canonical run entry that links to an existing restore domain record.
- The adapter row MUST be created/visible only once a restore run reaches `previewed` (or later) and MUST NOT be created for `draft`, `scoped`, or `checked`.
- When the restore run is `previewed`, the adapter `OperationRun` MUST use `status=queued` and `outcome=pending`.
- Restore execution history remains owned by the restore domain record (not replaced in Phase 1).
**Out of scope for 054 (explicit):**
@ -100,6 +115,7 @@ ### Scope & Assumptions
- Cancel/rerun/delete controls inside Monitoring hub (hub stays view-only)
- Replacing restore domain records with canonical runs
- A full settings UI for retention/notifications/etc.
- Implementing or validating `AuditLog` behavior for audit-only actions (FR-019) beyond actions explicitly changed by 054
**Assumptions (defaults to remove ambiguity in Phase 1):**
@ -107,35 +123,104 @@ ### Scope & Assumptions
- System-initiated runs (if any) do not notify users by default in Phase 1.
- Transition strategy: write canonical runs in parallel with any existing legacy per-module run tables (where they exist); Monitoring uses canonical runs as the source of truth immediately.
**Run vs Audit-only Adoption Matrix (Phase 1):**
| Feature Area | Action | Tracking | run_type / audit action |
|-------------|--------|----------|--------------------------|
| Policies | Sync now | OperationRun | `policy.sync` |
| Policies | Ignore policy | Audit-only | `policy.ignore` |
| Policies | Export to backup | OperationRun (queued) | `policy.export_backup` |
| Policy Versions | Capture snapshot | OperationRun | `policy.capture_snapshot` |
| Policy Versions | Prune versions | Audit-only | `policy_versions.prune` |
| Policy Versions | Archive versions | Audit-only | `policy_versions.archive` |
| Inventory | Sync now | OperationRun | `inventory.sync` |
| Directory Groups | Sync groups | OperationRun | `directory_groups.sync` |
| Drift | Generate drift | OperationRun | `drift.generate` |
| Backup Sets | Add policies | OperationRun | `backup_set.add_policies` |
| Backup Sets | Archive | Audit-only (DB-only) | `backup_set.archive` |
| Backup Sets | Restore (bulk) | OperationRun | `backup_set.restore` |
| Backup Sets | Force delete | Audit-only (admin-only) | `backup_set.force_delete` |
| Backup Schedules | Run now | OperationRun | `backup_schedule.run_now` |
| Backup Schedules | Retry | OperationRun | `backup_schedule.retry` |
| Backup Schedules | Edit | Audit-only | `backup_schedule.edit` |
| Backup Schedules | Delete | Audit-only | `backup_schedule.delete` |
| Tenants | Sync tenant | OperationRun | `tenant.sync` |
| Tenants | Admin consent | Audit-only | `tenant.admin_consent` |
| Tenants | Verify configuration | Audit-only | `tenant.verify_config` |
| Tenants | Setup Intune RBAC | Audit-only | `tenant.setup_rbac` |
| Tenants | Deactivate | Audit-only | `tenant.deactivate` |
| Restore | Execute restore | OperationRun (adapter) | `restore.execute` (context → `restore_run_id`) |
**Rule**: If an action is queued/background, long-running, or requires remote/external calls (e.g., Microsoft Graph),
it MUST be tracked as an OperationRun. Only fast DB-only changes MAY be Audit-only.
### Functional Requirements
- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, outcome bucket, summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”.
- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, terminal outcome (pending while active), summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”.
- **Status semantics**: `status` represents lifecycle stage (`queued` → `running``completed`).
- **Outcome semantics (stored tokens)**: `outcome` stores machine tokens: `pending` while active, otherwise `succeeded` / `partially_succeeded` / `failed`.
- **UI labels**: Monitoring displays human labels derived from stored tokens (e.g., `partially_succeeded` → “Partially succeeded”).
- **Reserved**: `cancelled` is reserved for future use and MUST NOT be produced by 054 (Monitoring hub has no cancel controls).
- **Context safety**: `context` MUST be sanitized and MUST include only safe references (e.g., stable IDs, selection scope keys, correlation IDs). It MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps.
- **FR-002 Run taxonomy**: Run type MUST be stable and follow `"<resource>.<action>"`.
- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity.
- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, outcome bucket, time range, and initiator; default sort is most recent first; default time window is last 30 days.
- **FR-005 Run detail**: Run detail MUST show initiator, run type, outcome bucket, timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results.
- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, `backup_schedule.run_now`, `backup_schedule.retry`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity.
- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, run state (queued/running/terminal outcome), time range, and initiator; default sort is most recent first; default time window is last 30 days.
- **FR-005 Run detail**: Run detail MUST show initiator, run type, run state (queued/running/terminal outcome), timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results.
- **FR-006 View-only hub**: Monitoring hub MUST be view-only (no start/rerun/cancel/delete controls) and MUST link back to owning feature surfaces.
- **FR-007 Start surfaces always enqueue**: Every Phase 1 start surface MUST authorize start, create/reuse a canonical run (dedupe), dispatch background execution, and return immediately with confirmation + “View run”.
- **FR-008 No remote work in interactive request**: Start surfaces MUST NOT perform remote work inline; long-running work happens in background execution.
- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where outcome is `queued` or `running`.
- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where status is `queued` or `running`.
- **FR-010 Phase 1 identity rules**: Identity rules MUST be defined at least as follows:
- `inventory.sync`: tenant + selection scope
- `policy.sync`: tenant + effective policy scope
- `directory_groups.sync`: tenant + selection (Phase 1 default: “all groups”)
- `backup_set.add_policies`: tenant + backup set + selected policies + option flags (if exposed)
- `backup_schedule.run_now`: tenant + backup schedule id
- `backup_schedule.retry`: tenant + backup schedule id
- `drift.generate`: tenant + scope key + baseline/current comparison inputs
- **FR-011 Outcome buckets**: Monitoring MUST present consistent outcome buckets: `queued`, `running`, `succeeded`, `partially succeeded`, `failed`.
- **FR-012 Partial vs failed**: “Partially succeeded” means at least one success and at least one failure; “Failed” means zero successes or cannot proceed.
- **FR-011 Run state presentation**: Monitoring MUST present a consistent run state using a single display bucket derived from lifecycle status and terminal outcome:
- If status is `queued` or `running`, display that status.
- If status is `completed`, display the terminal outcome derived from the stored token (`succeeded`, `partially_succeeded`, or `failed`) using the UI label mapping.
- **FR-012 Partial vs failed (terminal outcomes)**: “Partially succeeded” (`partially_succeeded`) means at least one success and at least one failure; “Failed” (`failed`) means zero successes or cannot proceed.
- **FR-013 Failure details are safe + useful**: Failures MUST be persisted and displayed as stable reason codes and short sanitized messages; failures MUST NOT include secrets/tokens/credentials/PII or full external payload dumps.
- **Reason codes** MUST be stable, machine-readable identifiers (lowercase, dot-separated), e.g. `graph.throttled`, `auth.forbidden`, `validation.invalid_input`, `unexpected.exception`.
- **Messages** MUST be short (≤ 200 characters), sanitized, and written for operators (no secrets/tokens/credentials/PII; no raw external payloads). If needed, messages MAY include a non-sensitive correlation identifier.
- **FR-014 Related links**: Run detail MUST include contextual links where applicable (e.g., drift findings, backup set, inventory results, directory groups, restore detail for `restore.execute`).
- **FR-015 Notifications**: System MUST emit in-app notifications for “queued” (after start) and terminal outcomes for Phase 1 runs; notifications MUST include a short summary and a “View run” link; recipients are the initiating user only.
- If a start request reuses an existing active run (dedupe), the run initiator (as stored on the `OperationRun`) remains the sole notification recipient; the second starter receives no additional notifications.
- **FR-016 Tenant isolation**: All run list/detail access MUST be tenant-scoped; cross-tenant access MUST be denied without disclosing run details.
- **FR-017 No render-time remote calls**: Monitoring pages MUST be render-safe and MUST NOT depend on external service calls during render.
- **FR-018 Roles & permissions**: Roles `Owner`, `Manager`, `Operator`, and `Readonly` MUST be able to view runs; only `Owner`, `Manager`, `Operator` may start operations; `Readonly` is strictly view-only.
- **FR-019 Audit-only actions (no OperationRun)**: Actions that are DB-only and complete within ≤2 seconds under normal
conditions MAY be executed without an OperationRun, as long as they do not start long-running background execution and
do not require any remote/external calls.
- **054 scope note**: 054 does not implement or modify audit-only actions. If any audit-only action is touched as part
of implementing 054 in the future, it MUST comply with this requirement and MUST be covered by tests.
If such an action is security-relevant or changes operational behavior (e.g., “Ignore policy”, “Deactivate tenant”,
“Admin consent”, “Prune versions”, “Force delete”), it MUST write exactly one tenant-scoped AuditLog entry with, at minimum:
- `tenant_id`
- `actor_user_id`
- `action` (stable action identifier, e.g., `policy.ignore`)
- `target_type`, `target_id`
- `before` / `after` (sanitized JSON) **or** `diff` (sanitized JSON)
- `created_at`
**Trigger guidance (to make classification reviewable)**:
- “Security-relevant” includes actions that grant/revoke access, change authorization posture, change admin consent, or otherwise modify who/what can read/write tenant data.
- “Operational behavior change” includes actions that change what the system will do in future runs (e.g., ignore/exclude resources, enable/disable schedules, retention/prune/archive actions, force deletes).
- If unclear whether an Audit-only action is security/ops-relevant, the default is to treat it as such and write an AuditLog entry.
**Sanitization (AuditLog before/after/diff)**:
- AuditLog payloads MUST include only the minimum fields needed to understand the change.
- AuditLog payloads MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps.
- If a field is sensitive, it MUST be omitted or replaced with a non-sensitive placeholder (e.g., `"[REDACTED]"`).
Monitoring/Operations remains reserved for OperationRun-tracked long-running/queued operations.
**Acceptance checks (testable)**:
- Audit-only action creates no OperationRun.
- Audit-only action creates exactly one AuditLog event containing the required fields.
- Audit-only action is tenant-scoped; cross-tenant access is forbidden and MUST NOT create AuditLog entries.
### Key Entities *(include if feature involves data)*
- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, outcome bucket, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references.
- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, terminal outcome, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references.
- **Restore domain record (exception)**: Restore remains a domain workflow record with richer semantics and history. Monitoring shows restore activity through a physical `operation_runs` row (adapter) that links back to the restore record, without replacing it.
## Success Criteria *(mandatory)*
@ -145,4 +230,5 @@ ### Measurable Outcomes
- **SC-001**: Operators can answer “what ran, when, and did it succeed?” for any Phase 1 run in under 1 minute using Monitoring → Operations.
- **SC-002**: Starting a Phase 1 operation returns confirmation + “View run” link within 2 seconds under normal conditions.
- **SC-003**: Duplicate starts reuse the same active run in at least 99% of attempts under normal conditions.
- **SC-003 Measurement scope (definition)**: An “attempt” counts when a start request is made for an operation with identical effective inputs while an identical run is already `queued` or `running`. The success condition is that the system reuses the existing active run reference rather than creating a second active run. “Normal conditions” exclude infrastructure outages (e.g., database unavailable) that prevent either run creation or dedupe evaluation.
- **SC-004**: No secrets/tokens/credentials/PII appear in persisted failures or notifications (verified by tests).

View File

@ -1,64 +1,209 @@
# Tasks: Unified Operations Runs Suitewide
---
description: "Task list for feature implementation"
---
**Feature**: `054-unify-runs-suitewide`
**Spec**: `specs/054-unify-runs-suitewide/spec.md`
# Tasks: Unified Operations Runs Suitewide (054)
## Phase 1: Foundation (DB & Service)
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/`
**Prerequisites**: `specs/054-unify-runs-suitewide/plan.md`, `specs/054-unify-runs-suitewide/spec.md`, `specs/054-unify-runs-suitewide/data-model.md`, `specs/054-unify-runs-suitewide/research.md`, `specs/054-unify-runs-suitewide/quickstart.md`, `specs/054-unify-runs-suitewide/contracts/`
- [ ] **Migration**: Create `operation_runs` table with partial unique index on `(tenant_id, run_identity_hash)` where status in `queued, running`.
- [ ] **Model**: Create `OperationRun` model with casts (JSONB for summaries/context), relationship to `Tenant` and `User`.
- [ ] **Service**: Implement `OperationRunService::ensureRun()` (idempotent creation) and `updateRun()` methods.
- [ ] **Test**: Feature test for `ensureRun` verifying idempotency (same hash = same run) and concurrency safety (simulated).
- [ ] **Test**: Feature test for `updateRun` verifying status transitions and history logging (if any).
- [ ] **Job Middleware**: Create `TrackOperationRun` middleware to automatically handle job success/failure updates for jobs using this system.
- [ ] **Retention**: Create a daily scheduled job to prune `operation_runs` older than 90 days.
**Tests**: Required (Pest) for runtime behavior changes.
**Operations**: Long-running/queued/remote/scheduled actions MUST create/reuse a canonical `OperationRun` and link to Monitoring → Operations. Monitoring pages MUST be DB-only at render time (no remote calls).
## Phase 2: Monitoring UI (Read-Only)
## Format: `[ID] [P?] [Story?] Description`
- [ ] **Page**: Create Filament Page `Monitoring/Operations` (List) strictly scoped to current tenant.
- [ ] **Table**: Implement `OperationRun` table with columns: Status (Badge), Operation Type, Initiator, Started At, Duration, Outcome.
- [ ] **Filters**: Add table filters for `Type`, `Outcome`, `Date Range`, `Initiator`.
- [ ] **Detail View**: Create "View Run" modal or separate page showing:
- Summary counts (Success/Fail/Total)
- Failure list (Sanitized codes/messages)
- Context JSON (Debug info)
- Timeline (Created/Started/Finished)
- [ ] **Test**: Livewire test verifying `Readonly` users can see table but no actions.
- [ ] **Test**: Verify cross-tenant access is blocked.
- **[P]**: Can run in parallel (different files, no blocking dependencies)
- **[Story]**: User story label (e.g., `[US1]`, `[US2]`, `[US3]`) — REQUIRED for story phases only
- Each task includes at least one concrete file path
## Phase 3: Producer Migration (Parallel Write)
## Path Conventions (Laravel Monolith)
### Inventory Sync (`inventory.sync`)
- [ ] **Refactor**: Update `RunInventorySyncJob` dispatch logic to call `OperationRunService::ensureRun()` first.
- [ ] **Refactor**: Update Job to use `TrackOperationRun` middleware (or manual updates) to sync status to `operation_runs`.
- [ ] **Verify**: Ensure legacy `inventory_sync_runs` is still written to (if legacy UI depends on it) OR confirm legacy UI is replaced. *Decision: Parallel write as per spec.*
- Source: `app/`, `routes/`, `resources/`, `config/`, `database/`
- Tests: `tests/Feature/`, `tests/Unit/`
### Policy Sync (`policy.sync`)
- [ ] **Refactor**: Update Policy Sync start logic to use `OperationRunService`.
- [ ] **Refactor**: Instrument Policy Sync job to update `operation_runs`.
---
### Directory Groups Sync (`directory_groups.sync`)
- [ ] **Refactor**: Update Group Sync start logic to use `OperationRunService`.
- [ ] **Refactor**: Instrument Group Sync job to update `operation_runs`.
## Phase 1: Setup (Spec + Contract Alignment)
### Drift Generation (`drift.generate`)
- [ ] **Refactor**: Update Drift Generation start logic to use `OperationRunService`.
- [ ] **Refactor**: Instrument Drift job to update `operation_runs`.
**Purpose**: Resolve ambiguity before implementation starts
### Backup Set (`backup_set.add_policies`)
- [ ] **Refactor**: Update "Add Policies" action to use `OperationRunService`.
- [x] T001 Validate Phase 1 run type set is consistent (adoption set + FR-003 + identity rules) in `specs/054-unify-runs-suitewide/spec.md`
- [x] T002 Validate Monitoring Operations routes match Filament surface + OpenAPI contract in `specs/054-unify-runs-suitewide/contracts/routes.md` and `specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml`
- [x] T003 Validate `OperationRunService` contract + quickstart usage examples align with intended API in `specs/054-unify-runs-suitewide/contracts/service_interface.md` and `specs/054-unify-runs-suitewide/quickstart.md`
- [x] T004 Confirm FR-019 (Audit-only) is out-of-scope for 054 unless an audit-only action is touched; if touched, add AuditLog implementation + tests per `specs/054-unify-runs-suitewide/spec.md` in `specs/054-unify-runs-suitewide/tasks.md`
## Phase 4: Restore Adapter
---
- [ ] **Listener**: Create `SyncRestoreRunToOperation` listener observing `RestoreRun` events (`created`, `updated`).
- [ ] **Logic**: Map `RestoreRun` status/outcomes to `OperationRun` schema.
- `RestoreRun` created -> `OperationRun` created (queued/running).
- `RestoreRun` updated -> `OperationRun` updated.
- [ ] **Context**: Store `{"restore_run_id": <id>}` in `OperationRun.context`.
- [ ] **Test**: Verify creating a `RestoreRun` automatically spawns a shadow `OperationRun`.
## Phase 2: Foundational (Canonical Run Primitive)
## Phase 5: Notifications & Polish
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
- [x] T005 Create `operation_runs` migration (columns + indexes + partial unique index) in `database/migrations/*_create_operation_runs_table.php`
- [x] T006 Create `OperationRun` Eloquent model (casts + relationships + helpers) in `app/Models/OperationRun.php`
- [x] T007 [P] Add `OperationRunStatus` enum in `app/Support/OperationRunStatus.php`
- [x] T008 [P] Add `OperationRunOutcome` enum (stored tokens vs UI labels; `cancelled` reserved) in `app/Support/OperationRunOutcome.php`
- [x] T009 [P] Add `OperationRunType` enum (Phase 1 run types) in `app/Support/OperationRunType.php`
- [x] T010 [P] Add `OperationRunFactory` for tests in `database/factories/OperationRunFactory.php`
- [x] T011 Implement idempotent create/reuse + lifecycle updates + failure sanitization in `app/Services/OperationRunService.php` (depends on T005T010)
- [x] T012 Centralize “View run” + deep links in `app/Support/OperationRunLinks.php` (depends on T011)
- [x] T013 [P] Implement tenant-scoped authorization policy in `app/Policies/OperationRunPolicy.php`
- [x] T014 Register `OperationRun` policy with Gate in `app/Providers/AppServiceProvider.php`
- [x] T015 Implement job middleware lifecycle tracking in `app/Jobs/Middleware/TrackOperationRun.php` (depends on T011)
- [x] T016 Implement 90-day retention pruning job in `app/Jobs/PruneOldOperationRunsJob.php`
- [x] T017 Schedule pruning job daily with non-overlapping lock (`withoutOverlapping` or equivalent cache lock) in `routes/console.php`
- [x] T018 Add scheduled pruning non-overlap regression test (if feasible) in `tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php`
- [x] T019 Add `OperationRunService` tests (hash stability, idempotency, unique-index race, sanitization) in `tests/Feature/OperationRunServiceTest.php`
- [x] T020 Add `TrackOperationRun` middleware lifecycle tests in `tests/Feature/TrackOperationRunMiddlewareTest.php`
---
## Phase 3: User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
**Goal**: Monitoring → Operations shows all canonical runs (tenant-scoped) with list + detail, filters, and safe failures.
**Independent Test**: Trigger at least one run of each Phase 1 run producer, then verify list/detail in Monitoring render DB-only and are tenant-scoped per `specs/054-unify-runs-suitewide/spec.md`.
### Tests for User Story 1
- [x] T021 [US1] Add Monitoring list/detail authorization + tenant isolation tests in `tests/Feature/MonitoringOperationsTest.php`
- [x] T022 [P] [US1] Add Monitoring DB-only render test (mock Graph client; assert never called) in `tests/Feature/MonitoringOperationsTest.php`
- [x] T023 [P] [US1] Add restore adapter visibility tests (created/visible from `previewed` onward; `previewed` maps to `queued/pending`) in `tests/Feature/RestoreAdapterTest.php`
### Implementation for User Story 1
- [x] T024 [US1] Create Filament Monitoring resource (list + view-only) in `app/Filament/Resources/OperationRunResource.php`
- [x] T025 [US1] Implement list columns + default sort + default last-30-days window in `app/Filament/Resources/OperationRunResource.php`
- [x] T026 [US1] Implement list filters (type, state bucket, time range, initiator) in `app/Filament/Resources/OperationRunResource.php`
- [x] T027 [US1] Implement run detail view (meta, summary_counts, failures, context, links) in `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php`
- [x] T028 [US1] Implement related links for each Phase 1 run type in `app/Support/OperationRunLinks.php`
- [x] T029 [US1] Implement RestoreRun → OperationRun adapter create/update (from `previewed` onward; `previewed` maps to `status=queued`, `outcome=pending`) in `app/Listeners/SyncRestoreRunToOperationRun.php`
- [x] T030 [US1] Wire RestoreRun lifecycle events to adapter in `app/Observers/RestoreRunObserver.php`
- [x] T031 [US1] Register RestoreRun observer in `app/Providers/AppServiceProvider.php`
---
## Phase 4: User Story 2 - Start Operations Without Blocking (Priority: P2)
**Goal**: Start surfaces are enqueue-only, return immediate confirmation + “View run”, and jobs update canonical run lifecycle.
**Independent Test**: Start each Phase 1 operation from its owning UI and confirm the request returns quickly, includes “View run”, and the run progresses queued → running → terminal outcome.
### Tests for User Story 2
- [x] T032 [P] [US2] Add Inventory “Sync now” start-surface tests in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`
- [x] T033 [P] [US2] Add Policies “Sync now” start-surface tests in `tests/Feature/PolicySyncStartSurfaceTest.php`
- [x] T034 [P] [US2] Add Directory Groups “Sync groups” start-surface tests in `tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php`
- [x] T035 [P] [US2] Add Drift “Generate drift” start-surface tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
- [x] T036 [P] [US2] Add Backup Set “Add policies” start-surface tests in `tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php`
- [x] T037 [P] [US2] Add Backup Schedule “Run now/Retry” start-surface tests in `tests/Feature/BackupScheduling/RunNowRetryActionsTest.php`
- [x] T067 [P] [US2] Add Backup Set “Remove policies” (row + bulk) start-surface tests in `tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php` and `tests/Feature/Filament/BackupItemsBulkRemoveTest.php`
- [x] T038 [P] [US2] Add “queued + terminal” notification tests (initiator only; safe content) in `tests/Feature/Notifications/OperationRunNotificationTest.php`
### Implementation for User Story 2
- [x] T039 [P] [US2] Refactor Inventory “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Pages/InventoryLanding.php`
- [x] T040 [P] [US2] Refactor Policies “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
- [x] T041 [P] [US2] Refactor Directory Groups “Sync groups” to ensure run + dispatch + “View run” in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
- [x] T042 [P] [US2] Refactor Drift “Generate drift” (manual + auto-on-open) to ensure run + dispatch + “View run” in `app/Filament/Pages/DriftLanding.php`
- [x] T043 [P] [US2] Refactor Backup Set “Add policies” Livewire action to ensure run + dispatch + “View run” in `app/Livewire/BackupSetPolicyPickerTable.php`
- [x] T044 [P] [US2] Refactor Backup Schedule “Run now/Retry” actions to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupScheduleResource.php`
- [x] T068 [P] [US2] Refactor Backup Set “Remove policies” actions (row + bulk) to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` and `app/Jobs/RemovePoliciesFromBackupSetJob.php`
- [x] T045 [P] [US2] Instrument inventory sync job run lifecycle + summary/failures in `app/Jobs/RunInventorySyncJob.php`
- [x] T046 [P] [US2] Instrument policy sync job run lifecycle + summary/failures in `app/Jobs/SyncPoliciesJob.php`
- [x] T047 [P] [US2] Instrument groups sync job run lifecycle + summary/failures in `app/Jobs/EntraGroupSyncJob.php`
- [x] T048 [P] [US2] Instrument drift generation job run lifecycle + summary/failures in `app/Jobs/GenerateDriftFindingsJob.php`
- [x] T049 [P] [US2] Instrument backup set “add policies” job run lifecycle + summary/failures in `app/Jobs/AddPoliciesToBackupSetJob.php`
- [x] T050 [P] [US2] Instrument backup schedule job run lifecycle + summary/failures in `app/Jobs/RunBackupScheduleJob.php`
- [x] T051 [US2] Implement queued notification (after successful dispatch) in `app/Notifications/OperationRunQueued.php`
- [x] T052 [US2] Implement terminal outcome notification (succeeded/partial/failed) in `app/Notifications/OperationRunCompleted.php`
- [x] T053 [US2] Emit notifications from canonical lifecycle updates (initiator only) in `app/Services/OperationRunService.php`
- [x] T054 [US2] Handle queue dispatch failures (fail fast; no misleading queued runs) in `app/Services/OperationRunService.php`
---
## Phase 5: User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3)
**Goal**: Duplicate starts reuse the same active run (dedupe), enforced at DB level and validated by tests.
**Independent Test**: Start the same operation twice with identical effective inputs while the first is queued/running and verify the system reuses the active run.
### Tests for User Story 3
- [x] T055 [US3] Add service-level dedupe + race-collision tests in `tests/Feature/OperationRunServiceTest.php`
- [x] T056 [US3] Add end-to-end “reuse active run” test for at least one producer in `tests/Feature/PolicySyncStartSurfaceTest.php`
### Implementation for User Story 3
- [x] T057 [US3] Normalize identity inputs before hashing (stable JSON; ignore initiator) in `app/Services/OperationRunService.php`
- [x] T058 [P] [US3] Ensure `inventory.sync` identity inputs follow FR-010 in `app/Filament/Pages/InventoryLanding.php`
- [x] T059 [P] [US3] Ensure `policy.sync` identity inputs follow FR-010 in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
- [x] T060 [P] [US3] Ensure `directory_groups.sync` identity inputs follow FR-010 in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
- [x] T061 [P] [US3] Ensure `drift.generate` identity inputs follow FR-010 in `app/Filament/Pages/DriftLanding.php`
- [x] T062 [P] [US3] Ensure `backup_set.add_policies` identity inputs follow FR-010 in `app/Livewire/BackupSetPolicyPickerTable.php`
- [x] T063 [P] [US3] Ensure `backup_schedule.run_now`/`backup_schedule.retry` identity inputs follow FR-010 in `app/Filament/Resources/BackupScheduleResource.php`
---
## Phase 6: Polish & Cross-Cutting Concerns
- [x] T064 [P] Run formatter on changed files via `./vendor/bin/pint --dirty`
- [x] T065 Run targeted tests for this feature via `./vendor/bin/sail artisan test tests/Feature/MonitoringOperationsTest.php`
- [x] T066 Run quickstart scenarios and update docs if needed in `specs/054-unify-runs-suitewide/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- Setup (Phase 1) → blocks Foundational
- Foundational (Phase 2) → blocks US1/US2/US3
- US1/US2/US3 can proceed after Phase 2 (parallel if staffed, or sequential P1 → P2 → P3)
- Polish (Phase 6) depends on the desired user stories being complete
### User Story Dependencies (Graph)
```text
Foundational
├─ US1 (Monitoring UI)
├─ US2 (Start surfaces + lifecycle + notifications)
└─ US3 (Dedupe + identity + race handling)
```
---
## Parallel Execution Examples
After Phase 2, these can run in parallel (different files/modules):
### US1 (Monitoring UI)
- `T023` (restore adapter tests) + `T024` (resource scaffold) + `T029` (restore adapter listener)
### US2 (Start surfaces + lifecycle + notifications)
- Tests: `T032``T038`
- Producers/start surfaces: `T039``T044`
- Workers/job instrumentation: `T045``T050`
- Notification classes: `T051` + `T052`
### US3 (Dedupe + identity + race handling)
- Identity review per producer: `T058``T063`
---
## Implementation Strategy
### MVP First (US1 Only)
1. Complete Phase 12 (canonical run primitive)
2. Complete US1 (Monitoring list/detail + tenant isolation)
3. Validate Monitoring renders DB-only and is tenant-scoped
### Incremental Delivery
1. Add US2 producers/workers + notifications
2. Add US3 dedupe + race validation
3. Polish (formatting, targeted tests, quickstart validation)
- [ ] **Notifications**: Implement Database Notifications for "Run Started" (with link) and "Run Completed" (with outcome).
- [ ] **Frontend**: Ensure "View Run" link in Toast notifications correctly opens the Monitoring Detail view.
- [ ] **Final Verify**: Run through the `requirements.md` checklist manually.

View File

@ -7,6 +7,7 @@
use App\Services\BulkOperationService;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
@ -37,6 +38,170 @@
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: ['backup_schedule_id' => (int) $schedule->id],
initiator: $user,
);
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
{
public function __construct() {}
public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
{
return ['synced' => [], 'failures' => []];
}
});
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'status' => 'completed',
'item_count' => 0,
]);
app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService
{
public function __construct(private readonly BackupSet $backupSet) {}
public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
{
return $this->backupSet;
}
});
Cache::flush();
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
app(PolicySyncService::class),
app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class),
app(BulkOperationService::class),
);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
expect($run->backup_set_id)->toBe($backupSet->id);
$operationRun->refresh();
expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->summary_counts)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
'backup_set_id' => (int) $backupSet->id,
]);
});
it('skips runs when all policy types are unknown', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['definitelyNotARealPolicyType'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
Cache::flush();
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: ['backup_schedule_id' => (int) $schedule->id],
initiator: $user,
);
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
app(PolicySyncService::class),
app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class),
app(BulkOperationService::class),
);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
expect($run->backup_set_id)->toBeNull();
$operationRun->refresh();
expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('failed');
expect($operationRun->failure_summary)->toMatchArray([
['code' => 'unknown_policy_type', 'message' => $run->error_message],
]);
});
it('updates the operation run based on the backup schedule run id when not passed into the job', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: ['backup_schedule_id' => (int) $schedule->id],
initiator: $user,
);
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
{
public function __construct() {}
@ -75,52 +240,12 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
app(BulkOperationService::class),
);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
expect($run->backup_set_id)->toBe($backupSet->id);
});
it('skips runs when all policy types are unknown', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['definitelyNotARealPolicyType'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => null,
$operationRun->refresh();
expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->summary_counts)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
'backup_set_id' => (int) $backupSet->id,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
Cache::flush();
(new RunBackupScheduleJob($run->id))->handle(
app(PolicySyncService::class),
app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class),
app(BulkOperationService::class),
);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
expect($run->backup_set_id)->toBeNull();
});

View File

@ -5,14 +5,29 @@
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\User;
use App\Notifications\OperationRunQueued;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
beforeEach(function () {
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
});
test('operator can run now and it persists a database notification', function () {
Queue::fake();
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@ -42,6 +57,17 @@
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now')
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
]);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@ -50,19 +76,29 @@
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class);
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id
&& $job->operationRun instanceof OperationRun
&& $job->operationRun->is($operationRun);
});
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'notifiable_type' => User::class,
'type' => OperationRunQueued::class,
'data->format' => 'filament',
'data->title' => 'Run dispatched',
'data->title' => 'Operation queued',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($operationRun, $tenant));
});
test('operator can retry and it persists a database notification', function () {
Queue::fake();
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@ -92,6 +128,17 @@
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry')
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
]);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@ -100,13 +147,24 @@
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class);
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id
&& $job->operationRun instanceof OperationRun
&& $job->operationRun->is($operationRun);
});
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'notifiable_type' => User::class,
'type' => OperationRunQueued::class,
'data->format' => 'filament',
'data->title' => 'Retry dispatched',
'data->title' => 'Operation queued',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($operationRun, $tenant));
});
test('readonly cannot dispatch run now or retry', function () {
@ -144,10 +202,16 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->count())
->toBe(0);
});
test('operator can bulk run now and it persists a database notification', function () {
Queue::fake();
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@ -189,6 +253,12 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now')
->count())
->toBe(2);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@ -204,10 +274,15 @@
'data->format' => 'filament',
'data->title' => 'Runs dispatched',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::index($tenant));
});
test('operator can bulk retry and it persists a database notification', function () {
Queue::fake();
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
@ -249,6 +324,12 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry')
->count())
->toBe(2);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
@ -264,10 +345,15 @@
'data->format' => 'filament',
'data->title' => 'Retries dispatched',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::index($tenant));
});
test('operator can bulk retry even if a run already exists for this minute', function () {
Queue::fake();
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');

View File

@ -0,0 +1,70 @@
<?php
use App\Jobs\AddPoliciesToBackupSetJob;
use App\Livewire\BackupSetPolicyPickerTable;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('enqueues backup set add-policies via canonical operation run (no Graph calls in request)', function () {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
$policies = Policy::factory()->count(2)->create([
'tenant_id' => $tenant->id,
'ignored_at' => null,
'last_synced_at' => now(),
]);
Livewire::actingAs($user)
->test(BackupSetPolicyPickerTable::class, [
'backupSetId' => $backupSet->id,
])
->callTableBulkAction('add_selected_to_backup_set', $policies)
->assertHasNoTableBulkActionErrors();
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_set.add_policies')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
expect($opRun?->outcome)->toBe('pending');
expect($opRun?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(AddPoliciesToBackupSetJob::class, function (AddPoliciesToBackupSetJob $job) use ($opRun): bool {
return $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
});

View File

@ -0,0 +1,65 @@
<?php
use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Support\OperationRunLinks;
use Filament\Notifications\DatabaseNotification;
it('remove policies job sends completion notification with view link', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->getKey(),
'name' => 'Test backup',
'item_count' => 0,
]);
$item = BackupItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'backup_set_id' => $backupSet->getKey(),
]);
$backupSet->update(['item_count' => $backupSet->items()->count()]);
$opRun = OperationRun::create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'backup_set.remove_policies',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => 'remove-hash-1',
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => [(int) $item->getKey()],
],
]);
$this->mock(AuditLogger::class, function ($mock): void {
$mock->shouldReceive('log')->zeroOrMoreTimes();
});
$job = new RemovePoliciesFromBackupSetJob(
backupSetId: (int) $backupSet->getKey(),
backupItemIds: [(int) $item->getKey()],
initiatorUserId: (int) $user->getKey(),
operationRun: $opRun,
);
$job->handle(app(AuditLogger::class), app(BulkOperationService::class));
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => DatabaseNotification::class,
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
});

View File

@ -0,0 +1,60 @@
<?php
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('queues a canonical operation run for row remove and does not call Graph during the request', function () {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
$backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create();
Livewire::test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->callTableAction('remove', $backupItem);
Queue::assertPushed(RemovePoliciesFromBackupSetJob::class, function (RemovePoliciesFromBackupSetJob $job) use ($backupSet, $backupItem, $user) {
return $job->backupSetId === (int) $backupSet->getKey()
&& $job->backupItemIds === [(int) $backupItem->getKey()]
&& $job->initiatorUserId === (int) $user->getKey();
});
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_set.remove_policies')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run->status)->toBe('queued');
expect($run->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
});

View File

@ -0,0 +1,88 @@
<?php
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('reconciles completed backup schedule runs into operation runs', function () {
$tenant = Tenant::factory()->create();
$schedule = BackupSchedule::create([
'tenant_id' => $tenant->id,
'name' => 'Daily',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => null,
]);
$startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC');
$finishedAt = CarbonImmutable::parse('2026-01-01 00:00:05', 'UTC');
$scheduleRun = BackupScheduleRun::create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'),
'started_at' => $startedAt,
'finished_at' => $finishedAt,
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => [
'policies_total' => 5,
'policies_backed_up' => 18,
'sync_failures' => [],
],
'error_code' => null,
'error_message' => null,
'backup_set_id' => null,
]);
$operationRun = OperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule.run_now',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id),
'summary_counts' => [],
'failure_summary' => [],
'context' => [
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $scheduleRun->id,
],
]);
$this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [
'--tenant' => [$tenant->id],
'--older-than' => 0,
])->assertSuccessful();
$operationRun->refresh();
expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->failure_summary)->toBe([]);
expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s'));
expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s'));
expect($operationRun->summary_counts)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $scheduleRun->id,
'policies_total' => 5,
'policies_backed_up' => 18,
'sync_failures' => 0,
]);
});

View File

@ -0,0 +1,72 @@
<?php
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('enqueues group sync and creates a canonical operation run', function () {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class)
->callAction('sync_groups');
$run = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING);
expect($run?->selection_key)->toBe('groups-v1:all');
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'directory_groups.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->runId === (int) $run?->getKey()
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
});
it('hides group sync start action for readonly users', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class)
->assertActionHidden('sync_groups');
Queue::assertNothingPushed();
});

View File

@ -1,16 +1,28 @@
<?php
use App\Filament\Pages\DriftLanding;
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Notifications\RunStatusChangedNotification;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use App\Support\RunIdempotency;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
beforeEach(function () {
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
});
test('opening Drift dispatches generation when findings are missing', function () {
Queue::fake();
@ -61,24 +73,30 @@
'current_run_id' => (int) $current->getKey(),
]);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => RunStatusChangedNotification::class,
]);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
->latest('id')
->first();
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(BulkOperationRunResource::getUrl('view', ['record' => $bulkRun->getKey()], tenant: $tenant));
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool {
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->baselineRunId === (int) $baseline->getKey()
&& $job->currentRunId === (int) $current->getKey()
&& $job->scopeKey === $scopeKey
&& $job->bulkOperationRunId === (int) $bulkRun->getKey();
&& $job->bulkOperationRunId === (int) $bulkRun->getKey()
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
});
@ -123,6 +141,11 @@
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->count())->toBe(1);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
->count())->toBe(1);
});
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
@ -144,6 +167,7 @@
Queue::assertNothingPushed();
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
test('opening Drift does not dispatch generation for readonly users', function () {
@ -171,4 +195,5 @@
Queue::assertNothingPushed();
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});

View File

@ -1,12 +1,13 @@
<?php
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Notifications\RunStatusChangedNotification;
use App\Models\OperationRun;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftFindingGenerator;
use App\Support\OperationRunLinks;
use Filament\Notifications\DatabaseNotification;
use Mockery\MockInterface;
test('drift generation job sends completion notification with view link', function () {
@ -40,6 +41,21 @@
'failures' => [],
]);
$opRun = OperationRun::create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'drift.generate',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => 'drift-hash-1',
'context' => [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
]);
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
$mock->shouldReceive('generate')->once()->andReturn(0);
});
@ -51,6 +67,7 @@
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
operationRun: $opRun,
);
$job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class));
@ -60,13 +77,13 @@
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => RunStatusChangedNotification::class,
'type' => DatabaseNotification::class,
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
->toBe(OperationRunLinks::view($opRun, $tenant));
});
test('drift generation job sends failure notification with view link', function () {
@ -100,6 +117,21 @@
'failures' => [],
]);
$opRun = OperationRun::create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'drift.generate',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => 'drift-hash-2',
'context' => [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
]);
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
$mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom'));
});
@ -111,6 +143,7 @@
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
operationRun: $opRun,
);
try {
@ -133,11 +166,11 @@
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => RunStatusChangedNotification::class,
'type' => DatabaseNotification::class,
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
->toBe(OperationRunLinks::view($opRun, $tenant));
});

View File

@ -1,21 +1,25 @@
<?php
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('backup items table can bulk remove selected items', function () {
Queue::fake();
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
@ -64,11 +68,21 @@
->callTableBulkAction('bulk_remove', collect([$itemA, $itemB]))
->assertHasNoTableBulkActionErrors();
Queue::assertPushed(RemovePoliciesFromBackupSetJob::class);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_set.remove_policies')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->outcome)->toBe('pending');
expect($run?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
// Enqueue-only: deletion happens in the job.
$backupSet->refresh();
expect($backupSet->items()->count())->toBe(0);
expect($backupSet->item_count)->toBe(0);
$this->assertSoftDeleted('backup_items', ['id' => $itemA->id]);
$this->assertSoftDeleted('backup_items', ['id' => $itemB->id]);
expect($backupSet->items()->count())->toBe(2);
expect($backupSet->item_count)->toBe(2);
});

View File

@ -0,0 +1,58 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Jobs\RunInventorySyncJob;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use App\Services\Inventory\InventorySyncService;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('enqueues inventory sync and creates a canonical operation run without calling Graph in request', function () {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
expect($opRun?->outcome)->toBe('pending');
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
});

View File

@ -23,7 +23,7 @@
$selectionPayload = $sync->defaultSelectionPayload();
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
$run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload);
$run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']);
$bulkRun = app(BulkOperationService::class)->createRun(
tenant: $tenant,
@ -52,8 +52,10 @@
expect($run->finished_at)->not->toBeNull();
expect($bulkRun->status)->toBe('completed');
expect($bulkRun->processed_items)->toBe(count($policyTypes));
expect($bulkRun->succeeded)->toBe(count($policyTypes));
expect($bulkRun->failed)->toBe(0);
expect($bulkRun->skipped)->toBe(0);
expect($bulkRun->processed_items)->toBeGreaterThan(0);
expect($bulkRun->processed_items)->toBe($bulkRun->succeeded);
});
it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () {
@ -66,6 +68,8 @@
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
$run->update(['selection_payload' => $computed['selection']]);
$bulkRun = app(BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,

View File

@ -0,0 +1,140 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
it('allows access to monitoring page for tenant members', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user);
$run = OperationRun::create([
'tenant_id' => $tenant->id,
'type' => 'test.run',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hash123',
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->assertSuccessful()
->assertSee('test.run');
});
it('renders monitoring pages DB-only (never calls Graph)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user);
$run = OperationRun::create([
'tenant_id' => $tenant->id,
'type' => 'test.run',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hash123',
]);
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->assertSuccessful();
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->assertSuccessful();
});
it('shows runs only for current tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$user = User::factory()->create();
$tenantA->users()->attach($user);
// We must simulate being in tenant context
$this->actingAs($user);
// Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit.
// Easier approach: visit the page for tenantA
OperationRun::create([
'tenant_id' => $tenantA->id,
'type' => 'tenantA.run',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hashA',
]);
OperationRun::create([
'tenant_id' => $tenantB->id,
'type' => 'tenantB.run',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hashB',
]);
// Livewire::test needs to know the tenant if the component relies on it.
// However, the component relies on `Filament::getTenant()`.
// The cleanest way is to just GET the page URL, which runs middleware.
$this->get(OperationRunResource::getUrl('index', tenant: $tenantA))
->assertSee('tenantA.run')
->assertDontSee('tenantB.run');
});
it('allows readonly users to view operations list and detail', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$tenant->users()->attach($user, ['role' => 'readonly']);
$run = OperationRun::create([
'tenant_id' => $tenant->id,
'type' => 'test.run',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hash123',
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->assertSuccessful()
->assertSee('test.run');
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->assertSuccessful()
->assertSee('test.run');
});
it('denies access to unauthorized users', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
// Not attached to tenant
// In a multitenant app, if you try to access a tenant route you are not part of,
// Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403.
// The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it.
// We should accept 404 or 403.
$response = $this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant));
// Allow either 403 or 404 as "Denied"
$this->assertTrue(in_array($response->status(), [403, 404]));
});

View File

@ -0,0 +1,131 @@
<?php
use App\Models\OperationRun;
use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$service = app(OperationRunService::class);
$run = $service->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: ['scope' => 'all'],
initiator: $user,
);
$service->dispatchOrFail($run, function (): void {
// no-op (dispatch succeeded)
});
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => OperationRunQueued::class,
'data->format' => 'filament',
'data->title' => 'Operation queued',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($run, $tenant));
});
it('does not emit queued notifications for runs without an initiator', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$service = app(OperationRunService::class);
$run = $service->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: ['scope' => 'all'],
initiator: null,
);
$service->dispatchOrFail($run, function (): void {
// no-op
});
expect($user->notifications()->count())->toBe(0);
});
it('emits a terminal notification when an operation run transitions to completed', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'context' => ['policy_types' => ['deviceConfiguration']],
]);
$service = app(OperationRunService::class);
$service->updateRun(
$run,
status: 'completed',
outcome: 'succeeded',
summaryCounts: ['observed' => 1],
failures: [],
);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => OperationRunCompleted::class,
'data->format' => 'filament',
'data->title' => 'Operation completed',
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($run, $tenant));
});
it('marks a run failed if dispatch throws synchronously', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$service = app(OperationRunService::class);
$run = $service->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: ['scope' => 'all'],
initiator: $user,
);
expect(fn () => $service->dispatchOrFail($run, function (): void {
throw new RuntimeException('Queue misconfigured');
}))
->toThrow(RuntimeException::class);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('failed');
});

View File

@ -0,0 +1,171 @@
<?php
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
it('creates a new operation run', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = new OperationRunService;
$run = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $user);
expect($run)->toBeInstanceOf(OperationRun::class);
$this->assertDatabaseHas('operation_runs', [
'id' => $run->getKey(),
'tenant_id' => $tenant->getKey(),
'type' => 'test.action',
'status' => 'queued',
'initiator_name' => $user->name,
]);
});
it('reuses an active run (idempotent)', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
expect($runA->getKey())->toBe($runB->getKey());
expect(OperationRun::query()->count())->toBe(1);
});
it('does not replace the initiator when deduping', function () {
$tenant = Tenant::factory()->create();
$userA = User::factory()->create();
$userB = User::factory()->create();
$service = new OperationRunService;
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userA);
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userB);
expect($runA->getKey())->toBe($runB->getKey());
expect($runB->fresh()?->user_id)->toBe($userA->getKey());
expect($runB->fresh()?->initiator_name)->toBe($userA->name);
});
it('hashes inputs deterministically regardless of key order', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$runA = $service->ensureRun($tenant, 'test.action', ['b' => 2, 'a' => 1]);
$runB = $service->ensureRun($tenant, 'test.action', ['a' => 1, 'b' => 2]);
expect($runA->getKey())->toBe($runB->getKey());
});
it('hashes list inputs deterministically regardless of list order', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$runA = $service->ensureRun($tenant, 'test.action', ['ids' => [2, 1]]);
$runB = $service->ensureRun($tenant, 'test.action', ['ids' => [1, 2]]);
expect($runA->getKey())->toBe($runB->getKey());
});
it('handles unique-index race collisions by returning the active run', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$fired = false;
$dispatcher = OperationRun::getEventDispatcher();
OperationRun::creating(function (OperationRun $model) use (&$fired): void {
if ($fired) {
return;
}
$fired = true;
OperationRun::withoutEvents(function () use ($model): void {
OperationRun::query()->create([
'tenant_id' => $model->tenant_id,
'user_id' => $model->user_id,
'initiator_name' => $model->initiator_name,
'type' => $model->type,
'status' => $model->status,
'outcome' => $model->outcome,
'run_identity_hash' => $model->run_identity_hash,
'context' => $model->context,
]);
});
});
try {
$run = $service->ensureRun($tenant, 'test.race', ['scope' => 'full']);
} finally {
OperationRun::flushEventListeners();
OperationRun::setEventDispatcher($dispatcher);
}
expect($run)->toBeInstanceOf(OperationRun::class);
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'test.race')->count())
->toBe(1);
});
it('creates a new run after the previous one completed', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
$runA->update(['status' => 'completed']);
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
expect($runA->getKey())->not->toBe($runB->getKey());
expect(OperationRun::query()->count())->toBe(2);
});
it('updates run lifecycle fields and summaries', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$run = $service->ensureRun($tenant, 'test.action', []);
$service->updateRun($run, 'running');
$fresh = $run->fresh();
expect($fresh?->status)->toBe('running');
expect($fresh?->started_at)->not->toBeNull();
$service->updateRun($run, 'completed', 'succeeded', ['success' => 1]);
$fresh = $run->fresh();
expect($fresh?->status)->toBe('completed');
expect($fresh?->outcome)->toBe('succeeded');
expect($fresh?->completed_at)->not->toBeNull();
expect($fresh?->summary_counts)->toBe(['success' => 1]);
});
it('sanitizes failure messages and redacts obvious secrets', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$run = $service->ensureRun($tenant, 'test.action', []);
try {
throw new RuntimeException('Authorization: Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789');
} catch (Throwable $e) {
$service->failRun($run, $e);
}
$fresh = $run->fresh();
expect($fresh?->status)->toBe('completed');
expect($fresh?->outcome)->toBe('failed');
expect($fresh?->failure_summary)->toBeArray();
$message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
expect($message)->toContain('[REDACTED]');
});

View File

@ -0,0 +1,181 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Jobs\SyncPoliciesJob;
use App\Models\OperationRun;
use App\Models\Policy;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('queues policy sync and creates a canonical operation run (no Graph calls in request)', function () {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
sort($requestedTypes);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context)->toMatchArray([
'scope' => 'all',
'types' => $requestedTypes,
]);
Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $requestedTypes): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->types === $requestedTypes
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $run?->getKey();
});
});
it('reuses an active policy sync run and does not enqueue twice', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction();
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction();
Queue::assertPushed(SyncPoliciesJob::class, 1);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->count())->toBe(1);
});
it('queues row policy sync and creates a canonical operation run (no Graph calls in request)', function () {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'ignored_at' => null,
]);
Livewire::test(ListPolicies::class)
->callTableAction('sync', $policy)
->assertHasNoTableActionErrors();
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync_one')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context)->toMatchArray([
'scope' => 'one',
'policy_id' => (int) $policy->getKey(),
]);
Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $policy): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->types === null
&& $job->policyIds === [(int) $policy->getKey()]
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $run?->getKey();
});
});
it('queues bulk policy sync and creates a canonical operation run (no Graph calls in request)', function () {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policies = Policy::factory()->count(2)->create([
'tenant_id' => $tenant->getKey(),
'ignored_at' => null,
]);
Livewire::test(ListPolicies::class)
->callTableBulkAction('bulk_sync', $policies)
->assertHasNoTableBulkActionErrors();
$selectedIds = $policies
->pluck('id')
->map(static fn ($id): int => (int) $id)
->sort()
->values()
->all();
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context)->toMatchArray([
'scope' => 'subset',
'policy_ids' => $selectedIds,
]);
Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $selectedIds): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->types === null
&& $job->policyIds === $selectedIds
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $run?->getKey();
});
});
it('hides policy sync start action for readonly users', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->assertActionHidden('sync');
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,93 @@
<?php
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\RestoreRunStatus;
it('creates an operation run only from previewed onward', function () {
$restoreRun = RestoreRun::factory()->create([
'status' => RestoreRunStatus::Checked->value,
]);
expect(OperationRun::query()
->where('tenant_id', $restoreRun->tenant_id)
->where('type', 'restore.execute')
->count())->toBe(0);
$restoreRun->update(['status' => RestoreRunStatus::Previewed->value]);
$opRun = OperationRun::query()
->where('tenant_id', $restoreRun->tenant_id)
->where('type', 'restore.execute')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
expect($opRun?->outcome)->toBe('pending');
expect($opRun?->context)->toMatchArray([
'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $restoreRun->backup_set_id,
'is_dry_run' => (bool) $restoreRun->is_dry_run,
]);
});
it('updates the operation run when restore completes', function () {
$restoreRun = RestoreRun::factory()->create([
'status' => RestoreRunStatus::Previewed->value,
]);
$opRun = OperationRun::query()
->where('tenant_id', $restoreRun->tenant_id)
->where('type', 'restore.execute')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
$restoreRun->update([
'status' => RestoreRunStatus::Completed->value,
'results' => [
'assignment_outcomes' => [
['status' => 'success'],
['status' => 'failed'],
['status' => 'skipped'],
],
],
]);
$opRun->refresh();
expect($opRun->status)->toBe('completed');
expect($opRun->outcome)->toBe('succeeded');
expect($opRun->summary_counts)->toMatchArray([
'assignments_success' => 1,
'assignments_failed' => 1,
'assignments_skipped' => 1,
]);
expect($opRun->completed_at)->not->toBeNull();
});
it('maps cancelled restore runs to failed outcome (cancelled is reserved)', function () {
$restoreRun = RestoreRun::factory()->create([
'status' => RestoreRunStatus::Previewed->value,
]);
$opRun = OperationRun::query()
->where('tenant_id', $restoreRun->tenant_id)
->where('type', 'restore.execute')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
$restoreRun->update(['status' => RestoreRunStatus::Cancelled->value]);
$opRun->refresh();
expect($opRun->status)->toBe('completed');
expect($opRun->outcome)->toBe('failed');
expect($opRun->failure_summary)->toBeArray();
expect($opRun->failure_summary[0]['code'] ?? null)->toBe('restore.cancelled');
});

View File

@ -0,0 +1,15 @@
<?php
use App\Jobs\PruneOldOperationRunsJob;
use Illuminate\Console\Scheduling\Schedule;
it('schedules pruning job daily without overlapping', function () {
/** @var Schedule $schedule */
$schedule = app(Schedule::class);
$event = collect($schedule->events())
->first(fn ($event) => ($event->description ?? null) === PruneOldOperationRunsJob::class);
expect($event)->not->toBeNull();
expect($event->withoutOverlapping)->toBeTrue();
});

View File

@ -0,0 +1,43 @@
<?php
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
it('does not mark an operation run completed when the job is released', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'test.release',
inputs: ['foo' => 'bar'],
initiator: $user,
);
$job = new class($operationRun) implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public OperationRun $operationRun)
{
$this->withFakeQueueInteractions();
}
};
$middleware = new TrackOperationRun;
$middleware->handle($job, function ($job): void {
$job->release(60);
});
$operationRun->refresh();
expect($operationRun->status)->toBe('running');
expect($operationRun->outcome)->toBe('pending');
});