feat(spec-087): remove legacy runs

This commit is contained in:
Ahmed Darrazi 2026-02-12 13:39:24 +01:00
parent 1acbf8cc54
commit 681e27c0bf
147 changed files with 2711 additions and 2862 deletions

View File

@ -27,6 +27,7 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub) - PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub) - PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub) - PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -46,8 +47,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
- 088-remove-tenant-graphoptions-legacy: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4 - 088-remove-tenant-graphoptions-legacy: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4
- 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4) - 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4)
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -2,8 +2,8 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\OperationRunService;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -50,7 +50,7 @@ public function handle(): int
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict( $opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant, tenant: $tenant,
type: 'directory_groups.sync', type: 'entra_group_sync',
identityInputs: [ identityInputs: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'slot_key' => $slotKey, 'slot_key' => $slotKey,
@ -65,6 +65,7 @@ public function handle(): int
if (! $opRun->wasRecentlyCreated) { if (! $opRun->wasRecentlyCreated) {
$skipped++; $skipped++;
continue; continue;
} }

View File

@ -5,7 +5,6 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
@ -14,6 +13,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
class TenantpilotPurgeNonPersistentData extends Command class TenantpilotPurgeNonPersistentData extends Command
@ -80,10 +80,6 @@ public function handle(): int
} }
DB::transaction(function () use ($tenant): void { DB::transaction(function () use ($tenant): void {
BackupScheduleRun::query()
->where('tenant_id', $tenant->id)
->delete();
BackupSchedule::query() BackupSchedule::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->delete(); ->delete();
@ -117,6 +113,8 @@ public function handle(): int
->delete(); ->delete();
}); });
$this->recordPurgeOperationRun($tenant, $counts);
$this->info('Purged.'); $this->info('Purged.');
} }
@ -150,7 +148,6 @@ private function resolveTenants()
private function countsForTenant(Tenant $tenant): array private function countsForTenant(Tenant $tenant): array
{ {
return [ return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(), 'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
@ -161,4 +158,39 @@ private function countsForTenant(Tenant $tenant): array
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(), 'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
]; ];
} }
/**
* @param array<string, int> $counts
*/
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
{
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_purge',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', implode(':', [
(string) $tenant->id,
'backup_schedule_purge',
now()->toISOString(),
Str::uuid()->toString(),
])),
'summary_counts' => [
'total' => array_sum($counts),
'processed' => array_sum($counts),
'succeeded' => array_sum($counts),
'failed' => 0,
],
'failure_summary' => [],
'context' => [
'source' => 'tenantpilot:purge-nonpersistent',
'deleted_rows' => $counts,
],
'started_at' => now(),
'completed_at' => now(),
]);
}
} }

View File

@ -2,11 +2,11 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\BackupScheduleRun; use App\Models\BackupSchedule;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OperationRunOutcome;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -16,7 +16,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{--older-than=5 : Only reconcile runs older than N minutes} {--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}'; {--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.'; protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle(OperationRunService $operationRunService): int public function handle(OperationRunService $operationRunService): int
{ {
@ -25,7 +25,7 @@ public function handle(OperationRunService $operationRunService): int
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query() $query = OperationRun::query()
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) ->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']); ->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) { if ($olderThanMinutes > 0) {
@ -49,29 +49,18 @@ public function handle(OperationRunService $operationRunService): int
$failed = 0; $failed = 0;
foreach ($query->cursor() as $operationRun) { foreach ($query->cursor() as $operationRun) {
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id'); $backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
if (! is_numeric($backupScheduleRunId)) { if (! is_numeric($backupScheduleId)) {
$skipped++;
continue;
}
$scheduleRun = BackupScheduleRun::query()
->whereKey((int) $backupScheduleRunId)
->where('tenant_id', $operationRun->tenant_id)
->first();
if (! $scheduleRun) {
if (! $dryRun) { if (! $dryRun) {
$operationRunService->updateRun( $operationRunService->updateRun(
$operationRun, $operationRun,
status: 'completed', status: 'completed',
outcome: 'failed', outcome: OperationRunOutcome::Failed->value,
failures: [ failures: [
[ [
'code' => 'backup_schedule_run.not_found', 'code' => 'backup_schedule.missing_context',
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'), 'message' => 'Backup schedule context is missing from this operation run.',
], ],
], ],
); );
@ -82,13 +71,34 @@ public function handle(OperationRunService $operationRunService): int
continue; continue;
} }
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) { $schedule = BackupSchedule::query()
if (! $dryRun) { ->whereKey((int) $backupScheduleId)
$operationRunService->updateRun($operationRun, 'running', 'pending'); ->where('tenant_id', (int) $operationRun->tenant_id)
->first();
if ($scheduleRun->started_at) { if (! $schedule instanceof BackupSchedule) {
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save(); if (! $dryRun) {
} $operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.not_found',
'message' => 'Backup schedule not found for this operation run.',
],
],
);
}
$failed++;
continue;
}
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
if (! $dryRun) {
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
} }
$reconciled++; $reconciled++;
@ -96,104 +106,27 @@ public function handle(OperationRunService $operationRunService): int
continue; continue;
} }
$outcome = match ($scheduleRun->status) { if ($operationRun->status === 'running') {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded', if (! $dryRun) {
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', $operationRunService->updateRun(
BackupScheduleRun::STATUS_SKIPPED => 'succeeded', $operationRun,
BackupScheduleRun::STATUS_CANCELED => 'failed', status: 'completed',
default => 'failed', outcome: OperationRunOutcome::Failed->value,
}; failures: [
[
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : []; 'code' => 'backup_schedule.stalled',
$syncFailures = $summary['sync_failures'] ?? []; 'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
],
$policiesTotal = (int) ($summary['policies_total'] ?? 0); ],
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0); );
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
$processed = $policiesBackedUp + $syncFailuresCount;
if ($policiesTotal > 0) {
$processed = min($policiesTotal, $processed);
}
$summaryCounts = array_filter([
'total' => $policiesTotal,
'processed' => $processed,
'succeeded' => $policiesBackedUp,
'failed' => $syncFailuresCount,
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
'items' => $policiesTotal,
], fn (mixed $value): bool => is_int($value) && $value !== 0);
$failures = [];
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
$failures[] = [
'code' => 'backup_schedule_run.cancelled',
'message' => 'Backup schedule run was cancelled.',
];
}
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
$failures[] = [
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
'message' => RunFailureSanitizer::sanitizeMessage((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' => RunFailureSanitizer::sanitizeMessage($message),
];
} }
$reconciled++;
continue;
} }
if (! $dryRun) { $skipped++;
$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( $this->info(sprintf(

View File

@ -3,10 +3,8 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob; use App\Jobs\GenerateDriftFindingsJob;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -16,6 +14,8 @@
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use BackedEnum; use BackedEnum;
@ -67,21 +67,35 @@ public function mount(): void
abort(403, 'Not allowed'); abort(403, 'Not allowed');
} }
$latestSuccessful = InventorySyncRun::query() $latestSuccessful = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('status', InventorySyncRun::STATUS_SUCCESS) ->where('type', 'inventory_sync')
->whereNotNull('finished_at') ->where('status', OperationRunStatus::Completed->value)
->orderByDesc('finished_at') ->whereIn('outcome', [
OperationRunOutcome::Succeeded->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->whereNotNull('completed_at')
->orderByDesc('completed_at')
->first(); ->first();
if (! $latestSuccessful instanceof InventorySyncRun) { if (! $latestSuccessful instanceof OperationRun) {
$this->state = 'blocked'; $this->state = 'blocked';
$this->message = 'No successful inventory runs found yet.'; $this->message = 'No successful inventory runs found yet.';
return; return;
} }
$scopeKey = (string) $latestSuccessful->selection_hash; $latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
if ($scopeKey === '') {
$this->state = 'blocked';
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
return;
}
$this->scopeKey = $scopeKey; $this->scopeKey = $scopeKey;
$selector = app(DriftRunSelector::class); $selector = app(DriftRunSelector::class);
@ -100,15 +114,15 @@ public function mount(): void
$this->baselineRunId = (int) $baseline->getKey(); $this->baselineRunId = (int) $baseline->getKey();
$this->currentRunId = (int) $current->getKey(); $this->currentRunId = (int) $current->getKey();
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString(); $this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString();
$this->currentFinishedAt = $current->finished_at?->toDateTimeString(); $this->currentFinishedAt = $current->completed_at?->toDateTimeString();
$existingOperationRun = OperationRun::query() $existingOperationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate') ->where('type', 'drift_generate_findings')
->where('context->scope_key', $scopeKey) ->where('context->scope_key', $scopeKey)
->where('context->baseline_run_id', (int) $baseline->getKey()) ->where('context->baseline_operation_run_id', (int) $baseline->getKey())
->where('context->current_run_id', (int) $current->getKey()) ->where('context->current_operation_run_id', (int) $current->getKey())
->latest('id') ->latest('id')
->first(); ->first();
@ -120,8 +134,8 @@ public function mount(): void
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey) ->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey()) ->where('baseline_operation_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey()) ->where('current_operation_run_id', $current->getKey())
->exists(); ->exists();
if ($exists) { if ($exists) {
@ -130,8 +144,8 @@ public function mount(): void
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey) ->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey()) ->where('baseline_operation_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey()) ->where('current_operation_run_id', $current->getKey())
->where('status', Finding::STATUS_NEW) ->where('status', Finding::STATUS_NEW)
->count(); ->count();
@ -189,8 +203,8 @@ public function mount(): void
$selection = app(BulkSelectionIdentity::class); $selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromQuery([ $selectionIdentity = $selection->fromQuery([
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(), 'baseline_operation_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(), 'current_operation_run_id' => (int) $current->getKey(),
]); ]);
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
@ -198,7 +212,7 @@ public function mount(): void
$opRun = $opService->enqueueBulkOperation( $opRun = $opService->enqueueBulkOperation(
tenant: $tenant, tenant: $tenant,
type: 'drift.generate', type: 'drift_generate_findings',
targetScope: [ targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
], ],
@ -216,8 +230,8 @@ public function mount(): void
initiator: $user, initiator: $user,
extraContext: [ extraContext: [
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(), 'baseline_operation_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(), 'current_operation_run_id' => (int) $current->getKey(),
], ],
emitQueuedNotification: false, emitQueuedNotification: false,
); );
@ -261,7 +275,7 @@ public function getBaselineRunUrl(): ?string
return null; return null;
} }
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current()); return route('admin.operations.view', ['run' => $this->baselineRunId]);
} }
public function getCurrentRunUrl(): ?string public function getCurrentRunUrl(): ?string
@ -270,7 +284,7 @@ public function getCurrentRunUrl(): ?string
return null; return null;
} }
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current()); return route('admin.operations.view', ['run' => $this->currentRunId]);
} }
public function getOperationRunUrl(): ?string public function getOperationRunUrl(): ?string

View File

@ -1707,7 +1707,7 @@ private function dispatchBootstrapJob(
OperationRun $run, OperationRun $run,
): void { ): void {
match ($operationType) { match ($operationType) {
'inventory.sync' => ProviderInventorySyncJob::dispatch( 'inventory_sync' => ProviderInventorySyncJob::dispatch(
tenantId: $tenantId, tenantId: $tenantId,
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
@ -1726,7 +1726,7 @@ private function dispatchBootstrapJob(
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return match ($operationType) {
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, 'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, 'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
default => null, default => null,
}; };

View File

@ -5,7 +5,6 @@
use App\Exceptions\InvalidPolicyTypeException; use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages; use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
use App\Jobs\RunBackupScheduleJob; use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\Tenant; use App\Models\Tenant;
@ -22,6 +21,7 @@
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -282,32 +282,40 @@ public static function table(Table $table): Table
->label('Last run status') ->label('Last run status')
->badge() ->badge()
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return '—'; return '—';
} }
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->label;
}) })
->color(function (?string $state): string { ->color(function (?string $state): string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return 'gray'; return 'gray';
} }
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->color;
}) })
->icon(function (?string $state): ?string { ->icon(function (?string $state): ?string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return null; return null;
} }
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->icon;
}) })
->iconColor(function (?string $state): string { ->iconColor(function (?string $state): string {
if (! filled($state)) { $outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return 'gray'; return 'gray';
} }
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state); $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}), }),
@ -389,7 +397,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.run_now', type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -458,7 +466,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.retry', type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -552,7 +560,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.run_now', type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -649,7 +657,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.retry', type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -734,7 +742,6 @@ public static function getRelations(): array
{ {
return [ return [
BackupScheduleOperationRunsRelationManager::class, BackupScheduleOperationRunsRelationManager::class,
BackupScheduleRunsRelationManager::class,
]; ];
} }
@ -904,6 +911,18 @@ protected static function policyTypeLabelMap(): array
->all(); ->all();
} }
protected static function scheduleStatusToOutcome(?string $status): ?string
{
return match (strtolower(trim((string) $status))) {
'running' => OperationRunOutcome::Pending->value,
'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
'skipped' => OperationRunOutcome::Blocked->value,
'failed', 'canceled' => OperationRunOutcome::Failed->value,
default => null,
};
}
protected static function dayOfWeekOptions(): array protected static function dayOfWeekOptions(): array
{ {
return [ return [

View File

@ -1,107 +0,0 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleRunsRelationManager extends RelationManager
{
protected static string $relationship = 'runs';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')
->label('Scheduled for')
->dateTime(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
Tables\Columns\TextColumn::make('duration')
->label('Duration')
->getStateUsing(function (BackupScheduleRun $record): string {
if (! $record->started_at || ! $record->finished_at) {
return '—';
}
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
if ($seconds < 60) {
return $seconds.'s';
}
$minutes = intdiv($seconds, 60);
$rem = $seconds % 60;
return sprintf('%dm %ds', $minutes, $rem);
}),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (BackupScheduleRun $record): string {
$summary = is_array($record->summary) ? $record->summary : [];
$total = (int) ($summary['policies_total'] ?? 0);
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
$errors = (int) ($summary['errors_count'] ?? 0);
if ($total === 0 && $backedUp === 0 && $errors === 0) {
return '—';
}
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
}),
Tables\Columns\TextColumn::make('error_code')
->label('Error')
->badge()
->default('—'),
Tables\Columns\TextColumn::make('error_message')
->label('Message')
->default('—')
->limit(80)
->wrap(),
Tables\Columns\TextColumn::make('backup_set_id')
->label('Backup set')
->default('—')
->url(function (BackupScheduleRun $record): ?string {
if (! $record->backup_set_id) {
return null;
}
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
})
->openUrlInNewTab(true),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->modalHeading('View backup schedule run')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (BackupScheduleRun $record): View {
return view('filament.modals.backup-schedule-run-view', [
'run' => $record,
]);
}),
])
->bulkActions([]);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources\EntraGroupResource\Pages; namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob; use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -23,10 +22,10 @@ class ListEntraGroups extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Action::make('view_group_sync_runs') Action::make('view_operations')
->label('Group Sync Runs') ->label('Operations')
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) ->url(fn (): string => OperationRunLinks::index(Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()), ->visible(fn (): bool => (bool) Tenant::current()),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('sync_groups') Action::make('sync_groups')
@ -48,7 +47,7 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'directory_groups.sync', type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey], identityInputs: ['selection_key' => $selectionKey],
context: [ context: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,

View File

@ -1,168 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
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 EntraGroupSyncRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
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';
protected static ?string $navigationLabel = 'Group Sync Runs';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
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')
->label('Initiator')
->placeholder('—'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
TextEntry::make('selection_key')->label('Selection'),
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(),
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('error_count')->label('Errors')->numeric(),
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Error Summary')
->schema([
TextEntry::make('error_code')->placeholder('—'),
TextEntry::make('error_category')->placeholder('—'),
ViewEntry::make('error_summary')
->label('Safe error summary')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', '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('initiator.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
Tables\Columns\TextColumn::make('selection_key')
->label('Selection')
->limit(24)
->copyable(),
Tables\Columns\TextColumn::make('slot_key')
->label('Slot')
->placeholder('—')
->limit(16)
->copyable(),
Tables\Columns\TextColumn::make('started_at')->since(),
Tables\Columns\TextColumn::make('finished_at')->since(),
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
])
->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('initiator')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
];
}
}

View File

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

View File

@ -1,24 +0,0 @@
<?php
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Models\EntraGroupSyncRun;
use App\Support\OperationRunLinks;
use Filament\Resources\Pages\ViewRecord;
class ViewEntraGroupSyncRun extends ViewRecord
{
protected static string $resource = EntraGroupSyncRunResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$legacyRun = $this->getRecord();
if ($legacyRun instanceof EntraGroupSyncRun && is_numeric($legacyRun->operation_run_id)) {
$this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id));
}
}
}

View File

@ -117,16 +117,16 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
TextEntry::make('subject_type')->label('Subject type'), TextEntry::make('subject_type')->label('Subject type'),
TextEntry::make('subject_external_id')->label('External ID')->copyable(), TextEntry::make('subject_external_id')->label('External ID')->copyable(),
TextEntry::make('baseline_run_id') TextEntry::make('baseline_operation_run_id')
->label('Baseline run') ->label('Baseline run')
->url(fn (Finding $record): ?string => $record->baseline_run_id ->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current()) ? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
: null) : null)
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('current_run_id') TextEntry::make('current_operation_run_id')
->label('Current run') ->label('Current run')
->url(fn (Finding $record): ?string => $record->current_run_id ->url(fn (Finding $record): ?string => $record->current_operation_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current()) ? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
: null) : null)
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'), TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
@ -297,22 +297,22 @@ public static function table(Table $table): Table
Tables\Filters\Filter::make('run_ids') Tables\Filters\Filter::make('run_ids')
->label('Run IDs') ->label('Run IDs')
->form([ ->form([
TextInput::make('baseline_run_id') TextInput::make('baseline_operation_run_id')
->label('Baseline run id') ->label('Baseline run id')
->numeric(), ->numeric(),
TextInput::make('current_run_id') TextInput::make('current_operation_run_id')
->label('Current run id') ->label('Current run id')
->numeric(), ->numeric(),
]) ])
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
$baselineRunId = $data['baseline_run_id'] ?? null; $baselineRunId = $data['baseline_operation_run_id'] ?? null;
if (is_numeric($baselineRunId)) { if (is_numeric($baselineRunId)) {
$query->where('baseline_run_id', (int) $baselineRunId); $query->where('baseline_operation_run_id', (int) $baselineRunId);
} }
$currentRunId = $data['current_run_id'] ?? null; $currentRunId = $data['current_operation_run_id'] ?? null;
if (is_numeric($currentRunId)) { if (is_numeric($currentRunId)) {
$query->where('current_run_id', (int) $currentRunId); $query->where('current_operation_run_id', (int) $currentRunId);
} }
return $query; return $query;

View File

@ -113,14 +113,14 @@ protected function buildAllMatchingQuery(): Builder
} }
$runIdsState = $this->getTableFilterState('run_ids') ?? []; $runIdsState = $this->getTableFilterState('run_ids') ?? [];
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id'); $baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
if (is_numeric($baselineRunId)) { if (is_numeric($baselineRunId)) {
$query->where('baseline_run_id', (int) $baselineRunId); $query->where('baseline_operation_run_id', (int) $baselineRunId);
} }
$currentRunId = Arr::get($runIdsState, 'current_run_id'); $currentRunId = Arr::get($runIdsState, 'current_operation_run_id');
if (is_numeric($currentRunId)) { if (is_numeric($currentRunId)) {
$query->where('current_run_id', (int) $currentRunId); $query->where('current_operation_run_id', (int) $currentRunId);
} }
return $query; return $query;

View File

@ -138,17 +138,6 @@ public static function infolist(Schema $schema): Schema
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
}) })
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('last_seen_run_id')
->label('Last inventory sync (legacy)')
->visible(fn (InventoryItem $record): bool => blank($record->last_seen_operation_run_id) && filled($record->last_seen_run_id))
->url(function (InventoryItem $record): ?string {
if (! $record->last_seen_run_id) {
return null;
}
return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current());
})
->openUrlInNewTab(),
TextEntry::make('support_restore') TextEntry::make('support_restore')
->label('Restore') ->label('Restore')
->badge() ->badge()
@ -247,7 +236,7 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('last_seen_at') Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen') ->label('Last seen')
->since(), ->since(),
Tables\Columns\TextColumn::make('lastSeenRun.status') Tables\Columns\TextColumn::make('lastSeenRun.outcome')
->label('Run') ->label('Run')
->badge() ->badge()
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
@ -255,28 +244,28 @@ public static function table(Table $table): Table
return '—'; return '—';
} }
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->label;
}) })
->color(function (?string $state): string { ->color(function (?string $state): string {
if (! filled($state)) { if (! filled($state)) {
return 'gray'; return 'gray';
} }
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->color;
}) })
->icon(function (?string $state): ?string { ->icon(function (?string $state): ?string {
if (! filled($state)) { if (! filled($state)) {
return null; return null;
} }
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon; return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->icon;
}) })
->iconColor(function (?string $state): ?string { ->iconColor(function (?string $state): ?string {
if (! filled($state)) { if (! filled($state)) {
return 'gray'; return 'gray';
} }
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state); $spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}), }),

View File

@ -153,12 +153,15 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'inventory.sync', type: 'inventory_sync',
identityInputs: [ identityInputs: [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
], ],
context: array_merge($computed['selection'], [ context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
]), ]),
initiator: $user, initiator: $user,
); );

View File

@ -1,230 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
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 Illuminate\Database\Eloquent\Model;
use UnitEnum;
class InventorySyncRunResource extends Resource
{
protected static ?string $model = InventorySyncRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $shouldRegisterNavigation = true;
protected static ?string $cluster = InventoryCluster::class;
protected static ?int $navigationSort = 2;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
return false;
}
if ($record instanceof InventorySyncRun) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function getNavigationLabel(): string
{
return 'Sync History';
}
public static function form(Schema $schema): Schema
{
return $schema;
}
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')
->label('Initiator')
->placeholder('—'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('errors_count')->label('Errors')->numeric(),
TextEntry::make('had_errors')
->label('Had errors')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors))
->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)),
])
->columns(2)
->columnSpanFull(),
Section::make('Selection Payload')
->schema([
ViewEntry::make('selection_payload')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->selection_payload ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Error Summary')
->schema([
ViewEntry::make('error_codes')
->label('Error codes')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->error_codes ?? [])
->columnSpanFull(),
ViewEntry::make('error_context')
->label('Safe error context')
->view('filament.infolists.entries.snapshot-json')
->state(fn (InventorySyncRun $record) => $record->error_context ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
Tables\Columns\TextColumn::make('selection_hash')
->label('Selection')
->copyable()
->limit(12),
Tables\Columns\TextColumn::make('started_at')->since(),
Tables\Columns\TextColumn::make('finished_at')->since(),
Tables\Columns\TextColumn::make('items_observed_count')
->label('Observed')
->numeric(),
Tables\Columns\TextColumn::make('items_upserted_count')
->label('Upserted')
->numeric(),
Tables\Columns\TextColumn::make('errors_count')
->label('Errors')
->numeric(),
])
->recordUrl(static fn (Model $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->with('user')
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
public static function getPages(): array
{
return [
'index' => Pages\ListInventorySyncRuns::route('/'),
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
];
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use Filament\Resources\Pages\ListRecords;
class ListInventorySyncRuns extends ListRecords
{
protected static string $resource = InventorySyncRunResource::class;
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventorySyncRun;
use App\Support\OperationRunLinks;
use Filament\Resources\Pages\ViewRecord;
class ViewInventorySyncRun extends ViewRecord
{
protected static string $resource = InventorySyncRunResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$legacyRun = $this->getRecord();
if ($legacyRun instanceof InventorySyncRun && is_numeric($legacyRun->operation_run_id)) {
$this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id));
}
}
}

View File

@ -370,7 +370,7 @@ public static function table(Table $table): Table
$result = $gate->start( $result = $gate->start(
tenant: $tenant, tenant: $tenant,
connection: $record, connection: $record,
operationType: 'inventory.sync', operationType: 'inventory_sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch( ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),

View File

@ -423,7 +423,7 @@ protected function getHeaderActions(): array
$result = $gate->start( $result = $gate->start(
tenant: $tenant, tenant: $tenant,
connection: $record, connection: $record,
operationType: 'inventory.sync', operationType: 'inventory_sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void { dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch( ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),

View File

@ -68,7 +68,7 @@ protected function getStats(): array
$inventoryActiveRuns = (int) OperationRun::query() $inventoryActiveRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('type', 'inventory.sync') ->where('type', 'inventory_sync')
->active() ->active()
->count(); ->count();

View File

@ -58,7 +58,7 @@ protected function getViewData(): array
$latestDriftSuccess = OperationRun::query() $latestDriftSuccess = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('type', 'drift.generate') ->where('type', 'drift_generate_findings')
->where('status', 'completed') ->where('status', 'completed')
->where('outcome', 'succeeded') ->where('outcome', 'succeeded')
->whereNotNull('completed_at') ->whereNotNull('completed_at')
@ -89,7 +89,7 @@ protected function getViewData(): array
$latestDriftFailure = OperationRun::query() $latestDriftFailure = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('type', 'drift.generate') ->where('type', 'drift_generate_findings')
->where('status', 'completed') ->where('status', 'completed')
->where('outcome', 'failed') ->where('outcome', 'failed')
->latest('id') ->latest('id')

View File

@ -4,15 +4,16 @@
namespace App\Filament\Widgets\Inventory; namespace App\Filament\Widgets\Inventory;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver; use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Inventory\InventorySyncStatusBadge; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
@ -80,8 +81,12 @@ protected function getStats(): array
? (int) round(($restorableItems / $totalItems) * 100) ? (int) round(($restorableItems / $totalItems) * 100)
: 0; : 0;
$lastRun = InventorySyncRun::query() $lastRun = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
->latest('id') ->latest('id')
->first(); ->first();
@ -91,21 +96,20 @@ protected function getStats(): array
$lastInventorySyncStatusIcon = 'heroicon-m-clock'; $lastInventorySyncStatusIcon = 'heroicon-m-clock';
$lastInventorySyncViewUrl = null; $lastInventorySyncViewUrl = null;
if ($lastRun instanceof InventorySyncRun) { if ($lastRun instanceof OperationRun) {
$timestamp = $lastRun->finished_at ?? $lastRun->started_at; $timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
if ($timestamp) { if ($timestamp) {
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]); $lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
} }
$status = (string) ($lastRun->status ?? ''); $outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
$lastInventorySyncStatusLabel = $badge->label;
$lastInventorySyncStatusColor = $badge->color;
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
$badge = InventorySyncStatusBadge::for($status); $lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
$lastInventorySyncStatusLabel = $badge['label'];
$lastInventorySyncStatusColor = $badge['color'];
$lastInventorySyncStatusIcon = $badge['icon'];
$lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant);
} }
$badgeColor = $lastInventorySyncStatusColor; $badgeColor = $lastInventorySyncStatusColor;
@ -135,7 +139,7 @@ protected function getStats(): array
$inventoryOps = (int) OperationRun::query() $inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('type', 'inventory.sync') ->where('type', 'inventory_sync')
->active() ->active()
->count(); ->count();

View File

@ -3,12 +3,15 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ApplyBackupScheduleRetentionJob implements ShouldQueue class ApplyBackupScheduleRetentionJob implements ShouldQueue
{ {
@ -26,6 +29,21 @@ public function handle(AuditLogger $auditLogger): void
return; return;
} }
$operationRun = OperationRun::query()->create([
'workspace_id' => (int) $schedule->tenant->workspace_id,
'tenant_id' => (int) $schedule->tenant_id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_retention',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
'context' => [
'backup_schedule_id' => (int) $schedule->id,
],
'started_at' => now(),
]);
$keepLast = (int) ($schedule->retention_keep_last ?? 30); $keepLast = (int) ($schedule->retention_keep_last ?? 30);
if ($keepLast < 1) { if ($keepLast < 1) {
@ -33,55 +51,65 @@ public function handle(AuditLogger $auditLogger): void
} }
/** @var Collection<int, int> $keepBackupSetIds */ /** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = BackupScheduleRun::query() $keepBackupSetIds = OperationRun::query()
->where('backup_schedule_id', $schedule->id) ->where('tenant_id', (int) $schedule->tenant_id)
->whereNotNull('backup_set_id') ->where('type', 'backup_schedule_run')
->orderByDesc('scheduled_for') ->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id')
->orderByDesc('completed_at')
->orderByDesc('id')
->limit($keepLast) ->limit($keepLast)
->pluck('backup_set_id') ->get()
->filter() ->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null)
->filter(fn (?int $id): bool => is_int($id) && $id > 0)
->values(); ->values();
/** @var Collection<int, int> $deleteBackupSetIds */ /** @var Collection<int, int> $allBackupSetIds */
$deleteBackupSetIds = BackupScheduleRun::query() $allBackupSetIds = OperationRun::query()
->where('backup_schedule_id', $schedule->id) ->where('tenant_id', (int) $schedule->tenant_id)
->whereNotNull('backup_set_id') ->where('type', 'backup_schedule_run')
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all())) ->where('status', OperationRunStatus::Completed->value)
->pluck('backup_set_id') ->where('context->backup_schedule_id', (int) $schedule->id)
->filter() ->whereNotNull('context->backup_set_id')
->get()
->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null)
->filter(fn (?int $id): bool => is_int($id) && $id > 0)
->unique() ->unique()
->values(); ->values();
if ($deleteBackupSetIds->isEmpty()) { /** @var Collection<int, int> $deleteBackupSetIds */
$auditLogger->log( $deleteBackupSetIds = $allBackupSetIds
tenant: $schedule->tenant, ->reject(fn (int $backupSetId): bool => $keepBackupSetIds->contains($backupSetId))
action: 'backup_schedule.retention_applied', ->values();
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => 0,
],
],
);
return;
}
$deletedCount = 0; $deletedCount = 0;
BackupSet::query() if ($deleteBackupSetIds->isNotEmpty()) {
->where('tenant_id', $schedule->tenant_id) BackupSet::query()
->whereIn('id', $deleteBackupSetIds->all()) ->where('tenant_id', $schedule->tenant_id)
->whereNull('deleted_at') ->whereIn('id', $deleteBackupSetIds->all())
->chunkById(200, function (Collection $sets) use (&$deletedCount): void { ->whereNull('deleted_at')
foreach ($sets as $set) { ->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
$set->delete(); foreach ($sets as $set) {
$deletedCount++; $set->delete();
} $deletedCount++;
}); }
});
}
$operationRun->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'summary_counts' => [
'total' => (int) $deleteBackupSetIds->count(),
'processed' => (int) $deleteBackupSetIds->count(),
'succeeded' => $deletedCount,
'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount),
'updated' => $deletedCount,
],
'completed_at' => now(),
]);
$auditLogger->log( $auditLogger->log(
tenant: $schedule->tenant, tenant: $schedule->tenant,
@ -93,6 +121,7 @@ public function handle(AuditLogger $auditLogger): void
'metadata' => [ 'metadata' => [
'keep_last' => $keepLast, 'keep_last' => $keepLast,
'deleted_backup_sets' => $deletedCount, 'deleted_backup_sets' => $deletedCount,
'operation_run_id' => (int) $operationRun->getKey(),
], ],
], ],
); );

View File

@ -3,13 +3,12 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Directory\EntraGroupSyncService; use App\Services\Directory\EntraGroupSyncService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use Carbon\CarbonImmutable; use App\Support\OperationRunOutcome;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -51,81 +50,40 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
throw new RuntimeException('Tenant not found.'); throw new RuntimeException('Tenant not found.');
} }
$legacyRun = $this->resolveLegacyRun($tenant);
if ($legacyRun instanceof EntraGroupSyncRun) {
if ($legacyRun->status !== EntraGroupSyncRun::STATUS_PENDING) {
return;
}
$legacyRun->update([
'status' => EntraGroupSyncRun::STATUS_RUNNING,
'started_at' => CarbonImmutable::now('UTC'),
]);
$auditLogger->log(
tenant: $tenant,
action: 'directory_groups.sync.started',
context: [
'selection_key' => $legacyRun->selection_key,
'run_id' => $legacyRun->getKey(),
'slot_key' => $legacyRun->slot_key,
],
actorId: $legacyRun->initiator_user_id,
status: 'success',
resourceType: 'entra_group_sync_run',
resourceId: (string) $legacyRun->getKey(),
);
} else {
$auditLogger->log(
tenant: $tenant,
action: 'directory_groups.sync.started',
context: [
'selection_key' => $this->selectionKey,
'slot_key' => $this->slotKey,
],
actorId: $this->operationRun->user_id,
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
$result = $syncService->sync($tenant, $this->selectionKey);
$terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED;
if ($result['error_code'] !== null) {
$terminalStatus = EntraGroupSyncRun::STATUS_FAILED;
} elseif ($result['safety_stop_triggered'] === true) {
$terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL;
}
if ($legacyRun instanceof EntraGroupSyncRun) {
$legacyRun->update([
'status' => $terminalStatus,
'pages_fetched' => $result['pages_fetched'],
'items_observed_count' => $result['items_observed_count'],
'items_upserted_count' => $result['items_upserted_count'],
'error_count' => $result['error_count'],
'safety_stop_triggered' => $result['safety_stop_triggered'],
'safety_stop_reason' => $result['safety_stop_reason'],
'error_code' => $result['error_code'],
'error_category' => $result['error_category'],
'error_summary' => $result['error_summary'],
'finished_at' => CarbonImmutable::now('UTC'),
]);
}
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
if ($this->operationRun->status === 'queued') {
$opService->updateRun($this->operationRun, 'running');
}
$auditLogger->log(
tenant: $tenant,
action: 'directory_groups.sync.started',
context: [
'selection_key' => $this->selectionKey,
'slot_key' => $this->slotKey,
],
actorId: $this->operationRun->user_id,
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
$result = $syncService->sync($tenant, $this->selectionKey);
$terminalStatus = 'succeeded';
if ($result['error_code'] !== null) {
$terminalStatus = 'failed';
} elseif ($result['safety_stop_triggered'] === true) {
$terminalStatus = 'partial';
}
$opOutcome = match ($terminalStatus) { $opOutcome = match ($terminalStatus) {
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded', 'succeeded' => OperationRunOutcome::Succeeded->value,
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded', 'partial' => OperationRunOutcome::PartiallySucceeded->value,
EntraGroupSyncRun::STATUS_FAILED => 'failed', default => OperationRunOutcome::Failed->value,
default => 'failed',
}; };
$failures = []; $failures = [];
@ -141,48 +99,19 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
'completed', 'completed',
$opOutcome, $opOutcome,
[ [
// NOTE: summary_counts are normalized to a fixed whitelist for Ops UX. 'total' => (int) $result['items_observed_count'],
// Keep keys aligned with App\Support\OpsUx\OperationSummaryKeys. 'processed' => (int) $result['items_observed_count'],
'total' => $result['items_observed_count'], 'updated' => (int) $result['items_upserted_count'],
'processed' => $result['items_observed_count'], 'failed' => (int) $result['error_count'],
'updated' => $result['items_upserted_count'],
'failed' => $result['error_count'],
], ],
$failures, $failures,
); );
if ($legacyRun instanceof EntraGroupSyncRun) {
$auditLogger->log(
tenant: $tenant,
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
? 'directory_groups.sync.succeeded'
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
? 'directory_groups.sync.partial'
: 'directory_groups.sync.failed'),
context: [
'selection_key' => $legacyRun->selection_key,
'run_id' => $legacyRun->getKey(),
'slot_key' => $legacyRun->slot_key,
'pages_fetched' => $legacyRun->pages_fetched,
'items_observed_count' => $legacyRun->items_observed_count,
'items_upserted_count' => $legacyRun->items_upserted_count,
'error_code' => $legacyRun->error_code,
'error_category' => $legacyRun->error_category,
],
actorId: $legacyRun->initiator_user_id,
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
resourceType: 'entra_group_sync_run',
resourceId: (string) $legacyRun->getKey(),
);
return;
}
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED action: $terminalStatus === 'succeeded'
? 'directory_groups.sync.succeeded' ? 'directory_groups.sync.succeeded'
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL : ($terminalStatus === 'partial'
? 'directory_groups.sync.partial' ? 'directory_groups.sync.partial'
: 'directory_groups.sync.failed'), : 'directory_groups.sync.failed'),
context: [ context: [
@ -195,41 +124,9 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
'error_category' => $result['error_category'], 'error_category' => $result['error_category'],
], ],
actorId: $this->operationRun->user_id, actorId: $this->operationRun->user_id,
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', status: $terminalStatus === 'failed' ? 'failed' : 'success',
resourceType: 'operation_run', resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
} }
private function resolveLegacyRun(Tenant $tenant): ?EntraGroupSyncRun
{
if ($this->runId !== null) {
$run = EntraGroupSyncRun::query()
->whereKey($this->runId)
->where('tenant_id', $tenant->getKey())
->first();
if ($run instanceof EntraGroupSyncRun) {
return $run;
}
return null;
}
if ($this->slotKey !== null) {
$run = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $this->selectionKey)
->where('slot_key', $this->slotKey)
->first();
if ($run instanceof EntraGroupSyncRun) {
return $run;
}
return null;
}
return null;
}
} }

View File

@ -2,7 +2,6 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Drift\DriftFindingGenerator; use App\Services\Drift\DriftFindingGenerator;
@ -45,8 +44,8 @@ public function handle(
): void { ): void {
Log::info('GenerateDriftFindingsJob: started', [ Log::info('GenerateDriftFindingsJob: started', [
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId, 'baseline_operation_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId, 'current_operation_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey, 'scope_key' => $this->scopeKey,
]); ]);
@ -78,13 +77,21 @@ public function handle(
throw new RuntimeException('Tenant not found.'); throw new RuntimeException('Tenant not found.');
} }
$baseline = InventorySyncRun::query()->find($this->baselineRunId); $baseline = OperationRun::query()
if (! $baseline instanceof InventorySyncRun) { ->whereKey($this->baselineRunId)
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
->first();
if (! $baseline instanceof OperationRun) {
throw new RuntimeException('Baseline run not found.'); throw new RuntimeException('Baseline run not found.');
} }
$current = InventorySyncRun::query()->find($this->currentRunId); $current = OperationRun::query()
if (! $current instanceof InventorySyncRun) { ->whereKey($this->currentRunId)
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
->first();
if (! $current instanceof OperationRun) {
throw new RuntimeException('Current run not found.'); throw new RuntimeException('Current run not found.');
} }
@ -104,8 +111,8 @@ public function handle(
Log::info('GenerateDriftFindingsJob: completed', [ Log::info('GenerateDriftFindingsJob: completed', [
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId, 'baseline_operation_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId, 'current_operation_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey, 'scope_key' => $this->scopeKey,
'created_findings_count' => $created, 'created_findings_count' => $created,
]); ]);
@ -120,8 +127,8 @@ public function handle(
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('GenerateDriftFindingsJob: failed', [ Log::error('GenerateDriftFindingsJob: failed', [
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId, 'baseline_operation_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId, 'current_operation_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey, 'scope_key' => $this->scopeKey,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
@ -132,7 +139,7 @@ public function handle(
]); ]);
$runs->appendFailures($this->operationRun, [[ $runs->appendFailures($this->operationRun, [[
'code' => 'drift.generate.failed', 'code' => 'drift_generate_findings.failed',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
]]); ]]);

View File

@ -4,9 +4,9 @@
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\BackupScheduling\ScheduleTimeService;
@ -15,6 +15,7 @@
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -23,15 +24,23 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class RunBackupScheduleJob implements ShouldQueue class RunBackupScheduleJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const string STATUS_RUNNING = 'running';
private const string STATUS_SUCCESS = 'success';
private const string STATUS_PARTIAL = 'partial';
private const string STATUS_FAILED = 'failed';
private const string STATUS_SKIPPED = 'skipped';
public int $tries = 3; public int $tries = 3;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
@ -63,317 +72,19 @@ public function handle(
return; return;
} }
if ($this->backupScheduleId !== null) { $backupScheduleId = $this->resolveBackupScheduleId();
$this->handleFromScheduleId(
backupScheduleId: $this->backupScheduleId, if ($backupScheduleId <= 0) {
policySyncService: $policySyncService, $this->markOperationRunFailed(
backupService: $backupService, run: $this->operationRun,
policyTypeResolver: $policyTypeResolver, summaryCounts: [],
scheduleTimeService: $scheduleTimeService, reasonCode: 'schedule_not_provided',
auditLogger: $auditLogger, reason: 'No backup schedule was provided for this run.',
errorMapper: $errorMapper,
); );
return; return;
} }
$run = BackupScheduleRun::query()
->with(['schedule', 'tenant', 'user'])
->find($this->backupScheduleRunId);
if (! $run) {
if ($this->operationRun) {
$this->markOperationRunFailed(
run: $this->operationRun,
summaryCounts: [],
reasonCode: 'run_not_found',
reason: 'Backup schedule run not found.',
);
}
return;
}
$tenant = $run->tenant;
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');
}
}
$schedule = $run->schedule;
if (! $schedule instanceof BackupSchedule) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
'error_message' => 'Schedule not found.',
'finished_at' => CarbonImmutable::now('UTC'),
]);
if ($this->operationRun) {
$this->markOperationRunFailed(
run: $this->operationRun,
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
],
reasonCode: 'schedule_not_found',
reason: 'Schedule not found.',
);
}
return;
}
if (! $tenant) {
$run->update([
'status' => BackupScheduleRun::STATUS_FAILED,
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
'error_message' => 'Tenant not found.',
'finished_at' => CarbonImmutable::now('UTC'),
]);
if ($this->operationRun) {
$this->markOperationRunFailed(
run: $this->operationRun,
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
],
reasonCode: 'tenant_not_found',
reason: 'Tenant not found.',
);
}
return;
}
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
if (! $lock->get()) {
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorCode: 'CONCURRENT_RUN',
errorMessage: 'Another run is already in progress for this schedule.',
summary: ['reason' => 'concurrent_run'],
scheduleTimeService: $scheduleTimeService,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
);
return;
}
try {
$nowUtc = CarbonImmutable::now('UTC');
$run->forceFill([
'started_at' => $run->started_at ?? $nowUtc,
'status' => BackupScheduleRun::STATUS_RUNNING,
])->save();
$this->notifyRunStarted($run, $schedule);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_started',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $run->scheduled_for?->toDateTimeString(),
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'success'
);
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
$validTypes = $runtime['valid'];
$unknownTypes = $runtime['unknown'];
if (empty($validTypes)) {
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorCode: 'UNKNOWN_POLICY_TYPE',
errorMessage: 'All configured policy types are unknown.',
summary: [
'unknown_policy_types' => $unknownTypes,
],
scheduleTimeService: $scheduleTimeService,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
);
return;
}
$supported = array_values(array_filter(
config('tenantpilot.supported_policy_types', []),
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
));
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
$policyIds = $syncReport['synced'] ?? [];
$syncFailures = $syncReport['failures'] ?? [];
$backupSet = $backupService->createBackupSet(
tenant: $tenant,
policyIds: $policyIds,
actorEmail: null,
actorName: null,
name: 'Scheduled backup: '.$schedule->name,
includeAssignments: false,
includeScopeTags: false,
includeFoundations: (bool) ($schedule->include_foundations ?? false),
);
$status = match ($backupSet->status) {
'completed' => BackupScheduleRun::STATUS_SUCCESS,
'partial' => BackupScheduleRun::STATUS_PARTIAL,
'failed' => BackupScheduleRun::STATUS_FAILED,
default => BackupScheduleRun::STATUS_SUCCESS,
};
$errorCode = null;
$errorMessage = null;
$summary = [
'policies_total' => count($policyIds),
'policies_backed_up' => (int) ($backupSet->item_count ?? 0),
'sync_failures' => $syncFailures,
];
if (! empty($unknownTypes)) {
$status = BackupScheduleRun::STATUS_PARTIAL;
$errorCode = 'UNKNOWN_POLICY_TYPE';
$errorMessage = 'Some configured policy types are unknown and were skipped.';
$summary['unknown_policy_types'] = $unknownTypes;
}
$this->finishRun(
run: $run,
schedule: $schedule,
status: $status,
errorCode: $errorCode,
errorMessage: $errorMessage,
summary: $summary,
scheduleTimeService: $scheduleTimeService,
backupSetId: (string) $backupSet->id,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_finished',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'status' => $status,
'error_code' => $errorCode,
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
);
} catch (\Throwable $throwable) {
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
$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;
}
$this->finishRun(
run: $run,
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
errorCode: $mapped['error_code'],
errorMessage: $mapped['error_message'],
summary: [
'exception' => get_class($throwable),
'attempt' => $attempt,
],
scheduleTimeService: $scheduleTimeService,
);
$this->syncOperationRunFromRun(
tenant: $tenant,
schedule: $schedule,
run: $run->refresh(),
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_failed',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'error_code' => $mapped['error_code'],
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
status: 'failed'
);
} finally {
optional($lock)->release();
}
}
private function handleFromScheduleId(
int $backupScheduleId,
PolicySyncService $policySyncService,
BackupService $backupService,
PolicyTypeResolver $policyTypeResolver,
ScheduleTimeService $scheduleTimeService,
AuditLogger $auditLogger,
RunErrorMapper $errorMapper,
): void {
$schedule = BackupSchedule::query() $schedule = BackupSchedule::query()
->with('tenant') ->with('tenant')
->find($backupScheduleId); ->find($backupScheduleId);
@ -402,15 +113,15 @@ private function handleFromScheduleId(
return; return;
} }
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$this->operationRun->update([ $this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [ 'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $schedule->getKey(), 'backup_schedule_id' => (int) $schedule->getKey(),
]), ]),
]); ]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
if ($this->operationRun->status === 'queued') { if ($this->operationRun->status === 'queued') {
$operationRunService->updateRun($this->operationRun, 'running'); $operationRunService->updateRun($this->operationRun, 'running');
} }
@ -422,7 +133,7 @@ private function handleFromScheduleId(
$this->finishSchedule( $this->finishSchedule(
schedule: $schedule, schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED, status: self::STATUS_SKIPPED,
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc, nowUtc: $nowUtc,
); );
@ -430,11 +141,12 @@ private function handleFromScheduleId(
$operationRunService->updateRun( $operationRunService->updateRun(
$this->operationRun, $this->operationRun,
status: 'completed', status: 'completed',
outcome: 'failed', outcome: OperationRunOutcome::Blocked->value,
summaryCounts: [ summaryCounts: [
'total' => 0, 'total' => 0,
'processed' => 0, 'processed' => 0,
'failed' => 1, 'failed' => 0,
'skipped' => 1,
], ],
failures: [ failures: [
[ [
@ -447,7 +159,7 @@ private function handleFromScheduleId(
$this->notifyScheduleRunFinished( $this->notifyScheduleRunFinished(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED, status: self::STATUS_SKIPPED,
errorMessage: 'Another run is already in progress for this schedule.', errorMessage: 'Another run is already in progress for this schedule.',
); );
@ -495,7 +207,7 @@ private function handleFromScheduleId(
if (empty($validTypes)) { if (empty($validTypes)) {
$this->finishSchedule( $this->finishSchedule(
schedule: $schedule, schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED, status: self::STATUS_SKIPPED,
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc, nowUtc: $nowUtc,
); );
@ -503,11 +215,12 @@ private function handleFromScheduleId(
$operationRunService->updateRun( $operationRunService->updateRun(
$this->operationRun, $this->operationRun,
status: 'completed', status: 'completed',
outcome: 'failed', outcome: OperationRunOutcome::Blocked->value,
summaryCounts: [ summaryCounts: [
'total' => 0, 'total' => 0,
'processed' => 0, 'processed' => 0,
'failed' => 1, 'failed' => 0,
'skipped' => 1,
], ],
failures: [ failures: [
[ [
@ -520,7 +233,7 @@ private function handleFromScheduleId(
$this->notifyScheduleRunFinished( $this->notifyScheduleRunFinished(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED, status: self::STATUS_SKIPPED,
errorMessage: 'All configured policy types are unknown.', errorMessage: 'All configured policy types are unknown.',
); );
@ -549,17 +262,17 @@ private function handleFromScheduleId(
); );
$status = match ($backupSet->status) { $status = match ($backupSet->status) {
'completed' => BackupScheduleRun::STATUS_SUCCESS, 'completed' => self::STATUS_SUCCESS,
'partial' => BackupScheduleRun::STATUS_PARTIAL, 'partial' => self::STATUS_PARTIAL,
'failed' => BackupScheduleRun::STATUS_FAILED, 'failed' => self::STATUS_FAILED,
default => BackupScheduleRun::STATUS_SUCCESS, default => self::STATUS_SUCCESS,
}; };
$errorCode = null; $errorCode = null;
$errorMessage = null; $errorMessage = null;
if (! empty($unknownTypes)) { if (! empty($unknownTypes)) {
$status = BackupScheduleRun::STATUS_PARTIAL; $status = self::STATUS_PARTIAL;
$errorCode = 'UNKNOWN_POLICY_TYPE'; $errorCode = 'UNKNOWN_POLICY_TYPE';
$errorMessage = 'Some configured policy types are unknown and were skipped.'; $errorMessage = 'Some configured policy types are unknown and were skipped.';
} }
@ -626,9 +339,10 @@ private function handleFromScheduleId(
]); ]);
$outcome = match ($status) { $outcome = match ($status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded', self::STATUS_SUCCESS => OperationRunOutcome::Succeeded->value,
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', self::STATUS_PARTIAL => OperationRunOutcome::PartiallySucceeded->value,
default => 'failed', self::STATUS_SKIPPED => OperationRunOutcome::Blocked->value,
default => OperationRunOutcome::Failed->value,
}; };
$operationRunService->updateRun( $operationRunService->updateRun(
@ -653,8 +367,8 @@ private function handleFromScheduleId(
errorMessage: $errorMessage, errorMessage: $errorMessage,
); );
if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey()));
} }
$auditLogger->log( $auditLogger->log(
@ -670,14 +384,14 @@ private function handleFromScheduleId(
], ],
resourceType: 'operation_run', resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' status: in_array($status, [self::STATUS_SUCCESS], true) ? 'success' : 'partial'
); );
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
$mapped = $errorMapper->map($throwable, $attempt, $this->tries); $mapped = $errorMapper->map($throwable, $attempt, $this->tries);
if ($mapped['shouldRetry']) { if ($mapped['shouldRetry']) {
$operationRunService->updateRun($this->operationRun, 'running', 'pending'); $operationRunService->updateRun($this->operationRun, 'running', OperationRunOutcome::Pending->value);
$this->release($mapped['delay']); $this->release($mapped['delay']);
@ -688,7 +402,7 @@ private function handleFromScheduleId(
$this->finishSchedule( $this->finishSchedule(
schedule: $schedule, schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED, status: self::STATUS_FAILED,
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc, nowUtc: $nowUtc,
); );
@ -696,7 +410,7 @@ private function handleFromScheduleId(
$operationRunService->updateRun( $operationRunService->updateRun(
$this->operationRun, $this->operationRun,
status: 'completed', status: 'completed',
outcome: 'failed', outcome: OperationRunOutcome::Failed->value,
summaryCounts: [ summaryCounts: [
'total' => 0, 'total' => 0,
'processed' => 0, 'processed' => 0,
@ -713,7 +427,7 @@ private function handleFromScheduleId(
$this->notifyScheduleRunFinished( $this->notifyScheduleRunFinished(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED, status: self::STATUS_FAILED,
errorMessage: (string) $mapped['error_message'], errorMessage: (string) $mapped['error_message'],
); );
@ -736,6 +450,17 @@ private function handleFromScheduleId(
} }
} }
private function resolveBackupScheduleId(): int
{
if ($this->backupScheduleId !== null && $this->backupScheduleId > 0) {
return $this->backupScheduleId;
}
$contextScheduleId = data_get($this->operationRun?->context, 'backup_schedule_id');
return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0;
}
private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void
{ {
$userId = $this->operationRun?->user_id; $userId = $this->operationRun?->user_id;
@ -744,9 +469,9 @@ private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedu
return; return;
} }
$user = \App\Models\User::query()->find($userId); $user = User::query()->find($userId);
if (! $user) { if (! $user instanceof User) {
return; return;
} }
@ -774,16 +499,16 @@ private function notifyScheduleRunFinished(
return; return;
} }
$user = \App\Models\User::query()->find($userId); $user = User::query()->find($userId);
if (! $user) { if (! $user instanceof User) {
return; return;
} }
$title = match ($status) { $title = match ($status) {
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed', self::STATUS_SUCCESS => 'Backup completed',
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)', self::STATUS_PARTIAL => 'Backup completed (partial)',
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped', self::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed', default => 'Backup failed',
}; };
@ -796,8 +521,8 @@ private function notifyScheduleRunFinished(
} }
match ($status) { match ($status) {
BackupScheduleRun::STATUS_SUCCESS => $notification->success(), self::STATUS_SUCCESS => $notification->success(),
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(), self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(), default => $notification->danger(),
}; };
@ -823,163 +548,6 @@ private function finishSchedule(
])->saveQuietly(); ])->saveQuietly();
} }
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
if (! $user) {
return;
}
$notification = Notification::make()
->title('Backup started')
->body(sprintf('Schedule "%s" has started.', $schedule->name))
->info()
->actions([
Action::make('view_run')
->label('View run')
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
]);
$notification->sendToDatabase($user);
}
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
if (! $user) {
return;
}
$title = match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed',
};
$notification = Notification::make()
->title($title)
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status));
if (filled($run->error_message)) {
$notification->body($notification->getBody()."\n".$run->error_message);
}
match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(),
};
$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,
): 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'] ?? [];
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
$syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0;
$failedCount = max(0, $policiesTotal - $policiesBackedUp);
$summaryCounts = [
'total' => $policiesTotal,
'processed' => $policiesTotal,
'succeeded' => $policiesBackedUp,
'failed' => $failedCount,
'skipped' => 0,
'created' => filled($run->backup_set_id) ? 1 : 0,
'updated' => $policiesBackedUp,
'items' => $policiesTotal,
];
$failures = [];
if (filled($run->error_message) || filled($run->error_code)) {
$failures[] = [
'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')),
'message' => (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_'.(string) $status : 'graph_error',
'message' => $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(),
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
]),
]);
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
}
private function markOperationRunFailed( private function markOperationRunFailed(
OperationRun $run, OperationRun $run,
array $summaryCounts, array $summaryCounts,
@ -992,7 +560,7 @@ private function markOperationRunFailed(
$operationRunService->updateRun( $operationRunService->updateRun(
$run, $run,
status: 'completed', status: 'completed',
outcome: 'failed', outcome: OperationRunOutcome::Failed->value,
summaryCounts: $summaryCounts, summaryCounts: $summaryCounts,
failures: [ failures: [
[ [
@ -1002,38 +570,4 @@ private function markOperationRunFailed(
], ],
); );
} }
private function finishRun(
BackupScheduleRun $run,
BackupSchedule $schedule,
string $status,
?string $errorCode,
?string $errorMessage,
array $summary,
ScheduleTimeService $scheduleTimeService,
?string $backupSetId = null,
): void {
$nowUtc = CarbonImmutable::now('UTC');
$run->forceFill([
'status' => $status,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'summary' => Arr::wrap($summary),
'finished_at' => $nowUtc,
'backup_set_id' => $backupSetId,
])->save();
$schedule->forceFill([
'last_run_at' => $nowUtc,
'last_run_status' => $status,
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
$this->notifyRunFinished($run, $schedule);
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
}
}
} }

View File

@ -11,6 +11,7 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -79,7 +80,6 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
// However, InventorySyncService execution logic might be complex with partial failures. // However, InventorySyncService execution logic might be complex with partial failures.
// We might want to explicitly update the OperationRun if partial failures occur. // We might want to explicitly update the OperationRun if partial failures occur.
$result = $inventorySyncService->executeSelection( $result = $inventorySyncService->executeSelection(
$this->operationRun, $this->operationRun,
$tenant, $tenant,
@ -97,10 +97,49 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
}, },
); );
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['result'] = [
'had_errors' => (bool) ($result['had_errors'] ?? true),
'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [],
'error_context' => is_array($result['error_context'] ?? null) ? $result['error_context'] : null,
];
$this->operationRun->update([
'context' => $updatedContext,
]);
$this->operationRun->refresh();
$status = (string) ($result['status'] ?? 'failed'); $status = (string) ($result['status'] ?? 'failed');
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : []; $errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : [];
$reason = (string) ($errorCodes[0] ?? $status); $reason = (string) ($errorCodes[0] ?? $status);
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : [];
$sanitizedErrorMessage = is_string($errorContext['message'] ?? null) ? (string) $errorContext['message'] : null;
$reasonCode = null;
$sanitizedMessageWithoutReasonCode = null;
if (is_string($sanitizedErrorMessage)) {
$sanitizedMessageWithoutReasonCode = preg_replace('/^\[[^\]]+\]\s*/', '', $sanitizedErrorMessage);
$sanitizedMessageWithoutReasonCode = is_string($sanitizedMessageWithoutReasonCode)
? trim($sanitizedMessageWithoutReasonCode)
: null;
if (preg_match('/^\[(?<code>[^\]]+)\]/', $sanitizedErrorMessage, $m)) {
$candidate = (string) ($m['code'] ?? '');
if ($candidate !== '' && ProviderReasonCodes::isKnown($candidate)) {
$reasonCode = $candidate;
}
}
}
if ($reason === 'unexpected_exception' && is_string($sanitizedErrorMessage) && $sanitizedErrorMessage !== '') {
$reason = is_string($sanitizedMessageWithoutReasonCode) && $sanitizedMessageWithoutReasonCode !== ''
? $sanitizedMessageWithoutReasonCode
: $sanitizedErrorMessage;
}
$itemsObserved = (int) ($result['items_observed_count'] ?? 0); $itemsObserved = (int) ($result['items_observed_count'] ?? 0);
$itemsUpserted = (int) ($result['items_upserted_count'] ?? 0); $itemsUpserted = (int) ($result['items_upserted_count'] ?? 0);
$errorsCount = (int) ($result['errors_count'] ?? 0); $errorsCount = (int) ($result['errors_count'] ?? 0);
@ -229,7 +268,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
'failed' => max($failedCount, count($missingPolicyTypes)), 'failed' => max($failedCount, count($missingPolicyTypes)),
], ],
failures: [ failures: [
['code' => 'inventory.failed', 'message' => $reason], ['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason],
], ],
); );

View File

@ -3,9 +3,9 @@
namespace App\Livewire; namespace App\Livewire;
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@ -151,9 +151,9 @@ public function table(Table $table): Table
->icon('heroicon-o-user-group') ->icon('heroicon-o-user-group')
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())), ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())),
Action::make('open_sync_runs') Action::make('open_sync_runs')
->label('Group Sync Runs') ->label('Operations')
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())), ->url(fn (): string => OperationRunLinks::index(Tenant::current())),
]); ]);
} }

View File

@ -27,18 +27,13 @@ public function tenant(): BelongsTo
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
} }
public function runs(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function operationRuns(): HasMany public function operationRuns(): HasMany
{ {
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id') return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
->whereIn('type', [ ->whereIn('type', [
'backup_schedule.run_now', 'backup_schedule_run',
'backup_schedule.retry', 'backup_schedule_retention',
'backup_schedule.scheduled', 'backup_schedule_purge',
]) ])
->where('context->backup_schedule_id', (int) $this->getKey()); ->where('context->backup_schedule_id', (int) $this->getKey());
} }

View File

@ -1,53 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BackupScheduleRun extends Model
{
use HasFactory;
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_PARTIAL = 'partial';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
public const STATUS_SKIPPED = 'skipped';
protected $guarded = [];
protected $casts = [
'scheduled_for' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'summary' => 'array',
];
public function schedule(): BelongsTo
{
return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function backupSet(): BelongsTo
{
return $this->belongsTo(BackupSet::class);
}
}

View File

@ -1,40 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EntraGroupSyncRun extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
public const STATUS_PARTIAL = 'partial';
protected $guarded = [];
protected $casts = [
'safety_stop_triggered' => 'boolean',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiator_user_id');
}
}

View File

@ -37,12 +37,12 @@ public function tenant(): BelongsTo
public function baselineRun(): BelongsTo public function baselineRun(): BelongsTo
{ {
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id'); return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id');
} }
public function currentRun(): BelongsTo public function currentRun(): BelongsTo
{ {
return $this->belongsTo(InventorySyncRun::class, 'current_run_id'); return $this->belongsTo(OperationRun::class, 'current_operation_run_id');
} }
public function acknowledgedByUser(): BelongsTo public function acknowledgedByUser(): BelongsTo

View File

@ -25,7 +25,7 @@ public function tenant(): BelongsTo
public function lastSeenRun(): BelongsTo public function lastSeenRun(): BelongsTo
{ {
return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id'); return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id');
} }
public function lastSeenOperationRun(): BelongsTo public function lastSeenOperationRun(): BelongsTo

View File

@ -1,59 +0,0 @@
<?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 InventorySyncRun extends Model
{
/** @use HasFactory<\Database\Factories\InventorySyncRunFactory> */
use HasFactory;
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_PARTIAL = 'partial';
public const STATUS_FAILED = 'failed';
public const STATUS_SKIPPED = 'skipped';
protected $guarded = [];
protected $casts = [
'selection_payload' => 'array',
'had_errors' => 'boolean',
'error_codes' => 'array',
'error_context' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
public const STATUS_PENDING = 'pending';
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeCompleted(Builder $query): Builder
{
return $query
->whereIn('status', [
self::STATUS_SUCCESS,
self::STATUS_PARTIAL,
self::STATUS_FAILED,
self::STATUS_SKIPPED,
])
->whereNotNull('finished_at');
}
}

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Arr;
class OperationRun extends Model class OperationRun extends Model
{ {
@ -65,4 +66,65 @@ public function scopeActive(Builder $query): Builder
{ {
return $query->whereIn('status', ['queued', 'running']); return $query->whereIn('status', ['queued', 'running']);
} }
public function getSelectionHashAttribute(): ?string
{
$context = is_array($this->context) ? $this->context : [];
return isset($context['selection_hash']) && is_string($context['selection_hash'])
? $context['selection_hash']
: null;
}
public function setSelectionHashAttribute(?string $value): void
{
$context = is_array($this->context) ? $this->context : [];
$context['selection_hash'] = $value;
$this->context = $context;
}
/**
* @return array<string, mixed>
*/
public function getSelectionPayloadAttribute(): array
{
$context = is_array($this->context) ? $this->context : [];
return Arr::only($context, [
'policy_types',
'categories',
'include_foundations',
'include_dependencies',
]);
}
/**
* @param array<string, mixed>|null $value
*/
public function setSelectionPayloadAttribute(?array $value): void
{
$context = is_array($this->context) ? $this->context : [];
if (is_array($value)) {
$context = array_merge($context, Arr::only($value, [
'policy_types',
'categories',
'include_foundations',
'include_dependencies',
]));
}
$this->context = $context;
}
public function getFinishedAtAttribute(): mixed
{
return $this->completed_at;
}
public function setFinishedAtAttribute(mixed $value): void
{
$this->completed_at = $value;
}
} }

View File

@ -240,11 +240,6 @@ public function backupSchedules(): HasMany
return $this->hasMany(BackupSchedule::class); return $this->hasMany(BackupSchedule::class);
} }
public function backupScheduleRuns(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function policyVersions(): HasMany public function policyVersions(): HasMany
{ {
return $this->hasMany(PolicyVersion::class); return $this->hasMany(PolicyVersion::class);
@ -260,11 +255,6 @@ public function entraGroups(): HasMany
return $this->hasMany(EntraGroup::class); return $this->hasMany(EntraGroup::class);
} }
public function entraGroupSyncRuns(): HasMany
{
return $this->hasMany(EntraGroupSyncRun::class);
}
public function auditLogs(): HasMany public function auditLogs(): HasMany
{ {
return $this->hasMany(AuditLog::class); return $this->hasMany(AuditLog::class);

View File

@ -2,7 +2,6 @@
namespace App\Notifications; namespace App\Notifications;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -61,16 +60,13 @@ public function toDatabase(object $notifiable): array
$actions = []; $actions = [];
if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) { if ($tenantId > 0 && $runId > 0) {
$tenant = Tenant::query()->find($tenantId); $tenant = Tenant::query()->find($tenantId);
if ($tenant) { if ($tenant) {
$url = match ($runType) { $url = $runType === 'restore'
'bulk_operation' => OperationRunLinks::view($runId, $tenant), ? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), : OperationRunLinks::view($runId, $tenant);
'directory_groups' => OperationRunLinks::view($runId, $tenant),
default => null,
};
if (! $url) { if (! $url) {
return [ return [

View File

@ -1,39 +0,0 @@
<?php
namespace App\Policies;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class EntraGroupSyncRunPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
return $user->canAccessTenant($tenant);
}
public function view(User $user, EntraGroupSyncRun $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

@ -4,7 +4,6 @@
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\EntraGroupSyncRun;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderCredential; use App\Models\ProviderCredential;
@ -16,7 +15,6 @@
use App\Observers\RestoreRunObserver; use App\Observers\RestoreRunObserver;
use App\Policies\BackupSchedulePolicy; use App\Policies\BackupSchedulePolicy;
use App\Policies\EntraGroupPolicy; use App\Policies\EntraGroupPolicy;
use App\Policies\EntraGroupSyncRunPolicy;
use App\Policies\FindingPolicy; use App\Policies\FindingPolicy;
use App\Policies\OperationRunPolicy; use App\Policies\OperationRunPolicy;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
@ -136,7 +134,6 @@ public function boot(): void
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
Gate::policy(Finding::class, FindingPolicy::class); Gate::policy(Finding::class, FindingPolicy::class);
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
Gate::policy(EntraGroup::class, EntraGroupPolicy::class); Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
Gate::policy(OperationRun::class, OperationRunPolicy::class); Gate::policy(OperationRun::class, OperationRunPolicy::class);
} }

View File

@ -67,7 +67,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
$operationRun = $this->operationRunService->ensureRunWithIdentityStrict( $operationRun = $this->operationRunService->ensureRunWithIdentityStrict(
tenant: $schedule->tenant, tenant: $schedule->tenant,
type: OperationRunType::BackupScheduleScheduled->value, type: OperationRunType::BackupScheduleExecute->value,
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => $scheduledFor->toDateTimeString(), 'scheduled_for' => $scheduledFor->toDateTimeString(),

View File

@ -30,7 +30,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'directory_groups.sync', type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey], identityInputs: ['selection_key' => $selectionKey],
context: [ context: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,

View File

@ -3,7 +3,7 @@
namespace App\Services\Drift; namespace App\Services\Drift;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventorySyncRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
@ -21,14 +21,14 @@ public function __construct(
private readonly ScopeTagsNormalizer $scopeTagsNormalizer, private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
) {} ) {}
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
{ {
if (! $baseline->finished_at || ! $current->finished_at) { if (! $baseline->completed_at || ! $current->completed_at) {
throw new RuntimeException('Baseline/current run must be finished.'); throw new RuntimeException('Baseline/current run must be finished.');
} }
/** @var array<string, mixed> $selection */ /** @var array<string, mixed> $selection */
$selection = is_array($current->selection_payload) ? $current->selection_payload : []; $selection = is_array($current->context) ? $current->context : [];
$policyTypes = Arr::get($selection, 'policy_types'); $policyTypes = Arr::get($selection, 'policy_types');
if (! is_array($policyTypes)) { if (! is_array($policyTypes)) {
@ -114,8 +114,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
$finding->forceFill([ $finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
'subject_external_id' => (string) $policy->external_id, 'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM, 'severity' => Finding::SEVERITY_MEDIUM,
@ -187,8 +187,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
$finding->forceFill([ $finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'assignment', 'subject_type' => 'assignment',
'subject_external_id' => (string) $policy->external_id, 'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM, 'severity' => Finding::SEVERITY_MEDIUM,
@ -262,8 +262,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
$finding->forceFill([ $finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'scope_tag', 'subject_type' => 'scope_tag',
'subject_external_id' => (string) $policy->external_id, 'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM, 'severity' => Finding::SEVERITY_MEDIUM,
@ -289,16 +289,16 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
return $created; return $created;
} }
private function versionForRun(Policy $policy, InventorySyncRun $run): ?PolicyVersion private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion
{ {
if (! $run->finished_at) { if (! $run->completed_at) {
return null; return null;
} }
return PolicyVersion::query() return PolicyVersion::query()
->where('tenant_id', $policy->tenant_id) ->where('tenant_id', $policy->tenant_id)
->where('policy_id', $policy->getKey()) ->where('policy_id', $policy->getKey())
->where('captured_at', '<=', $run->finished_at) ->where('captured_at', '<=', $run->completed_at)
->latest('captured_at') ->latest('captured_at')
->first(); ->first();
} }

View File

@ -2,22 +2,29 @@
namespace App\Services\Drift; namespace App\Services\Drift;
use App\Models\InventorySyncRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
class DriftRunSelector class DriftRunSelector
{ {
/** /**
* @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null * @return array{baseline:OperationRun,current:OperationRun}|null
*/ */
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
{ {
$runs = InventorySyncRun::query() $runs = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('selection_hash', $scopeKey) ->where('type', 'inventory_sync')
->where('status', InventorySyncRun::STATUS_SUCCESS) ->where('status', OperationRunStatus::Completed->value)
->whereNotNull('finished_at') ->whereIn('outcome', [
->orderByDesc('finished_at') OperationRunOutcome::Succeeded->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->where('context->selection_hash', $scopeKey)
->whereNotNull('completed_at')
->orderByDesc('completed_at')
->limit(2) ->limit(2)
->get(); ->get();
@ -28,7 +35,7 @@ public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?arr
$current = $runs->first(); $current = $runs->first();
$baseline = $runs->last(); $baseline = $runs->last();
if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) { if (! $baseline instanceof OperationRun || ! $current instanceof OperationRun) {
return null; return null;
} }

View File

@ -2,12 +2,14 @@
namespace App\Services\Drift; namespace App\Services\Drift;
use App\Models\InventorySyncRun; use App\Models\OperationRun;
class DriftScopeKey class DriftScopeKey
{ {
public function fromRun(InventorySyncRun $run): string public function fromRun(OperationRun $run): string
{ {
return (string) $run->selection_hash; $context = is_array($run->context) ? $run->context : [];
return (string) ($context['selection_hash'] ?? '');
} }
} }

View File

@ -27,7 +27,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
$latestRun = OperationRun::query() $latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'inventory.sync') ->where('type', 'inventory_sync')
->where('status', 'completed') ->where('status', 'completed')
->where('context->selection_hash', $selectionHash) ->where('context->selection_hash', $selectionHash)
->orderByDesc('completed_at') ->orderByDesc('completed_at')

View File

@ -3,7 +3,6 @@
namespace App\Services\Inventory; namespace App\Services\Inventory;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
@ -34,13 +33,13 @@ public function __construct(
) {} ) {}
/** /**
* Runs an inventory sync immediately and persists a corresponding InventorySyncRun. * Runs an inventory sync immediately and persists a canonical OperationRun.
* *
* This is primarily used in tests and for synchronous workflows. * This is primarily used in tests and for synchronous workflows.
* *
* @param array<string, mixed> $selectionPayload * @param array<string, mixed> $selectionPayload
*/ */
public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
{ {
$computed = $this->normalizeAndHashSelection($selectionPayload); $computed = $this->normalizeAndHashSelection($selectionPayload);
$normalizedSelection = $computed['selection']; $normalizedSelection = $computed['selection'];
@ -54,40 +53,20 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
'type' => OperationRunType::InventorySync->value, 'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Running->value, 'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()), 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()),
'context' => $normalizedSelection, 'context' => array_merge($normalizedSelection, [
'started_at' => now(), 'selection_hash' => $selectionHash,
]); ]),
$run = InventorySyncRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'operation_run_id' => (int) $operationRun->getKey(),
'selection_hash' => $selectionHash,
'selection_payload' => $normalizedSelection,
'status' => InventorySyncRun::STATUS_RUNNING,
'had_errors' => false,
'started_at' => now(), 'started_at' => now(),
]); ]);
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection); $result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
$status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED); $status = (string) ($result['status'] ?? 'failed');
$hadErrors = (bool) ($result['had_errors'] ?? true); $hadErrors = (bool) ($result['had_errors'] ?? true);
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null; $errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [];
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null; $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
$run->update([
'status' => $status,
'had_errors' => $hadErrors,
'error_codes' => $errorCodes,
'error_context' => $errorContext,
'items_observed_count' => (int) ($result['items_observed_count'] ?? 0),
'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0),
'errors_count' => (int) ($result['errors_count'] ?? 0),
'finished_at' => now(),
]);
$policyTypes = $normalizedSelection['policy_types'] ?? []; $policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : []; $policyTypes = is_array($policyTypes) ? $policyTypes : [];
@ -98,6 +77,28 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
default => OperationRunOutcome::Failed->value, default => OperationRunOutcome::Failed->value,
}; };
$failureSummary = [];
if ($hadErrors && $errorCodes !== []) {
foreach (array_values(array_unique($errorCodes)) as $errorCode) {
if (! is_string($errorCode) || $errorCode === '') {
continue;
}
$failureSummary[] = [
'code' => $errorCode,
'message' => sprintf('Inventory sync reported %s.', str_replace('_', ' ', $errorCode)),
];
}
}
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
$updatedContext['result'] = [
'had_errors' => $hadErrors,
'error_codes' => $errorCodes,
'error_context' => $errorContext,
];
$operationRun->update([ $operationRun->update([
'status' => OperationRunStatus::Completed->value, 'status' => OperationRunStatus::Completed->value,
'outcome' => $operationOutcome, 'outcome' => $operationOutcome,
@ -109,16 +110,18 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
'items' => (int) ($result['items_observed_count'] ?? 0), 'items' => (int) ($result['items_observed_count'] ?? 0),
'updated' => (int) ($result['items_upserted_count'] ?? 0), 'updated' => (int) ($result['items_upserted_count'] ?? 0),
], ],
'failure_summary' => $failureSummary,
'context' => $updatedContext,
'completed_at' => now(), 'completed_at' => now(),
]); ]);
return $run->refresh(); return $operationRun->refresh();
} }
/** /**
* Runs an inventory sync (inline), enforcing locks/concurrency. * Runs an inventory sync (inline), enforcing locks/concurrency.
* *
* This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical. * This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
* *
* @param array<string, mixed> $selectionPayload * @param array<string, mixed> $selectionPayload
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed

View File

@ -17,7 +17,7 @@ public function all(): array
'module' => 'health_check', 'module' => 'health_check',
'label' => 'Provider connection check', 'label' => 'Provider connection check',
], ],
'inventory.sync' => [ 'inventory_sync' => [
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'inventory', 'module' => 'inventory',
'label' => 'Inventory sync', 'label' => 'Inventory sync',

View File

@ -14,10 +14,7 @@ final class BadgeCatalog
private const DOMAIN_MAPPERS = [ private const DOMAIN_MAPPERS = [
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class, BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::InventorySyncRunStatus->value => Domains\InventorySyncRunStatusBadge::class,
BadgeDomain::BackupScheduleRunStatus->value => Domains\BackupScheduleRunStatusBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class, BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::EntraGroupSyncRunStatus->value => Domains\EntraGroupSyncRunStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class, BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class, BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class, BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,

View File

@ -6,10 +6,7 @@ enum BadgeDomain: string
{ {
case OperationRunStatus = 'operation_run_status'; case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome'; case OperationRunOutcome = 'operation_run_outcome';
case InventorySyncRunStatus = 'inventory_sync_run_status';
case BackupScheduleRunStatus = 'backup_schedule_run_status';
case BackupSetStatus = 'backup_set_status'; case BackupSetStatus = 'backup_set_status';
case EntraGroupSyncRunStatus = 'entra_group_sync_run_status';
case RestoreRunStatus = 'restore_run_status'; case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity'; case RestoreCheckSeverity = 'restore_check_severity';
case FindingStatus = 'finding_status'; case FindingStatus = 'finding_status';

View File

@ -1,26 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\BackupScheduleRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BackupScheduleRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
BackupScheduleRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
BackupScheduleRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
BackupScheduleRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
BackupScheduleRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
BackupScheduleRun::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-minus-circle'),
BackupScheduleRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\EntraGroupSyncRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class EntraGroupSyncRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
EntraGroupSyncRun::STATUS_PENDING => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
EntraGroupSyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
EntraGroupSyncRun::STATUS_SUCCEEDED => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
EntraGroupSyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
EntraGroupSyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\InventorySyncRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class InventorySyncRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
InventorySyncRun::STATUS_PENDING, 'queued' => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
InventorySyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
InventorySyncRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
InventorySyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
InventorySyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
InventorySyncRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use App\Models\InventorySyncRun;
class InventorySyncStatusBadge
{
/**
* @return array{label: string, color: string, icon: string}
*/
public static function for(?string $status): array
{
$status = (string) ($status ?? '');
$label = match ($status) {
InventorySyncRun::STATUS_SUCCESS => 'Success',
InventorySyncRun::STATUS_PARTIAL => 'Partial',
InventorySyncRun::STATUS_FAILED => 'Failed',
InventorySyncRun::STATUS_RUNNING => 'Running',
InventorySyncRun::STATUS_PENDING => 'Pending',
InventorySyncRun::STATUS_SKIPPED => 'Skipped',
'queued' => 'Queued',
default => '—',
};
$color = match ($status) {
InventorySyncRun::STATUS_SUCCESS => 'success',
InventorySyncRun::STATUS_PARTIAL => 'warning',
InventorySyncRun::STATUS_FAILED => 'danger',
InventorySyncRun::STATUS_RUNNING => 'info',
InventorySyncRun::STATUS_PENDING, 'queued' => 'gray',
InventorySyncRun::STATUS_SKIPPED => 'gray',
default => 'gray',
};
$icon = match ($status) {
InventorySyncRun::STATUS_SUCCESS => 'heroicon-m-check-circle',
InventorySyncRun::STATUS_PARTIAL => 'heroicon-m-exclamation-triangle',
InventorySyncRun::STATUS_FAILED => 'heroicon-m-x-circle',
InventorySyncRun::STATUS_RUNNING => 'heroicon-m-arrow-path',
InventorySyncRun::STATUS_PENDING, 'queued' => 'heroicon-m-clock',
InventorySyncRun::STATUS_SKIPPED => 'heroicon-m-minus-circle',
default => 'heroicon-m-clock',
};
return [
'label' => $label,
'color' => $color,
'icon' => $icon,
];
}
}

View File

@ -19,18 +19,18 @@ public static function labels(): array
'policy.unignore' => 'Restore policies', 'policy.unignore' => 'Restore policies',
'policy.export' => 'Export policies to backup', 'policy.export' => 'Export policies to backup',
'provider.connection.check' => 'Provider connection check', 'provider.connection.check' => 'Provider connection check',
'inventory.sync' => 'Inventory sync', 'inventory_sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot', 'compliance.snapshot' => 'Compliance snapshot',
'directory_groups.sync' => 'Directory groups sync', 'entra_group_sync' => 'Directory groups sync',
'drift.generate' => 'Drift generation', 'drift_generate_findings' => 'Drift generation',
'backup_set.add_policies' => 'Backup set update', 'backup_set.add_policies' => 'Backup set update',
'backup_set.remove_policies' => 'Backup set update', 'backup_set.remove_policies' => 'Backup set update',
'backup_set.delete' => 'Archive backup sets', 'backup_set.delete' => 'Archive backup sets',
'backup_set.restore' => 'Restore backup sets', 'backup_set.restore' => 'Restore backup sets',
'backup_set.force_delete' => 'Delete backup sets', 'backup_set.force_delete' => 'Delete backup sets',
'backup_schedule.run_now' => 'Backup schedule run', 'backup_schedule_run' => 'Backup schedule run',
'backup_schedule.retry' => 'Backup schedule retry', 'backup_schedule_retention' => 'Backup schedule retention',
'backup_schedule.scheduled' => 'Backup schedule run', 'backup_schedule_purge' => 'Backup schedule purge',
'restore.execute' => 'Restore execution', 'restore.execute' => 'Restore execution',
'directory_role_definitions.sync' => 'Role definitions sync', 'directory_role_definitions.sync' => 'Role definitions sync',
'restore_run.delete' => 'Delete restore runs', 'restore_run.delete' => 'Delete restore runs',
@ -60,10 +60,10 @@ public static function expectedDurationSeconds(string $operationType): ?int
'policy.sync', 'policy.sync_one' => 90, 'policy.sync', 'policy.sync_one' => 90,
'provider.connection.check' => 30, 'provider.connection.check' => 30,
'policy.export' => 120, 'policy.export' => 120,
'inventory.sync' => 180, 'inventory_sync' => 180,
'compliance.snapshot' => 180, 'compliance.snapshot' => 180,
'directory_groups.sync' => 120, 'entra_group_sync' => 120,
'drift.generate' => 240, 'drift_generate_findings' => 240,
default => null, default => null,
}; };
} }

View File

@ -54,7 +54,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin'); $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
} }
if ($run->type === 'inventory.sync') { if ($run->type === 'inventory_sync') {
$links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant); $links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant);
} }
@ -67,11 +67,11 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
} }
} }
if ($run->type === 'directory_groups.sync') { if ($run->type === 'entra_group_sync') {
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant); $links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
} }
if ($run->type === 'drift.generate') { if ($run->type === 'drift_generate_findings') {
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant); $links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
} }
@ -84,7 +84,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
} }
} }
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) { if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant); $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
} }

View File

@ -4,16 +4,16 @@
enum OperationRunType: string enum OperationRunType: string
{ {
case InventorySync = 'inventory.sync'; case InventorySync = 'inventory_sync';
case PolicySync = 'policy.sync'; case PolicySync = 'policy.sync';
case PolicySyncOne = 'policy.sync_one'; case PolicySyncOne = 'policy.sync_one';
case DirectoryGroupsSync = 'directory_groups.sync'; case DirectoryGroupsSync = 'entra_group_sync';
case DriftGenerate = 'drift.generate'; case DriftGenerate = 'drift_generate_findings';
case BackupSetAddPolicies = 'backup_set.add_policies'; case BackupSetAddPolicies = 'backup_set.add_policies';
case BackupSetRemovePolicies = 'backup_set.remove_policies'; case BackupSetRemovePolicies = 'backup_set.remove_policies';
case BackupScheduleRunNow = 'backup_schedule.run_now'; case BackupScheduleExecute = 'backup_schedule_run';
case BackupScheduleRetry = 'backup_schedule.retry'; case BackupScheduleRetention = 'backup_schedule_retention';
case BackupScheduleScheduled = 'backup_schedule.scheduled'; case BackupSchedulePurge = 'backup_schedule_purge';
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'; case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
case RestoreExecute = 'restore.execute'; case RestoreExecute = 'restore.execute';

View File

@ -15,9 +15,9 @@ public function requiredCapabilityForType(string $operationType): ?string
} }
return match ($operationType) { return match ($operationType) {
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, 'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'directory_groups.sync' => Capabilities::TENANT_SYNC, 'entra_group_sync' => Capabilities::TENANT_SYNC,
'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, 'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
'restore.execute' => Capabilities::TENANT_MANAGE, 'restore.execute' => Capabilities::TENANT_MANAGE,
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,

View File

@ -36,7 +36,6 @@ public static function baseline(): self
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
'App\\Filament\\Resources\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.', 'App\\Filament\\Resources\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.',
'App\\Filament\\Resources\\BackupScheduleResource\\RelationManagers\\BackupScheduleRunsRelationManager' => 'Backup schedule runs relation manager retrofit deferred to backup scheduling track.',
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.', 'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.', 'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.',

View File

@ -1,45 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EntraGroupSyncRun>
*/
class EntraGroupSyncRunFactory extends Factory
{
protected $model = EntraGroupSyncRun::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'selection_key' => 'groups-v1:all',
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => User::factory(),
'pages_fetched' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'error_count' => 0,
'safety_stop_triggered' => false,
'safety_stop_reason' => null,
'error_code' => null,
'error_category' => null,
'error_summary' => null,
'started_at' => null,
'finished_at' => null,
];
}
public function scheduled(): static
{
return $this->state(fn (): array => [
'initiator_user_id' => null,
]);
}
}

View File

@ -22,8 +22,8 @@ public function definition(): array
'tenant_id' => Tenant::factory(), 'tenant_id' => Tenant::factory(),
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => hash('sha256', fake()->uuid()), 'scope_key' => hash('sha256', fake()->uuid()),
'baseline_run_id' => null, 'baseline_operation_run_id' => null,
'current_run_id' => null, 'current_operation_run_id' => null,
'fingerprint' => hash('sha256', fake()->uuid()), 'fingerprint' => hash('sha256', fake()->uuid()),
'subject_type' => 'assignment', 'subject_type' => 'assignment',
'subject_external_id' => fake()->uuid(), 'subject_external_id' => fake()->uuid(),

View File

@ -32,7 +32,7 @@ public function definition(): array
'warnings' => [], 'warnings' => [],
], ],
'last_seen_at' => now(), 'last_seen_at' => now(),
'last_seen_run_id' => null, 'last_seen_operation_run_id' => null,
]; ];
} }
} }

View File

@ -1,43 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventorySyncRun>
*/
class InventorySyncRunFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$selectionPayload = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
];
return [
'tenant_id' => Tenant::factory(),
'selection_hash' => hash('sha256', (string) json_encode($selectionPayload)),
'selection_payload' => $selectionPayload,
'status' => InventorySyncRun::STATUS_SUCCESS,
'had_errors' => false,
'error_codes' => [],
'error_context' => null,
'started_at' => now()->subMinute(),
'finished_at' => now(),
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
];
}
}

View File

@ -24,7 +24,7 @@ public function up(): void
DB::statement(<<<'SQL' DB::statement(<<<'SQL'
CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique
ON operation_runs (tenant_id, run_identity_hash) ON operation_runs (tenant_id, run_identity_hash)
WHERE type = 'backup_schedule.scheduled' WHERE type = 'backup_schedule_run'
SQL); SQL);
} }

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* @var array<string, string>
*/
private array $upMap = [
'inventory.sync' => 'inventory_sync',
'directory_groups.sync' => 'entra_group_sync',
'drift.generate' => 'drift_generate_findings',
'backup_schedule.run_now' => 'backup_schedule_run',
'backup_schedule.retry' => 'backup_schedule_run',
'backup_schedule.scheduled' => 'backup_schedule_run',
];
/**
* @var array<string, string>
*/
private array $downMap = [
'inventory_sync' => 'inventory.sync',
'entra_group_sync' => 'directory_groups.sync',
'drift_generate_findings' => 'drift.generate',
// Multiple legacy values are normalized into one canonical value.
'backup_schedule_run' => 'backup_schedule.run_now',
];
public function up(): void
{
if (! Schema::hasTable('operation_runs')) {
return;
}
foreach ($this->upMap as $from => $to) {
DB::table('operation_runs')
->where('type', $from)
->update(['type' => $to]);
}
}
public function down(): void
{
if (! Schema::hasTable('operation_runs')) {
return;
}
foreach ($this->downMap as $from => $to) {
DB::table('operation_runs')
->where('type', $from)
->update(['type' => $to]);
}
}
};

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('findings') || ! Schema::hasTable('operation_runs')) {
return;
}
Schema::table('findings', function (Blueprint $table) {
if (! Schema::hasColumn('findings', 'baseline_operation_run_id')) {
$table->foreignId('baseline_operation_run_id')
->nullable()
->after('baseline_run_id')
->constrained('operation_runs')
->nullOnDelete();
$table->index(['tenant_id', 'baseline_operation_run_id'], 'findings_tenant_baseline_operation_run_idx');
}
if (! Schema::hasColumn('findings', 'current_operation_run_id')) {
$table->foreignId('current_operation_run_id')
->nullable()
->after('current_run_id')
->constrained('operation_runs')
->nullOnDelete();
$table->index(['tenant_id', 'current_operation_run_id'], 'findings_tenant_current_operation_run_idx');
}
});
}
public function down(): void
{
if (! Schema::hasTable('findings')) {
return;
}
Schema::table('findings', function (Blueprint $table) {
if (Schema::hasColumn('findings', 'baseline_operation_run_id')) {
$table->dropIndex('findings_tenant_baseline_operation_run_idx');
$table->dropConstrainedForeignId('baseline_operation_run_id');
}
if (Schema::hasColumn('findings', 'current_operation_run_id')) {
$table->dropIndex('findings_tenant_current_operation_run_idx');
$table->dropConstrainedForeignId('current_operation_run_id');
}
});
}
};

View File

@ -0,0 +1,107 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('findings') || ! Schema::hasTable('inventory_sync_runs') || ! Schema::hasTable('operation_runs')) {
return;
}
if (! Schema::hasColumn('findings', 'baseline_operation_run_id') || ! Schema::hasColumn('findings', 'current_operation_run_id')) {
return;
}
DB::table('findings')
->select(['id', 'baseline_run_id', 'current_run_id', 'baseline_operation_run_id', 'current_operation_run_id'])
->orderBy('id')
->chunkById(500, function (Collection $rows): void {
$legacyRunIds = $rows
->flatMap(function (object $row): array {
return [
$row->baseline_run_id,
$row->current_run_id,
];
})
->filter(fn (mixed $id): bool => is_numeric($id))
->map(fn (mixed $id): int => (int) $id)
->unique()
->values();
if ($legacyRunIds->isEmpty()) {
return;
}
$operationRunIdByLegacyRunId = DB::table('inventory_sync_runs')
->whereIn('id', $legacyRunIds->all())
->whereNotNull('operation_run_id')
->pluck('operation_run_id', 'id')
->map(fn (mixed $id): int => (int) $id);
if ($operationRunIdByLegacyRunId->isEmpty()) {
return;
}
$existingOperationRunIds = DB::table('operation_runs')
->whereIn('id', $operationRunIdByLegacyRunId->values()->unique()->all())
->pluck('id')
->mapWithKeys(fn (mixed $id): array => [(int) $id => true]);
foreach ($rows as $row) {
$updates = [];
if ($row->baseline_operation_run_id === null && is_numeric($row->baseline_run_id)) {
$candidate = $operationRunIdByLegacyRunId->get((int) $row->baseline_run_id);
if (is_numeric($candidate) && $existingOperationRunIds->has((int) $candidate)) {
$updates['baseline_operation_run_id'] = (int) $candidate;
}
}
if ($row->current_operation_run_id === null && is_numeric($row->current_run_id)) {
$candidate = $operationRunIdByLegacyRunId->get((int) $row->current_run_id);
if (is_numeric($candidate) && $existingOperationRunIds->has((int) $candidate)) {
$updates['current_operation_run_id'] = (int) $candidate;
}
}
if ($updates === []) {
continue;
}
DB::table('findings')
->where('id', (int) $row->id)
->update($updates);
}
});
}
public function down(): void
{
if (! Schema::hasTable('findings')) {
return;
}
$updates = [];
if (Schema::hasColumn('findings', 'baseline_operation_run_id')) {
$updates['baseline_operation_run_id'] = null;
}
if (Schema::hasColumn('findings', 'current_operation_run_id')) {
$updates['current_operation_run_id'] = null;
}
if ($updates === []) {
return;
}
DB::table('findings')->update($updates);
}
};

View File

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('inventory_items') || ! Schema::hasTable('inventory_sync_runs') || ! Schema::hasTable('operation_runs')) {
return;
}
if (! Schema::hasColumn('inventory_items', 'last_seen_run_id') || ! Schema::hasColumn('inventory_items', 'last_seen_operation_run_id')) {
return;
}
DB::table('inventory_items')
->select(['id', 'last_seen_run_id', 'last_seen_operation_run_id'])
->orderBy('id')
->chunkById(500, function (Collection $rows): void {
$legacyRunIds = $rows
->pluck('last_seen_run_id')
->filter(fn (mixed $id): bool => is_numeric($id))
->map(fn (mixed $id): int => (int) $id)
->unique()
->values();
if ($legacyRunIds->isEmpty()) {
return;
}
$operationRunIdByLegacyRunId = DB::table('inventory_sync_runs')
->whereIn('id', $legacyRunIds->all())
->whereNotNull('operation_run_id')
->pluck('operation_run_id', 'id')
->map(fn (mixed $id): int => (int) $id);
if ($operationRunIdByLegacyRunId->isEmpty()) {
return;
}
$existingOperationRunIds = DB::table('operation_runs')
->whereIn('id', $operationRunIdByLegacyRunId->values()->unique()->all())
->pluck('id')
->mapWithKeys(fn (mixed $id): array => [(int) $id => true]);
foreach ($rows as $row) {
if ($row->last_seen_operation_run_id !== null || ! is_numeric($row->last_seen_run_id)) {
continue;
}
$candidate = $operationRunIdByLegacyRunId->get((int) $row->last_seen_run_id);
if (! is_numeric($candidate) || ! $existingOperationRunIds->has((int) $candidate)) {
continue;
}
DB::table('inventory_items')
->where('id', (int) $row->id)
->update(['last_seen_operation_run_id' => (int) $candidate]);
}
});
}
public function down(): void
{
if (! Schema::hasTable('inventory_items') || ! Schema::hasColumn('inventory_items', 'last_seen_operation_run_id')) {
return;
}
DB::table('inventory_items')->update(['last_seen_operation_run_id' => null]);
}
};

View File

@ -0,0 +1,74 @@
<?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
{
public function up(): void
{
if (Schema::hasTable('findings')) {
DB::statement('DROP INDEX IF EXISTS findings_tenant_id_baseline_run_id_index');
DB::statement('DROP INDEX IF EXISTS findings_tenant_id_current_run_id_index');
Schema::table('findings', function (Blueprint $table) {
if (Schema::hasColumn('findings', 'baseline_run_id')) {
$table->dropConstrainedForeignId('baseline_run_id');
}
if (Schema::hasColumn('findings', 'current_run_id')) {
$table->dropConstrainedForeignId('current_run_id');
}
});
}
if (Schema::hasTable('inventory_items')) {
Schema::table('inventory_items', function (Blueprint $table) {
if (Schema::hasColumn('inventory_items', 'last_seen_run_id')) {
$table->dropConstrainedForeignId('last_seen_run_id');
}
});
}
}
public function down(): void
{
if (! Schema::hasTable('inventory_sync_runs')) {
return;
}
if (Schema::hasTable('findings')) {
Schema::table('findings', function (Blueprint $table) {
if (! Schema::hasColumn('findings', 'baseline_run_id')) {
$table->foreignId('baseline_run_id')
->nullable()
->after('scope_key')
->constrained('inventory_sync_runs');
$table->index(['tenant_id', 'baseline_run_id']);
}
if (! Schema::hasColumn('findings', 'current_run_id')) {
$table->foreignId('current_run_id')
->nullable()
->after('baseline_run_id')
->constrained('inventory_sync_runs');
$table->index(['tenant_id', 'current_run_id']);
}
});
}
if (Schema::hasTable('inventory_items')) {
Schema::table('inventory_items', function (Blueprint $table) {
if (! Schema::hasColumn('inventory_items', 'last_seen_run_id')) {
$table->foreignId('last_seen_run_id')
->nullable()
->after('last_seen_at')
->constrained('inventory_sync_runs')
->nullOnDelete();
}
});
}
}
};

View File

@ -0,0 +1,100 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('backup_schedule_runs');
Schema::dropIfExists('entra_group_sync_runs');
Schema::dropIfExists('inventory_sync_runs');
}
public function down(): void
{
if (! Schema::hasTable('inventory_sync_runs')) {
Schema::create('inventory_sync_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('selection_hash', 64);
$table->jsonb('selection_payload')->nullable();
$table->string('status');
$table->boolean('had_errors')->default(false);
$table->jsonb('error_codes')->nullable();
$table->jsonb('error_context')->nullable();
$table->timestampTz('started_at')->nullable();
$table->timestampTz('finished_at')->nullable();
$table->unsignedInteger('items_observed_count')->default(0);
$table->unsignedInteger('items_upserted_count')->default(0);
$table->unsignedInteger('errors_count')->default(0);
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
$table->timestamps();
$table->index(['tenant_id', 'selection_hash']);
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'finished_at']);
$table->index(['tenant_id', 'user_id']);
$table->index('operation_run_id');
});
}
if (! Schema::hasTable('entra_group_sync_runs')) {
Schema::create('entra_group_sync_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('selection_key');
$table->string('slot_key')->nullable();
$table->string('status');
$table->string('error_code')->nullable();
$table->string('error_category')->nullable();
$table->text('error_summary')->nullable();
$table->boolean('safety_stop_triggered')->default(false);
$table->string('safety_stop_reason')->nullable();
$table->unsignedInteger('pages_fetched')->default(0);
$table->unsignedInteger('items_observed_count')->default(0);
$table->unsignedInteger('items_upserted_count')->default(0);
$table->unsignedInteger('error_count')->default(0);
$table->foreignId('initiator_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestampTz('started_at')->nullable();
$table->timestampTz('finished_at')->nullable();
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
$table->timestamps();
$table->index(['tenant_id', 'selection_key']);
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'finished_at']);
$table->index('operation_run_id');
$table->unique(['tenant_id', 'selection_key', 'slot_key']);
});
}
if (! Schema::hasTable('backup_schedule_runs')) {
Schema::create('backup_schedule_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->dateTime('scheduled_for');
$table->dateTime('started_at')->nullable();
$table->dateTime('finished_at')->nullable();
$table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']);
$table->json('summary')->nullable();
$table->string('error_code')->nullable();
$table->text('error_message')->nullable();
$table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete();
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
$table->timestamps();
$table->unique(['backup_schedule_id', 'scheduled_for']);
$table->index(['backup_schedule_id', 'scheduled_for']);
$table->index(['tenant_id', 'created_at']);
$table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created');
$table->index('operation_run_id');
});
}
}
};

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Legacy Runs Removal
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-12
**Feature**: [specs/087-legacy-runs-removal/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass on 2026-02-12. Ready for `/speckit.plan`.

View File

@ -0,0 +1,59 @@
openapi: 3.0.3
info:
title: TenantPilot Operations Runs (UI endpoints)
version: "1.0"
description: |
Minimal contract describing the canonical Operations run list and detail endpoints.
Note: These are Filament (server-rendered / Livewire) endpoints, not a public JSON API.
servers:
- url: /
paths:
/admin/monitoring/operations:
get:
summary: Operations run list (canonical)
description: Canonical list of operation runs scoped by workspace entitlement.
responses:
"200":
description: HTML page
content:
text/html:
schema:
type: string
"302":
description: Redirect to login
/admin/operations/{runId}:
get:
summary: Operations run detail (canonical)
description: Canonical tenantless run viewer.
parameters:
- name: runId
in: path
required: true
schema:
type: integer
responses:
"200":
description: HTML page
content:
text/html:
schema:
type: string
"403":
description: Workspace member but missing capability
"404":
description: Not entitled to workspace scope (deny-as-not-found)
"302":
description: Redirect to login
components:
schemas:
OperationRunType:
type: string
description: Canonical run types created by this feature.
enum:
- inventory_sync
- drift_generate_findings
- entra_group_sync
- backup_schedule_run
- backup_schedule_retention
- backup_schedule_purge

View File

@ -0,0 +1,109 @@
# Phase 1 — Data Model: Legacy Runs Removal (Spec 087)
**Branch**: `087-legacy-runs-removal`
**Date**: 2026-02-12
## Canonical Entity: OperationRun
**Table**: `operation_runs`
**Model**: `App\\Models\\OperationRun`
Key fields (existing):
- `id`
- `workspace_id` (required)
- `tenant_id` (nullable; tenant-scoped runs use this)
- `user_id` (nullable)
- `initiator_name`
- `type` (canonical run_type)
- `status` / `outcome`
- `run_identity_hash` (idempotency)
- `summary_counts` (JSON)
- `failure_summary` (JSON)
- `context` (JSON)
- `started_at` / `completed_at`
- `created_at` / `updated_at`
Spec impact:
- `type` must be standardized to the underscore identifiers listed in FR-012.
## Legacy Entities (To Remove)
### InventorySyncRun
**Table**: `inventory_sync_runs`
**Model**: `App\\Models\\InventorySyncRun`
Notes:
- Acts as a legacy duplicate run store.
- Has `operation_run_id` bridge column (added later).
**Plan**: remove model + table, and rely on `operation_runs`.
### EntraGroupSyncRun
**Table**: `entra_group_sync_runs`
**Model**: `App\\Models\\EntraGroupSyncRun`
Notes:
- Legacy duplicate run store.
- Has `operation_run_id` bridge column.
**Plan**: remove model + table, and rely on `operation_runs`.
### BackupScheduleRun
**Table**: `backup_schedule_runs`
**Model**: `App\\Models\\BackupScheduleRun`
Notes:
- Currently used for schedule run history + retention/purge selection.
- Has `operation_run_id` bridge column.
**Plan**: remove model + table and replace schedule “run history” with querying `operation_runs` by type + context.
## Drift Findings: References Must Become Canonical
**Table**: `findings`
**Model**: `App\\Models\\Finding`
Current fields (relevant):
- `baseline_run_id` → FK to `inventory_sync_runs` (nullable)
- `current_run_id` → FK to `inventory_sync_runs` (nullable)
**Planned fields**:
- `baseline_operation_run_id` → FK to `operation_runs` (nullable)
- `current_operation_run_id` → FK to `operation_runs` (nullable)
**Backfill rule**:
- If `baseline_run_id` points to an `inventory_sync_runs` row with a non-null `operation_run_id`, and that `operation_runs` row exists, copy it.
- Same for `current_run_id`.
- Otherwise leave null (matches spec edge-case expectations).
## Inventory Items: Last Seen Run Reference Must Become Canonical
**Table**: `inventory_items`
**Model**: `App\\Models\\InventoryItem`
Current fields (relevant):
- `last_seen_run_id` → FK to `inventory_sync_runs` (nullable)
- `last_seen_operation_run_id` → FK to `operation_runs` (nullable; already exists)
**Plan**:
- Backfill `last_seen_operation_run_id` via `inventory_sync_runs.operation_run_id` where possible.
- Drop `last_seen_run_id` after code is migrated.
## Backup Schedules: Run History
**Table**: `backup_schedules`
**Model**: `App\\Models\\BackupSchedule`
Planned behavior:
- Remove `runs()` relationship that points to `backup_schedule_runs`.
- For UI/history, query `operation_runs` using:
- `type = backup_schedule_run|backup_schedule_retention|backup_schedule_purge`
- `context->backup_schedule_id = {id}` (and optionally scheduled time metadata)
## Validation Rules (from spec)
- All new run records created by this feature must have `type` in the FR-012 allow-list.
- Run visibility is workspace-scoped; non-members must be deny-as-not-found (404).

View File

@ -0,0 +1,144 @@
# Implementation Plan: Legacy Runs Removal (Spec 087)
**Branch**: `087-legacy-runs-removal` | **Date**: 2026-02-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md`
## Summary
Remove the “legacy run” worlds for inventory sync, Entra group sync, and backup schedules so that `operation_runs` is the only run tracking source. Migrate drift + inventory references away from `inventory_sync_runs`, remove legacy Filament run UI surfaces (no redirects), drop legacy run tables, and add an architecture guard test (`NoLegacyRuns`) to prevent regressions.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (Sail), SQLite in tests
**Testing**: Pest v4 (PHPUnit 12)
**Target Platform**: Containerized web app (Sail locally, Dokploy deploy)
**Project Type**: Web application (Laravel + Filament)
**Performance Goals**: Monitoring/Operations pages render DB-only; avoid N+1 when listing runs
**Constraints**: Legacy run URLs must be not found (no redirects). This does not apply to the existing tenant-scoped operations index convenience redirect (`/admin/t/{tenant}/operations` → `/admin/operations`). Non-member workspace access is 404; member missing capability is 403
**Scale/Scope**: TenantPilot admin app; operations visibility + drift references are core operational surfaces
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Workspace isolation / RBAC-UX (404 vs 403): PASS (required by spec FR-008)
- Run observability: PASS (this feature eliminates duplicate run stores)
- Monitoring pages DB-only: PASS (no new render-time Graph calls)
- Badge semantics (BADGE-001): PASS (legacy run badge mappings are removed, not expanded)
- Filament action surface contract: PASS (feature mostly removes surfaces; modified surfaces must preserve inspection affordance + RBAC)
## Project Structure
### Documentation (this feature)
```text
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── operations-runs.openapi.yaml
└── checklists/
└── requirements.md
### Source Code (repository root)
```text
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/
├── app/
│ ├── Filament/
│ ├── Jobs/
│ ├── Models/
│ ├── Services/
│ └── Support/
├── database/
│ ├── migrations/
│ ├── factories/
│ └── seeders/
├── resources/
├── routes/
└── tests/
└── Feature/
└── Guards/
```
**Structure Decision**: Laravel monolith with Filament resources/pages; this feature touches `app/`, `database/migrations/`, `resources/`, `routes/`, and `tests/Feature/`.
## Phase 0 — Outline & Research (COMPLETE)
Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/research.md`
Research resolves remaining planning unknowns:
- Identifies legacy run tables and UI surfaces to remove.
- Resolves the `run_type` contract mismatch (spec underscore values vs current dotted enum values).
- Defines an FK cutover plan for drift + inventory references so legacy tables can be dropped safely.
## Phase 1 — Design & Contracts (COMPLETE)
Outputs:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/contracts/operations-runs.openapi.yaml`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/quickstart.md`
Post-design constitution re-check: PASS (no new Graph/render concerns; run observability strengthened).
## Phase 2 — Implementation Plan
### Ordering constraints
- Drift + inventory FKs currently point at legacy run tables; migrate those references before dropping tables.
- Legacy run pages must be removed without redirects; verify with 404 tests.
### Step A — Canonical run types (FR-012)
- Align stored `operation_runs.type` values with the underscore allow-list for the affected workflows.
- Provide a migration that rewrites existing dotted values used today into the canonical underscore forms where safe.
### Step B — Stop legacy writes + remove legacy read dependencies
- Inventory sync: eliminate writes to `inventory_sync_runs` and use `operation_runs` only.
- Entra group sync: eliminate writes to `entra_group_sync_runs` and use `operation_runs` only.
- Backup schedule:
- Eliminate writes to `backup_schedule_runs`.
- Persist schedule metadata in `operation_runs.context` (e.g., `backup_schedule_id`, `scheduled_for`, `reason`).
- Ensure retention and purge each create their own canonical runs (FR-011).
### Step C — Drift + inventory reference cutover
- Add `findings.baseline_operation_run_id` / `findings.current_operation_run_id` and backfill via `inventory_sync_runs.operation_run_id`.
- Backfill `inventory_items.last_seen_operation_run_id` where only `last_seen_run_id` exists.
- Update app code to use the canonical columns and tolerate null mappings.
- Drop legacy run ID columns after cutover.
### Step D — Remove legacy UI surfaces (FR-003/FR-004)
- Remove Filament resources/pages:
- `InventorySyncRunResource`
- `EntraGroupSyncRunResource`
- Remove backup schedule legacy run history relation manager + modal.
- Update links in drift/finding/inventory surfaces to use the canonical operations viewer.
- Keep (or explicitly remove) the tenant-scoped operations index convenience redirect separately; it is not a legacy *run* page.
### Step E — Drop legacy tables
- Drop `inventory_sync_runs`, `entra_group_sync_runs`, and `backup_schedule_runs` after FK cutover.
### Step F — Architecture guard (FR-009)
- Add a guard test under `tests/Feature/Guards/` scanning `app/`, `database/`, `resources/`, and `routes/` for legacy run tokens.
### Test plan (minimum)
- New: `NoLegacyRunsTest` (architecture guard)
- Legacy routes: assert old run URLs return 404 (no redirects)
- Drift + inventory:
- new records store canonical run references
- historical records without safe mapping render with null references
## Complexity Tracking
No constitution violations are required for this plan.

View File

@ -0,0 +1,36 @@
# Quickstart: Legacy Runs Removal (Spec 087)
**Branch**: `087-legacy-runs-removal`
## Local Dev (Sail)
1) Start containers:
- `vendor/bin/sail up -d`
2) Run migrations:
- `vendor/bin/sail artisan migrate`
3) Run targeted tests (once implementation exists):
- Minimum required pack:
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyRunsTest.php tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Drift`
- Backup + directory regressions:
- `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling tests/Feature/DirectoryGroups`
- Canonical run flow checks:
- `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Console/PurgeNonPersistentDataCommandTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
## Manual Verification Checklist (once implementation exists)
- Inventory sync run appears in Monitoring → Operations and opens via the canonical viewer.
- Entra group sync run appears in Monitoring → Operations and opens via the canonical viewer.
- Backup schedule run/retention/purge each appear in Monitoring → Operations.
- Legacy run URLs return 404 (no redirects):
- Inventory sync runs resource route
- Entra group sync runs resource route
- Backup schedule run history relation manager is removed from schedule detail
## Notes
- Per spec clarifications, no backfill of legacy run history is performed. Only reference migration from legacy IDs to existing `operation_runs` IDs is expected.

View File

@ -0,0 +1,120 @@
# Phase 0 — Research: Legacy Runs Removal (Spec 087)
**Branch**: `087-legacy-runs-removal`
**Date**: 2026-02-12
**Input Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md`
## What “Legacy Runs” Means In This Repo
**Decision**: “Legacy runs” for this spec are the tenant-scoped run tables + UI surfaces that duplicate `operation_runs` for:
- Inventory sync
- Entra group sync
- Backup schedule execution
**Evidence (current repo state)**:
- Legacy tables exist:
- `inventory_sync_runs` ([database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php](../../database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php))
- `entra_group_sync_runs` ([database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php](../../database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php))
- `backup_schedule_runs` ([database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php](../../database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php))
- These legacy tables already have bridging columns to canonical runs (`operation_run_id` was added later), indicating an ongoing migration to `operation_runs`:
- [database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php](../../database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php)
- [database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php](../../database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php)
- [database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php](../../database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php)
## Canonical Run Types: Spec vs Current Code
### Problem
The spec mandates canonical `run_type` identifiers:
- `inventory_sync`
- `drift_generate_findings`
- `entra_group_sync`
- `backup_schedule_run`
- `backup_schedule_retention`
- `backup_schedule_purge`
But the current code uses dotted `OperationRunType` values such as:
- `inventory.sync`
- `directory_groups.sync`
- `drift.generate`
- `backup_schedule.run_now` / `backup_schedule.retry` / `backup_schedule.scheduled`
([app/Support/OperationRunType.php](../../app/Support/OperationRunType.php))
### Decision
Adopt the specs underscore identifiers as the canonical stored values going forward.
### Rationale
- The spec explicitly requires a standardized and enforced contract.
- Stored `type` values are used across UI and notifications; a single canonical scheme reduces “type sprawl.”
### Backwards Compatibility Plan (for existing rows)
- Provide a one-time data migration that rewrites existing `operation_runs.type` values from the old dotted values to the new underscore values *for the affected categories only*.
- Keep a compatibility mapping in code (temporary) only if needed to avoid breaking filters/UI during rollout.
### Alternatives Considered
- Keep dotted values and treat the specs list as “display labels.” Rejected because it violates FR-012s explicit identifiers.
- Introduce a parallel “canonical_type” column. Rejected because it increases complexity and duplication.
## Drift + Inventory References (FK Cutover)
### Problem
Drift and inventory currently reference legacy run IDs:
- Findings: `baseline_run_id` / `current_run_id` are FKs to `inventory_sync_runs` ([database/migrations/2026_01_13_223311_create_findings_table.php](../../database/migrations/2026_01_13_223311_create_findings_table.php))
- Inventory items: `last_seen_run_id` references `inventory_sync_runs` ([database/migrations/2026_01_07_142720_create_inventory_items_table.php](../../database/migrations/2026_01_07_142720_create_inventory_items_table.php))
But inventory items already have the new canonical field:
- `last_seen_operation_run_id` ([database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php](../../database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php))
### Decision
Migrate drift + inventory references to `operation_runs` and drop the legacy FK columns.
### Migration Strategy
- Add new nullable columns:
- `findings.baseline_operation_run_id`
- `findings.current_operation_run_id`
- Backfill by joining through `inventory_sync_runs.operation_run_id` where available.
- Update app code to read/write the new fields.
- Drop old `*_run_id` FK columns.
- Only after that, drop the legacy tables.
### Rationale
- Required to drop legacy tables without breaking referential integrity.
- Aligns with spec acceptance scenario: “historical drift findings where no safe mapping exists” remain functional (references can be null).
## Legacy UI Surfaces Found (To Remove)
**Inventory sync runs**:
- [app/Filament/Resources/InventorySyncRunResource.php](../../app/Filament/Resources/InventorySyncRunResource.php)
**Entra group sync runs**:
- [app/Filament/Resources/EntraGroupSyncRunResource.php](../../app/Filament/Resources/EntraGroupSyncRunResource.php)
**Backup schedule run history** (RelationManager + modal):
- [app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php](../../app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php)
## Architecture Guard (NoLegacyRuns)
### Decision
Add a CI/architecture Pest test similar to existing “NoLegacy…” guards:
- [tests/Feature/Guards/NoLegacyBulkOperationsTest.php](../../tests/Feature/Guards/NoLegacyBulkOperationsTest.php)
### Guard Scope (per spec clarification)
Scan for forbidden tokens under:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/routes/`
Exclude:
- `vendor/`, `storage/`, `specs/`, `spechistory/`, `references/`, `bootstrap/cache/`
### Initial Forbidden Tokens (starting list)
- `InventorySyncRun`, `inventory_sync_runs`, `InventorySyncRunResource`
- `EntraGroupSyncRun`, `entra_group_sync_runs`, `EntraGroupSyncRunResource`
- `BackupScheduleRun`, `backup_schedule_runs`, `BackupScheduleRunsRelationManager`
## Open Questions
None remaining for planning.
- “No backfill” is already clarified in the spec; the only data movement here is migrating *references* to existing `operation_runs`.
- Authorization semantics for canonical operations pages are clarified (404 vs 403) and must be preserved.

View File

@ -0,0 +1,146 @@
# Feature Specification: Legacy Runs Removal
**Feature Branch**: `087-legacy-runs-removal`
**Created**: 2026-02-12
**Status**: Draft
**Input**: User description: "Spec 087 — Legacy Runs Removal (RIGOROS)"
## Clarifications
### Session 2026-02-12
- Q: Should we backfill legacy run history into the canonical run system before dropping legacy tables? → A: No backfill (accept losing pre-existing legacy run history)
- Q: Should backup schedule retention/purge produce canonical run records? → A: Yes — retention and purge each produce their own canonical runs
- Q: Which canonical run_type contract should be enforced? → A: Use the explicit list: inventory_sync, drift_generate_findings, entra_group_sync, backup_schedule_run, backup_schedule_retention, backup_schedule_purge
- Q: For canonical run URLs, should access be 404 for non-members and 403 for members missing capability? → A: Yes — non-member/not entitled → 404; member missing capability → 403
- Q: How broad should the “NoLegacyRuns” architecture guard scan be? → A: Scan app/, database/, resources/, and routes/ (exclude specs/ and references/)
## Redirect Scope
This specs “no redirects” requirement applies to legacy *run* pages and legacy run UI surfaces.
The existing convenience route that redirects the tenant-scoped operations index to the canonical operations index (for example: `/admin/t/{tenant}/operations``/admin/operations`) is not considered a legacy run page.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Single canonical run history (Priority: P1)
As a workspace member, I can see and open run history for inventory sync, Entra group sync, and backup schedules in a single, consistent place, without having to navigate multiple legacy run screens.
**Why this priority**: Removes duplicated tracking and inconsistent UX, and reduces confusion about which “run” is authoritative.
**Independent Test**: Trigger each run type once and verify it appears in the canonical Operations run list and can be opened via the canonical run detail view.
**Acceptance Scenarios**:
1. **Given** a workspace member with permission to view operations, **When** a new inventory sync run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI.
2. **Given** a workspace member with permission to view operations, **When** a new Entra group sync run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI.
3. **Given** a workspace member with permission to view operations, **When** a backup schedule run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI.
---
### User Story 2 - Legacy run UI is intentionally gone (Priority: P1)
As a workspace member, I cannot access legacy run pages anymore. Old links intentionally fail, so there is no ambiguity about where runs are viewed.
**Why this priority**: Prevents “run sprawl” and ensures there is exactly one canonical run UI.
**Independent Test**: Attempt to access any known legacy run route and verify it is not available.
**Acceptance Scenarios**:
1. **Given** a user (any role), **When** they attempt to access a legacy run URL, **Then** the application responds as not found.
2. **Given** the application navigation, **When** a user browses the admin UI, **Then** no legacy run resources appear in navigation.
---
### User Story 3 - Drift references canonical runs (Priority: P1)
As a workspace member using drift-related features, I see baseline/current references tied to canonical runs, and drift logic does not depend on a legacy run store.
**Why this priority**: Drift currently has structural coupling to a legacy run concept; removing it reduces risk and complexity.
**Independent Test**: Create drift findings that reference baseline/current runs and confirm the references use canonical runs (or are empty if historical data cannot be mapped).
**Acceptance Scenarios**:
1. **Given** drift findings created after this feature ships, **When** a user views baseline/current references, **Then** the references point to canonical runs.
2. **Given** historical drift findings where no safe mapping exists, **When** a user views baseline/current references, **Then** the UI remains functional and references are empty rather than erroring.
---
### Edge Cases
- Historical runs exist with details that were only stored in legacy run records, and this history will not be backfilled into canonical runs.
- A user is a non-member of the workspace and attempts to view a run.
- A user is a workspace member but lacks the capability to view operations.
- A scheduled backup run is skipped/cancelled and still needs a canonical run record with a clear final outcome.
- Retention/purge runs remove old data; the user expects runs to remain auditable and consistent.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes long-running work and run tracking. It MUST ensure run observability via the canonical run system, consistent identity, and workspace-scoped visibility.
**Constitution alignment (RBAC-UX):** This feature changes run visibility and removes legacy pages. It MUST explicitly define not-found vs forbidden behavior and enforce authorization server-side.
**Constitution alignment (BADGE-001):** Any run status-like badges MUST be derived from canonical run status/outcome to keep semantics centralized.
**Constitution alignment (Filament Action Surfaces):** This feature removes legacy run resources and updates run links to point to the canonical operations views.
### Functional Requirements
- **FR-001**: The system MUST treat the canonical run system as the single source of truth for all run tracking and display.
- **FR-002**: The system MUST NOT create or update legacy run records for inventory sync, Entra group sync, or backup schedule runs.
- **FR-003**: The system MUST remove all legacy run UI surfaces (resources/pages/relation managers/badges) so that runs are only accessible from the canonical Operations UI.
- **FR-004**: Requests to legacy run pages MUST behave as not found (no redirects).
- **FR-005**: Drift-related entities MUST reference canonical runs for baseline/current/last-seen run linkage.
- **FR-006**: Backup schedule retention/purge workflows MUST operate without relying on legacy run stores.
- **FR-007**: Entra group sync run history and links MUST reference canonical runs only.
- **FR-008**: The system MUST enforce workspace-first access rules for canonical run list and detail views:
- non-member or not entitled to the workspace scope → not found (404)
- member but missing capability → forbidden (403)
- **FR-009**: Automated architecture checks MUST prevent reintroduction of legacy run concepts by failing CI if legacy tokens are found anywhere in: `app/`, `database/`, `resources/`, `routes/`.
- **FR-010**: The system MUST NOT backfill historical legacy run records into the canonical run system; only new runs are guaranteed to be represented as canonical runs.
- **FR-011**: Backup schedule retention and purge executions MUST each create their own canonical run records, observable in the canonical Operations UI.
- **FR-012**: The system MUST standardize and enforce the canonical `run_type` identifiers for runs created by this feature:
- `inventory_sync`
- `drift_generate_findings`
- `entra_group_sync`
- `backup_schedule_run`
- `backup_schedule_retention`
- `backup_schedule_purge`
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations run list | Admin UI | None | Filtered list + open run | View | None | None | N/A | N/A | Yes (via canonical runs) | All run types converge here |
| Operations run detail | Admin UI | None | N/A | N/A | None | None | None | N/A | Yes (via canonical runs) | Single canonical view for run details |
| Backup schedule detail | Admin UI | None | Links to canonical runs | View | None | None | N/A | N/A | Yes | Legacy run tab removed |
### Key Entities *(include if feature involves data)*
- **Operation Run**: A canonical record representing a long-running operation, including type, status, timestamps, context, and summary.
- **Drift Finding**: A drift detection result that may reference baseline and current runs.
- **Inventory Item**: An inventory record that may reference the last run in which it was observed.
- **Backup Schedule**: A recurring configuration that triggers backup operations and may trigger retention/purge operations.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of new inventory sync, Entra group sync, and backup schedule executions produce exactly one canonical run record that is visible in the canonical Operations run list.
- **SC-002**: Legacy run pages are not accessible (attempts to access legacy run URLs result in not found).
- **SC-003**: Drift creation and drift UI flows do not depend on legacy run stores; baseline/current references resolve to canonical runs when mappings exist.
- **SC-004**: The automated architecture guard blocks reintroduction of legacy run concepts and fails the build when legacy tokens reappear.
- **SC-005**: Backup schedule retention/purge workflows complete successfully without legacy run stores and are observable through canonical runs.
- **SC-006**: Each retention and each purge execution produces a canonical run record (independent from the associated backup schedule run).
## Assumptions
- Historical legacy-only run details do not need to be preserved; pre-existing legacy run history may be lost after legacy tables are removed.
- The canonical Operations UI already exists and is the single supported run viewer.
## Dependencies
- Workspace-scoped run access rules remain enforced consistently across list and detail views.

View File

@ -0,0 +1,179 @@
---
description: "Task list for Spec 087 implementation"
---
# Tasks: Legacy Runs Removal (Spec 087)
**Input**: Design documents from `/specs/087-legacy-runs-removal/`
**Tests**: REQUIRED (Pest) because this feature changes runtime behavior.
---
## Phase 1: Setup (Docs + Local Validation)
- [X] T001 Validate and (if needed) update specs/087-legacy-runs-removal/quickstart.md with the exact focused test commands for this spec
- [X] T002 Capture the final run_type allow-list in specs/087-legacy-runs-removal/spec.md (FR-012) and ensure tasks below only create those values
---
## Phase 2: Foundational (Blocking Prerequisites)
**⚠️ CRITICAL**: Complete this phase before starting any user story work.
- [X] T003 Update canonical run type identifiers to underscore values for this feature only:
- Update app/Support/OperationRunType.php (enum App\\Support\\OperationRunType) so new runs store the underscore identifiers from FR-012
- Change ONLY the affected enum case values (leave unrelated run types unchanged):
- InventorySync
- DirectoryGroupsSync
- DriftGenerate
- BackupScheduleRunNow
- BackupScheduleRetry
- BackupScheduleScheduled
- Scope note: OperationRun creation accepts a raw string type; enforcement is via standardized usage + tests in this spec (no global renames)
- [X] T004 Create migration database/migrations/2026_02_12_000001_canonicalize_operation_run_types.php to rewrite existing operation_runs.type values (dotted → underscore) for affected workflows (FR-012). Acceptance criteria: perform these rewrites (and only these):
- inventory.sync → inventory_sync
- directory_groups.sync → entra_group_sync
- drift.generate → drift_generate_findings
- backup_schedule.run_now → backup_schedule_run
- backup_schedule.retry → backup_schedule_run
- backup_schedule.scheduled → backup_schedule_run
- [X] T005 [P] Add/adjust OperationRun RBAC semantics tests (404 vs 403) in tests/Feature/Monitoring/MonitoringOperationsTest.php and/or tests/Feature/Operations/ (FR-008)
- [X] T006 [P] Add architecture guard test tests/Feature/Guards/NoLegacyRunsTest.php scanning app/, database/, resources/, routes/ for legacy run tokens (FR-009). Use this initial forbidden token list:
- InventorySyncRun, inventory_sync_runs, InventorySyncRunResource
- EntraGroupSyncRun, entra_group_sync_runs, EntraGroupSyncRunResource
- BackupScheduleRun, backup_schedule_runs, BackupScheduleRunsRelationManager
Exclusions (minimum): vendor/, storage/, specs/, spechistory/, references/, bootstrap/cache/
- [X] T007 [P] Remove legacy run badge domains/mappers and rely on OperationRun status/outcome badge domains by updating app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, and tests/Unit/Badges/RunStatusBadgesTest.php (BADGE-001)
- [X] T043 [P] Add explicit “no backfill of legacy run history” regression coverage:
- Confirm no migration/job creates new operation_runs by reading legacy run tables (reference/FK backfills are allowed)
- Add a focused test under tests/Feature/Guards/ or tests/Feature/Database/ to enforce this (FR-010)
NOTE: routes/web.php currently has a legacy convenience redirect (/admin/t/{tenant}/operations → /admin/operations). It is not a legacy *run page*; keep or change it explicitly as part of US2 decisions.
**Checkpoint**: Canonical run types are stable, guard exists, and operations RBAC semantics are enforced by tests.
---
## Phase 3: User Story 1 — Single canonical run history (Priority: P1) 🎯 MVP
**Goal**: New inventory sync, Entra group sync, and backup schedule executions produce exactly one canonical OperationRun each, visible in Operations UI.
**Independent Test**: Trigger each run type once and verify it appears in the Operations run list and can be opened in the canonical run viewer.
### Tests (write first)
- [X] T008 [P] [US1] Update inventory sync run creation expectations in tests/Feature/Inventory/InventorySyncServiceTest.php to assert an operation_runs row exists with type inventory_sync
- [X] T009 [P] [US1] Update Entra group sync expectations in tests/Feature/DirectoryGroups/StartSyncTest.php and tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php to assert an operation_runs row exists with type entra_group_sync
- [X] T010 [P] [US1] Update backup schedule run expectations in tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php to assert an operation_runs row exists with type backup_schedule_run
- [X] T011 [P] [US1] Add tests for retention + purge creating independent canonical runs in tests/Feature/BackupScheduling/ApplyRetentionJobTest.php and tests/Feature/Console/PurgeNonPersistentDataCommandTest.php (backup_schedule_retention / backup_schedule_purge)
- [X] T044 [P] [US1] Add/adjust backup schedule tests to cover skipped/canceled schedule runs still producing a canonical operation_run with a clear terminal outcome (spec Edge Case)
### Implementation
- [X] T012 [US1] Refactor app/Services/Inventory/InventorySyncService.php to stop creating InventorySyncRun rows and instead create/update a canonical OperationRun (FR-001/FR-002)
- [X] T013 [US1] Refactor app/Jobs/EntraGroupSyncJob.php to stop updating EntraGroupSyncRun and instead create/update a canonical OperationRun (FR-001/FR-002)
- [X] T014 [US1] Refactor app/Jobs/RunBackupScheduleJob.php to stop reading/updating BackupScheduleRun status as the canonical record and instead use a canonical OperationRun with schedule metadata stored in operation_runs.context (FR-001/FR-002)
- [X] T015 [US1] Refactor app/Jobs/ApplyBackupScheduleRetentionJob.php to remove BackupScheduleRun dependency and track retention via a canonical OperationRun (FR-006/FR-011)
- [X] T016 [US1] Refactor app/Console/Commands/TenantpilotPurgeNonPersistentData.php to track purge via a canonical OperationRun and remove BackupScheduleRun queries (FR-006/FR-011)
- [X] T017 [US1] Refactor app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php to reconcile based on operation_runs only (remove BackupScheduleRun reads) (FR-001/FR-006)
- [X] T018 [US1] Update run status notifications/links to point to canonical run viewer by editing app/Notifications/RunStatusChangedNotification.php and app/Notifications/OperationRunQueued.php (FR-001)
- [X] T019 [US1] Update inventory run “last run” widget links to canonical runs by editing app/Filament/Widgets/Inventory/InventoryKpiHeader.php (FR-001)
**Checkpoint**: US1 tests pass and every new execution yields a canonical OperationRun of the correct underscore run_type.
---
## Phase 4: User Story 2 — Legacy run UI is intentionally gone (Priority: P1)
**Goal**: Legacy run pages/resources are removed; old links return 404; no legacy run resources appear in navigation.
**Independent Test**: Request known legacy run URLs and verify 404; verify navigation has no legacy run resources.
### Tests (write first)
- [X] T020 [P] [US2] Replace redirect-based legacy tests with 404 assertions in tests/Feature/Operations/LegacyRunRedirectTest.php (FR-004)
- [X] T021 [P] [US2] Add explicit “legacy run routes not found” coverage in tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php for inventory/entra/backup schedule legacy run URLs (FR-004)
### Implementation
- [X] T022 [US2] Remove legacy Inventory Sync run Filament resource by deleting app/Filament/Resources/InventorySyncRunResource.php and app/Filament/Resources/InventorySyncRunResource/Pages/ (FR-003)
- [X] T023 [US2] Remove legacy Entra Group Sync run Filament resource by deleting app/Filament/Resources/EntraGroupSyncRunResource.php and app/Filament/Resources/EntraGroupSyncRunResource/Pages/ (FR-003)
- [X] T024 [US2] Remove legacy backup schedule run history relation manager by deleting app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (FR-003)
- [X] T025 [US2] Remove navigation links to legacy run resources by updating app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php and any other references found under app/Filament/ (FR-003)
- [X] T026 [US2] Remove legacy run UI tests and replace with canonical Operations coverage by updating/deleting tests/Feature/Filament/InventorySyncRunResourceTest.php, tests/Feature/Filament/EntraGroupSyncRunResourceTest.php, and tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php (FR-003)
- [X] T027 [US2] Remove legacy run badge mappers now unused by UI by deleting app/Support/Inventory/InventorySyncStatusBadge.php and app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php (FR-003/BADGE-001)
NOTE: Badge cleanup is handled by T007. If any legacy badge mapper is still referenced, remove it as part of T007 and treat this note as satisfied.
**Checkpoint**: Legacy run URLs are 404 (no redirects) and legacy run resources no longer exist.
---
## Phase 5: User Story 3 — Drift references canonical runs (Priority: P1)
**Goal**: Drift baseline/current/last-seen references use canonical OperationRuns; historical records without a safe mapping render without errors.
**Independent Test**: Generate drift findings and confirm baseline/current reference canonical run IDs; verify null-safe behavior when mapping is absent.
### Tests (write first)
- [X] T028 [P] [US3] Update drift baseline/current selection tests under tests/Feature/Drift/ (directory) to create OperationRun records instead of InventorySyncRun
- [X] T029 [P] [US3] Update RBAC drift landing UI enforcement in tests/Feature/Rbac/DriftLandingUiEnforcementTest.php to avoid InventorySyncRunResource and assert canonical run links
### Data model + migrations
- [X] T030 [US3] Create migration database/migrations/2026_02_12_000002_add_operation_run_ids_to_findings_table.php adding findings.baseline_operation_run_id and findings.current_operation_run_id (nullable FKs to operation_runs)
- [X] T031 [US3] Create migration database/migrations/2026_02_12_000003_backfill_findings_operation_run_ids.php that backfills the new findings columns by joining through inventory_sync_runs.operation_run_id (null-safe)
- [X] T032 [US3] Create migration database/migrations/2026_02_12_000004_backfill_inventory_items_last_seen_operation_run_id.php backfilling inventory_items.last_seen_operation_run_id from inventory_items.last_seen_run_id via inventory_sync_runs.operation_run_id
### Application changes
- [X] T033 [US3] Refactor drift run selection to use OperationRuns by updating app/Services/Drift/DriftRunSelector.php and app/Services/Drift/DriftScopeKey.php (FR-005)
- [X] T034 [US3] Refactor drift finding generation to accept/use OperationRuns by updating app/Services/Drift/DriftFindingGenerator.php (FR-005)
- [X] T035 [US3] Update drift landing UI to link to canonical OperationRuns (not legacy resources) by updating app/Filament/Pages/DriftLanding.php (FR-003/FR-005)
### Cutover + cleanup
- [X] T036 [US3] Create migration database/migrations/2026_02_12_000005_drop_legacy_run_id_columns_from_findings_and_inventory_items.php dropping findings.baseline_run_id/current_run_id and inventory_items.last_seen_run_id after code cutover (FR-005)
**Checkpoint**: Drift reads/writes canonical operation run references and remains functional with null mappings.
---
## Phase 6: Drop legacy tables (Data + Code)
**Purpose**: Remove legacy run storage permanently after all cutovers.
- [X] T037 Create migration database/migrations/2026_02_12_000006_drop_legacy_run_tables.php dropping inventory_sync_runs, entra_group_sync_runs, and backup_schedule_runs (FR-001)
- [X] T038 [P] Delete legacy run Eloquent models app/Models/InventorySyncRun.php, app/Models/EntraGroupSyncRun.php, and app/Models/BackupScheduleRun.php (FR-001)
- [X] T039 [P] Delete legacy factories database/factories/InventorySyncRunFactory.php and database/factories/EntraGroupSyncRunFactory.php and update any references in tests/Feature/ (FR-001)
- [X] T040 Update any remaining references to legacy run tables (tokens caught by the guard) under app/, database/, resources/, routes/ until tests/Feature/Guards/NoLegacyRunsTest.php passes (FR-009)
---
## Phase 7: Polish & Cross-Cutting Concerns
- [X] T041 Run formatting for touched files with vendor/bin/sail bin pint --dirty and fix any style issues in app/ and tests/
- [X] T042 Run focused test pack for this spec (at minimum): tests/Feature/Guards/NoLegacyRunsTest.php, tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php, tests/Feature/Monitoring/MonitoringOperationsTest.php, tests/Feature/Drift/ (directory)
---
## Dependencies & Execution Order
### Story dependency graph
- Phase 2 blocks everything.
- US1 (canonical run creation) should land before US2 (removing UI) to avoid removing the only user-visible run history.
- US3 (drift cutover) can proceed after Phase 2, but MUST complete before Phase 6 (dropping legacy tables).
### Parallel opportunities (examples)
- After Phase 2:
- US1 implementation tasks in app/Services/Inventory/InventorySyncService.php and app/Jobs/EntraGroupSyncJob.php can be done in parallel.
- US2 removal tasks (Filament resource deletion) can proceed in parallel with US3 migrations/code, as long as any shared files are coordinated.
## Implementation Strategy (MVP)
- MVP = Phase 2 + US1 + the minimum of US2 needed to remove navigation entry points.
- Then complete US3 cutover and only then drop legacy tables.

View File

@ -2,8 +2,8 @@
use App\Jobs\ApplyBackupScheduleRetentionJob; use App\Jobs\ApplyBackupScheduleRetentionJob;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun;
use Filament\Facades\Filament; use Filament\Facades\Filament;
test('retention keeps last N backup sets per schedule', function () { test('retention keeps last N backup sets per schedule', function () {
@ -35,18 +35,33 @@
]); ]);
}); });
// Oldest → newest $completedAt = now('UTC')->startOfMinute()->subMinutes(10);
$scheduledFor = now('UTC')->startOfMinute()->subMinutes(10);
foreach ($sets as $set) { foreach ($sets as $set) {
BackupScheduleRun::query()->create([ OperationRun::query()->create([
'backup_schedule_id' => $schedule->id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->id, 'tenant_id' => (int) $tenant->id,
'scheduled_for' => $scheduledFor, 'user_id' => null,
'status' => BackupScheduleRun::STATUS_SUCCESS, 'initiator_name' => 'System',
'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0], 'type' => 'backup_schedule_run',
'backup_set_id' => $set->id, 'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', 'retention-test:'.$schedule->id.':'.$set->id),
'summary_counts' => [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
],
'failure_summary' => [],
'context' => [
'backup_schedule_id' => (int) $schedule->id,
'backup_set_id' => (int) $set->id,
],
'started_at' => $completedAt,
'completed_at' => $completedAt,
]); ]);
$scheduledFor = $scheduledFor->addMinute();
$completedAt = $completedAt->addMinute();
} }
ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id); ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id);
@ -64,4 +79,15 @@
foreach ($deleted as $set) { foreach ($deleted as $set) {
$this->assertSoftDeleted('backup_sets', ['id' => $set->id]); $this->assertSoftDeleted('backup_sets', ['id' => $set->id]);
} }
$retentionRun = OperationRun::query()
->where('tenant_id', (int) $tenant->id)
->where('type', 'backup_schedule_retention')
->latest('id')
->first();
expect($retentionRun)->not->toBeNull();
expect($retentionRun?->status)->toBe('completed');
expect($retentionRun?->outcome)->toBe('succeeded');
expect($retentionRun?->summary_counts['succeeded'] ?? null)->toBe(3);
}); });

View File

@ -1,53 +0,0 @@
<?php
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
test('backup schedule run view modal renders run details', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$backupSet = BackupSet::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Set 174',
'status' => 'completed',
'item_count' => 0,
]);
$run = BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => [
'policies_total' => 7,
'policies_backed_up' => 7,
'errors_count' => 0,
],
'error_code' => null,
'error_message' => null,
'backup_set_id' => $backupSet->id,
]);
$this->actingAs($user);
$html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render();
expect($html)->toContain('Scheduled for');
expect($html)->toContain('Status');
expect($html)->toContain('Summary');
expect($html)->toContain((string) $backupSet->id);
});

View File

@ -35,11 +35,9 @@
$dispatcher->dispatchDue([$tenant->external_id]); $dispatcher->dispatchDue([$tenant->external_id]);
$dispatcher->dispatchDue([$tenant->external_id]); $dispatcher->dispatchDue([$tenant->external_id]);
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'backup_schedule.scheduled') ->where('type', 'backup_schedule_run')
->count())->toBe(1); ->count())->toBe(1);
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
@ -48,7 +46,7 @@
return $job->backupScheduleId !== null return $job->backupScheduleId !== null
&& $job->backupScheduleRunId === 0 && $job->backupScheduleRunId === 0
&& $job->operationRun?->tenant_id === $tenant->getKey() && $job->operationRun?->tenant_id === $tenant->getKey()
&& $job->operationRun?->type === 'backup_schedule.scheduled'; && $job->operationRun?->type === 'backup_schedule_run';
}); });
}); });
@ -77,7 +75,7 @@
$operationRunService->ensureRunWithIdentityStrict( $operationRunService->ensureRunWithIdentityStrict(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.scheduled', type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
@ -93,13 +91,11 @@
$dispatcher = app(BackupScheduleDispatcher::class); $dispatcher = app(BackupScheduleDispatcher::class);
$dispatcher->dispatchDue([$tenant->external_id]); $dispatcher->dispatchDue([$tenant->external_id]);
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
Bus::assertNotDispatched(RunBackupScheduleJob::class); Bus::assertNotDispatched(RunBackupScheduleJob::class);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'backup_schedule.scheduled') ->where('type', 'backup_schedule_run')
->count())->toBe(1); ->count())->toBe(1);
$schedule->refresh(); $schedule->refresh();

View File

@ -1,17 +1,24 @@
<?php <?php
use App\Jobs\ApplyBackupScheduleRetentionJob;
use App\Jobs\RunBackupScheduleJob; use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Contracts\Queue\Job; use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
it('creates a backup set and marks the operation run successful', function () {
Bus::fake();
it('creates a backup set and marks the run successful', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -31,18 +38,11 @@
'next_run_at' => null, '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 */ /** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun( $operationRun = $operationRunService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.run_now', type: 'backup_schedule_run',
inputs: ['backup_schedule_id' => (int) $schedule->id], inputs: ['backup_schedule_id' => (int) $schedule->id],
initiator: $user, initiator: $user,
); );
@ -75,33 +75,35 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
Cache::flush(); Cache::flush();
(new RunBackupScheduleJob($run->id, $operationRun))->handle( (new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle(
app(PolicySyncService::class), app(PolicySyncService::class),
app(BackupService::class), app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class), app(AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class), app(RunErrorMapper::class),
); );
$run->refresh(); $schedule->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); expect($schedule->last_run_status)->toBe('success');
expect($run->backup_set_id)->toBe($backupSet->id);
$operationRun->refresh(); $operationRun->refresh();
expect($operationRun->status)->toBe('completed'); expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded'); expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->context)->toMatchArray([ expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
'backup_set_id' => (int) $backupSet->id, 'backup_set_id' => (int) $backupSet->id,
]); ]);
expect($operationRun->summary_counts)->toMatchArray([ expect($operationRun->summary_counts)->toMatchArray([
'created' => 1, 'created' => 1,
]); ]);
Bus::assertDispatched(ApplyBackupScheduleRetentionJob::class);
}); });
it('skips runs when all policy types are unknown', function () { it('skips runs when all policy types are unknown', function () {
Bus::fake();
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -121,44 +123,41 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
'next_run_at' => null, '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 */ /** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun( $operationRun = $operationRunService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.run_now', type: 'backup_schedule_run',
inputs: ['backup_schedule_id' => (int) $schedule->id], inputs: ['backup_schedule_id' => (int) $schedule->id],
initiator: $user, initiator: $user,
); );
(new RunBackupScheduleJob($run->id, $operationRun))->handle( Cache::flush();
(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle(
app(PolicySyncService::class), app(PolicySyncService::class),
app(BackupService::class), app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class), app(AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class), app(RunErrorMapper::class),
); );
$run->refresh(); $schedule->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); expect($schedule->last_run_status)->toBe('skipped');
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
expect($run->backup_set_id)->toBeNull();
$operationRun->refresh(); $operationRun->refresh();
expect($operationRun->status)->toBe('completed'); expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('failed'); expect($operationRun->outcome)->toBe('blocked');
expect($operationRun->failure_summary)->toMatchArray([ expect($operationRun->failure_summary)->toMatchArray([
['code' => 'unknown_policy_type', 'message' => $run->error_message, 'reason_code' => 'unknown_error'], [
'code' => 'unknown_policy_type',
'message' => 'All configured policy types are unknown.',
'reason_code' => 'unknown_error',
],
]); ]);
Bus::assertNotDispatched(ApplyBackupScheduleRetentionJob::class);
}); });
it('fails fast when operation run context is not passed into the job', function () { it('fails fast when operation run context is not passed into the job', function () {
@ -181,28 +180,18 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
'next_run_at' => null, '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,
]);
$queueJob = \Mockery::mock(Job::class); $queueJob = \Mockery::mock(Job::class);
$queueJob->shouldReceive('fail')->once(); $queueJob->shouldReceive('fail')->once();
$job = new RunBackupScheduleJob($run->id); $job = new RunBackupScheduleJob(0, null, (int) $schedule->id);
$job->setJob($queueJob); $job->setJob($queueJob);
$job->handle( $job->handle(
app(PolicySyncService::class), app(PolicySyncService::class),
app(BackupService::class), app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class), app(AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class), app(RunErrorMapper::class),
); );
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_RUNNING);
}); });

View File

@ -49,12 +49,9 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule); ->callTableAction('runNow', $schedule);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$operationRun = OperationRun::query() $operationRun = OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now') ->where('type', 'backup_schedule_run')
->first(); ->first();
expect($operationRun)->not->toBeNull(); expect($operationRun)->not->toBeNull();
@ -113,12 +110,9 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule); ->callTableAction('runNow', $schedule);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$runs = OperationRun::query() $runs = OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now') ->where('type', 'backup_schedule_run')
->pluck('id') ->pluck('id')
->all(); ->all();
@ -153,12 +147,9 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule); ->callTableAction('retry', $schedule);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$operationRun = OperationRun::query() $operationRun = OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry') ->where('type', 'backup_schedule_run')
->first(); ->first();
expect($operationRun)->not->toBeNull(); expect($operationRun)->not->toBeNull();
@ -180,7 +171,7 @@
'notifiable_type' => User::class, 'notifiable_type' => User::class,
'type' => OperationRunQueued::class, 'type' => OperationRunQueued::class,
'data->format' => 'filament', 'data->format' => 'filament',
'data->title' => 'Backup schedule retry queued', 'data->title' => 'Backup schedule run queued',
]); ]);
$notification = $user->notifications()->latest('id')->first(); $notification = $user->notifications()->latest('id')->first();
@ -216,12 +207,9 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule); ->callTableAction('retry', $schedule);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$runs = OperationRun::query() $runs = OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry') ->where('type', 'backup_schedule_run')
->pluck('id') ->pluck('id')
->all(); ->all();
@ -265,12 +253,9 @@
// Action should be hidden/blocked for readonly users. // Action should be hidden/blocked for readonly users.
} }
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry']) ->whereIn('type', ['backup_schedule_run', 'backup_schedule_run'])
->count()) ->count())
->toBe(0); ->toBe(0);
}); });
@ -312,18 +297,15 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB]));
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(0);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now') ->where('type', 'backup_schedule_run')
->count()) ->count())
->toBe(2); ->toBe(2);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now') ->where('type', 'backup_schedule_run')
->pluck('user_id') ->pluck('user_id')
->unique() ->unique()
->values() ->values()
@ -381,18 +363,15 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(0);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry') ->where('type', 'backup_schedule_run')
->count()) ->count())
->toBe(2); ->toBe(2);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry') ->where('type', 'backup_schedule_run')
->pluck('user_id') ->pluck('user_id')
->unique() ->unique()
->values() ->values()
@ -452,7 +431,7 @@
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$existing = $operationRunService->ensureRunWithIdentity( $existing = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule.retry', type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $scheduleA->getKey(), 'backup_schedule_id' => (int) $scheduleA->getKey(),
'nonce' => 'existing', 'nonce' => 'existing',
@ -471,12 +450,9 @@
Livewire::test(ListBackupSchedules::class) Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(0);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry') ->where('type', 'backup_schedule_run')
->count()) ->count())
->toBe(3); ->toBe(3);

View File

@ -50,7 +50,7 @@
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $user->id, 'user_id' => $user->id,
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'backup_schedule.run_now', 'type' => 'backup_schedule_run',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'context' => ['scope' => 'scheduled'], 'context' => ['scope' => 'scheduled'],

View File

@ -3,7 +3,6 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
@ -101,22 +100,9 @@
'next_run_at' => now()->addHour(), 'next_run_at' => now()->addHour(),
]); ]);
BackupScheduleRun::create([
'backup_schedule_id' => $scheduleA->id,
'tenant_id' => $tenantA->id,
'scheduled_for' => now()->startOfMinute(),
'started_at' => null,
'finished_at' => null,
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => null,
'error_code' => null,
'error_message' => null,
'backup_set_id' => $backupSetA->id,
]);
expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
$this->artisan('tenantpilot:purge-nonpersistent', [ $this->artisan('tenantpilot:purge-nonpersistent', [
'tenant' => $tenantA->id, 'tenant' => $tenantA->id,
@ -130,8 +116,11 @@
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(OperationRun::query()
->where('tenant_id', $tenantA->id)
->where('type', 'backup_schedule_purge')
->exists())->toBeTrue();
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);

View File

@ -1,7 +1,6 @@
<?php <?php
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@ -9,7 +8,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('reconciles completed backup schedule runs into operation runs', function () { it('reconciles stale running backup schedule operation runs', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$schedule = BackupSchedule::create([ $schedule = BackupSchedule::create([
@ -29,39 +28,24 @@
]); ]);
$startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'); $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([ $operationRun = OperationRun::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => null, 'user_id' => null,
'initiator_name' => 'System', 'initiator_name' => 'System',
'type' => 'backup_schedule.run_now', 'type' => 'backup_schedule_run',
'status' => 'queued', 'status' => 'running',
'outcome' => 'pending', 'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id), 'run_identity_hash' => hash('sha256', 'backup_schedule_run:'.$schedule->id),
'summary_counts' => [], 'summary_counts' => [],
'failure_summary' => [], 'failure_summary' => [],
'context' => [ 'context' => [
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $scheduleRun->id,
], ],
'started_at' => $startedAt,
'created_at' => $startedAt,
'updated_at' => $startedAt,
]); ]);
$this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [ $this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [
@ -72,21 +56,15 @@
$operationRun->refresh(); $operationRun->refresh();
expect($operationRun->status)->toBe('completed'); expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded'); expect($operationRun->outcome)->toBe('failed');
expect($operationRun->failure_summary)->toBe([]); expect($operationRun->failure_summary)->toMatchArray([
[
expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s')); 'code' => 'backup_schedule.stalled',
expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s')); 'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
'reason_code' => 'unknown_error',
],
]);
expect($operationRun->context)->toMatchArray([ expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $scheduleRun->id,
]);
expect($operationRun->summary_counts)->toMatchArray([
'total' => 5,
'processed' => 5,
'succeeded' => 18,
'items' => 5,
]); ]);
}); });

View File

@ -17,25 +17,15 @@
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC')); CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC'));
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
Artisan::call('tenantpilot:directory-groups:dispatch', [ Artisan::call('tenantpilot:directory-groups:dispatch', [
'--tenant' => [$tenant->tenant_id], '--tenant' => [$tenant->tenant_id],
]); ]);
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
$legacyCountAfter = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
expect($legacyCountAfter)->toBe($legacyCountBefore);
$opRun = OperationRun::query() $opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'directory_groups.sync') ->where('type', 'entra_group_sync')
->where('context->slot_key', $slotKey) ->where('context->slot_key', $slotKey)
->first(); ->first();

View File

@ -26,22 +26,12 @@
$tenant->makeCurrent(); $tenant->makeCurrent();
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
Livewire::test(ListEntraGroups::class) Livewire::test(ListEntraGroups::class)
->callAction('sync_groups'); ->callAction('sync_groups');
$legacyCountAfter = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
expect($legacyCountAfter)->toBe($legacyCountBefore);
$opRun = OperationRun::query() $opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'directory_groups.sync') ->where('type', 'entra_group_sync')
->latest('id') ->latest('id')
->first(); ->first();

View File

@ -17,7 +17,7 @@
expect($run)->toBeInstanceOf(OperationRun::class) expect($run)->toBeInstanceOf(OperationRun::class)
->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->user_id)->toBe($user->getKey()) ->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe('directory_groups.sync') ->and($run->type)->toBe('entra_group_sync')
->and($run->status)->toBe('queued') ->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all'); ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all');

View File

@ -54,7 +54,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: 'directory_groups.sync', type: 'entra_group_sync',
inputs: ['selection_key' => 'groups-v1:all'], inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user, initiator: $user,
); );

View File

@ -34,7 +34,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: 'directory_groups.sync', type: 'entra_group_sync',
inputs: ['selection_key' => 'groups-v1:all'], inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user, initiator: $user,
); );

View File

@ -1,7 +1,6 @@
<?php <?php
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Services\Drift\DriftFindingGenerator; use App\Services\Drift\DriftFindingGenerator;
@ -11,17 +10,17 @@
$scopeKey = hash('sha256', 'scope-assignments'); $scopeKey = hash('sha256', 'scope-assignments');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);

View File

@ -1,6 +1,5 @@
<?php <?php
use App\Models\InventorySyncRun;
use App\Services\Drift\DriftRunSelector; use App\Services\Drift\DriftRunSelector;
test('it selects the previous and latest successful runs for the same scope', function () { test('it selects the previous and latest successful runs for the same scope', function () {
@ -9,27 +8,27 @@
$scopeKey = hash('sha256', 'scope-a'); $scopeKey = hash('sha256', 'scope-a');
InventorySyncRun::factory()->for($tenant)->create([ createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(3), 'finished_at' => now()->subDays(3),
]); ]);
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);
InventorySyncRun::factory()->for($tenant)->create([ createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'status' => InventorySyncRun::STATUS_FAILED, 'status' => 'failed',
'finished_at' => now(), 'finished_at' => now(),
]); ]);
@ -48,9 +47,9 @@
$scopeKey = hash('sha256', 'scope-b'); $scopeKey = hash('sha256', 'scope-b');
InventorySyncRun::factory()->for($tenant)->create([ createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);

View File

@ -2,7 +2,6 @@
use App\Filament\Pages\DriftLanding; use App\Filament\Pages\DriftLanding;
use App\Jobs\GenerateDriftFindingsJob; use App\Jobs\GenerateDriftFindingsJob;
use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -17,17 +16,17 @@
$scopeKey = hash('sha256', 'scope-zero-findings'); $scopeKey = hash('sha256', 'scope-zero-findings');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);
@ -35,7 +34,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'drift.generate', 'type' => 'drift_generate_findings',
'status' => 'completed', 'status' => 'completed',
'outcome' => 'succeeded', 'outcome' => 'succeeded',
'run_identity_hash' => 'drift-zero-findings', 'run_identity_hash' => 'drift-zero-findings',
@ -48,8 +47,8 @@
], ],
'context' => [ 'context' => [
'scope_key' => $scopeKey, 'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(), 'baseline_operation_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(), 'current_operation_run_id' => (int) $current->getKey(),
], ],
]); ]);

View File

@ -4,7 +4,6 @@
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
@ -14,15 +13,15 @@
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = createUserWithTenant(role: 'manager');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => hash('sha256', 'scope-assignments-diff'), 'selection_hash' => hash('sha256', 'scope-assignments-diff'),
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $baseline->selection_hash, 'selection_hash' => $baseline->selection_hash,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);
@ -104,8 +103,8 @@
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => (string) $current->selection_hash, 'scope_key' => (string) $current->selection_hash,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'assignment', 'subject_type' => 'assignment',
'subject_external_id' => $policy->external_id, 'subject_external_id' => $policy->external_id,
'evidence_jsonb' => [ 'evidence_jsonb' => [

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
@ -12,15 +11,15 @@
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = createUserWithTenant(role: 'manager');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => hash('sha256', 'scope-scope-tags-diff'), 'selection_hash' => hash('sha256', 'scope-scope-tags-diff'),
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $baseline->selection_hash, 'selection_hash' => $baseline->selection_hash,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);
@ -61,8 +60,8 @@
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => (string) $current->selection_hash, 'scope_key' => (string) $current->selection_hash,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'scope_tag', 'subject_type' => 'scope_tag',
'subject_external_id' => $policy->external_id, 'subject_external_id' => $policy->external_id,
'evidence_jsonb' => [ 'evidence_jsonb' => [

View File

@ -3,7 +3,6 @@
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
@ -12,15 +11,15 @@
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = createUserWithTenant(role: 'manager');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => hash('sha256', 'scope-settings-diff'), 'selection_hash' => hash('sha256', 'scope-settings-diff'),
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $baseline->selection_hash, 'selection_hash' => $baseline->selection_hash,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);
@ -59,8 +58,8 @@
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => (string) $current->selection_hash, 'scope_key' => (string) $current->selection_hash,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
'subject_external_id' => $policy->external_id, 'subject_external_id' => $policy->external_id,
'evidence_jsonb' => [ 'evidence_jsonb' => [

View File

@ -3,30 +3,29 @@
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
test('finding detail renders without Graph calls', function () { test('finding detail renders without Graph calls', function () {
bindFailHardGraphClient(); bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = createUserWithTenant(role: 'manager');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => hash('sha256', 'scope-detail'), 'selection_hash' => hash('sha256', 'scope-detail'),
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $baseline->selection_hash, 'selection_hash' => $baseline->selection_hash,
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT, 'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => (string) $current->selection_hash, 'scope_key' => (string) $current->selection_hash,
'baseline_run_id' => $baseline->getKey(), 'baseline_operation_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'deviceConfiguration', 'subject_type' => 'deviceConfiguration',
'subject_external_id' => 'policy-123', 'subject_external_id' => 'policy-123',
'evidence_jsonb' => [ 'evidence_jsonb' => [

View File

@ -1,7 +1,6 @@
<?php <?php
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Services\Drift\DriftFindingGenerator; use App\Services\Drift\DriftFindingGenerator;
@ -11,17 +10,17 @@
$scopeKey = hash('sha256', 'scope-determinism'); $scopeKey = hash('sha256', 'scope-determinism');
$baseline = InventorySyncRun::factory()->for($tenant)->create([ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDays(2), 'finished_at' => now()->subDays(2),
]); ]);
$current = InventorySyncRun::factory()->for($tenant)->create([ $current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']], 'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'status' => InventorySyncRun::STATUS_SUCCESS, 'status' => 'success',
'finished_at' => now()->subDay(), 'finished_at' => now()->subDay(),
]); ]);

Some files were not shown because too many files have changed in this diff Show More