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)
- 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)
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -46,8 +47,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## 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
- 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 END -->

View File

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

View File

@ -5,7 +5,6 @@
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
@ -14,6 +13,7 @@
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class TenantpilotPurgeNonPersistentData extends Command
@ -80,10 +80,6 @@ public function handle(): int
}
DB::transaction(function () use ($tenant): void {
BackupScheduleRun::query()
->where('tenant_id', $tenant->id)
->delete();
BackupSchedule::query()
->where('tenant_id', $tenant->id)
->delete();
@ -117,6 +113,8 @@ public function handle(): int
->delete();
});
$this->recordPurgeOperationRun($tenant, $counts);
$this->info('Purged.');
}
@ -150,7 +148,6 @@ private function resolveTenants()
private function countsForTenant(Tenant $tenant): array
{
return [
'backup_schedule_runs' => BackupScheduleRun::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(),
'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(),
];
}
/**
* @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;
use App\Models\BackupScheduleRun;
use App\Models\BackupSchedule;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OperationRunOutcome;
use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -16,7 +16,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle(OperationRunService $operationRunService): int
{
@ -25,7 +25,7 @@ public function handle(OperationRunService $operationRunService): int
$dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query()
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) {
@ -49,29 +49,18 @@ public function handle(OperationRunService $operationRunService): int
$failed = 0;
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)) {
$skipped++;
continue;
}
$scheduleRun = BackupScheduleRun::query()
->whereKey((int) $backupScheduleRunId)
->where('tenant_id', $operationRun->tenant_id)
->first();
if (! $scheduleRun) {
if (! is_numeric($backupScheduleId)) {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule_run.not_found',
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
'code' => 'backup_schedule.missing_context',
'message' => 'Backup schedule context is missing from this operation run.',
],
],
);
@ -82,13 +71,34 @@ public function handle(OperationRunService $operationRunService): int
continue;
}
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
if (! $dryRun) {
$operationRunService->updateRun($operationRun, 'running', 'pending');
$schedule = BackupSchedule::query()
->whereKey((int) $backupScheduleId)
->where('tenant_id', (int) $operationRun->tenant_id)
->first();
if ($scheduleRun->started_at) {
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
}
if (! $schedule instanceof BackupSchedule) {
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++;
@ -96,104 +106,27 @@ public function handle(OperationRunService $operationRunService): int
continue;
}
$outcome = match ($scheduleRun->status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
BackupScheduleRun::STATUS_CANCELED => 'failed',
default => 'failed',
};
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
$syncFailures = $summary['sync_failures'] ?? [];
$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),
];
if ($operationRun->status === 'running') {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.stalled',
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
],
],
);
}
$reconciled++;
continue;
}
if (! $dryRun) {
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
]),
]);
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
$operationRun->forceFill([
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
])->save();
}
$reconciled++;
$skipped++;
}
$this->info(sprintf(

View File

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

View File

@ -1707,7 +1707,7 @@ private function dispatchBootstrapJob(
OperationRun $run,
): void {
match ($operationType) {
'inventory.sync' => ProviderInventorySyncJob::dispatch(
'inventory_sync' => ProviderInventorySyncJob::dispatch(
tenantId: $tenantId,
userId: $userId,
providerConnectionId: $providerConnectionId,
@ -1726,7 +1726,7 @@ private function dispatchBootstrapJob(
private function resolveBootstrapCapability(string $operationType): ?string
{
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,
default => null,
};

View File

@ -5,7 +5,6 @@
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\Tenant;
@ -22,6 +21,7 @@
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
@ -282,32 +282,40 @@ public static function table(Table $table): Table
->label('Last run status')
->badge()
->formatStateUsing(function (?string $state): string {
if (! filled($state)) {
$outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return '—';
}
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label;
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->label;
})
->color(function (?string $state): string {
if (! filled($state)) {
$outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return 'gray';
}
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color;
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->color;
})
->icon(function (?string $state): ?string {
if (! filled($state)) {
$outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return null;
}
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon;
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->icon;
})
->iconColor(function (?string $state): string {
if (! filled($state)) {
$outcome = static::scheduleStatusToOutcome($state);
if (! filled($outcome)) {
return 'gray';
}
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state);
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
return $spec->iconColor ?? $spec->color;
}),
@ -389,7 +397,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.run_now',
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -458,7 +466,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.retry',
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -552,7 +560,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.run_now',
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -649,7 +657,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.retry',
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -734,7 +742,6 @@ public static function getRelations(): array
{
return [
BackupScheduleOperationRunsRelationManager::class,
BackupScheduleRunsRelationManager::class,
];
}
@ -904,6 +911,18 @@ protected static function policyTypeLabelMap(): array
->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
{
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;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant;
use App\Models\User;
@ -23,10 +22,10 @@ class ListEntraGroups extends ListRecords
protected function getHeaderActions(): array
{
return [
Action::make('view_group_sync_runs')
->label('Group Sync Runs')
Action::make('view_operations')
->label('Operations')
->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()),
UiEnforcement::forAction(
Action::make('sync_groups')
@ -48,7 +47,7 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'directory_groups.sync',
type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'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_type')->label('Subject type'),
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
TextEntry::make('baseline_run_id')
TextEntry::make('baseline_operation_run_id')
->label('Baseline run')
->url(fn (Finding $record): ?string => $record->baseline_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
: null)
->openUrlInNewTab(),
TextEntry::make('current_run_id')
TextEntry::make('current_operation_run_id')
->label('Current run')
->url(fn (Finding $record): ?string => $record->current_run_id
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
->url(fn (Finding $record): ?string => $record->current_operation_run_id
? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
: null)
->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
@ -297,22 +297,22 @@ public static function table(Table $table): Table
Tables\Filters\Filter::make('run_ids')
->label('Run IDs')
->form([
TextInput::make('baseline_run_id')
TextInput::make('baseline_operation_run_id')
->label('Baseline run id')
->numeric(),
TextInput::make('current_run_id')
TextInput::make('current_operation_run_id')
->label('Current run id')
->numeric(),
])
->query(function (Builder $query, array $data): Builder {
$baselineRunId = $data['baseline_run_id'] ?? null;
$baselineRunId = $data['baseline_operation_run_id'] ?? null;
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)) {
$query->where('current_run_id', (int) $currentRunId);
$query->where('current_operation_run_id', (int) $currentRunId);
}
return $query;

View File

@ -113,14 +113,14 @@ protected function buildAllMatchingQuery(): Builder
}
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
$baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
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)) {
$query->where('current_run_id', (int) $currentRunId);
$query->where('current_operation_run_id', (int) $currentRunId);
}
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]);
})
->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')
->label('Restore')
->badge()
@ -247,7 +236,7 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
Tables\Columns\TextColumn::make('lastSeenRun.status')
Tables\Columns\TextColumn::make('lastSeenRun.outcome')
->label('Run')
->badge()
->formatStateUsing(function (?string $state): string {
@ -255,28 +244,28 @@ public static function table(Table $table): Table
return '—';
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->label;
})
->color(function (?string $state): string {
if (! filled($state)) {
return 'gray';
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->color;
})
->icon(function (?string $state): ?string {
if (! filled($state)) {
return null;
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->icon;
})
->iconColor(function (?string $state): ?string {
if (! filled($state)) {
return 'gray';
}
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state);
return $spec->iconColor ?? $spec->color;
}),

View File

@ -153,12 +153,15 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'inventory.sync',
type: 'inventory_sync',
identityInputs: [
'selection_hash' => $computed['selection_hash'],
],
context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'],
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
]),
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(
tenant: $tenant,
connection: $record,
operationType: 'inventory.sync',
operationType: 'inventory_sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),

View File

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

View File

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

View File

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

View File

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

View File

@ -3,12 +3,15 @@
namespace App\Jobs;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\Intune\AuditLogger;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ApplyBackupScheduleRetentionJob implements ShouldQueue
{
@ -26,6 +29,21 @@ public function handle(AuditLogger $auditLogger): void
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);
if ($keepLast < 1) {
@ -33,55 +51,65 @@ public function handle(AuditLogger $auditLogger): void
}
/** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = BackupScheduleRun::query()
->where('backup_schedule_id', $schedule->id)
->whereNotNull('backup_set_id')
->orderByDesc('scheduled_for')
$keepBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id)
->where('type', 'backup_schedule_run')
->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)
->pluck('backup_set_id')
->filter()
->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)
->values();
/** @var Collection<int, int> $deleteBackupSetIds */
$deleteBackupSetIds = BackupScheduleRun::query()
->where('backup_schedule_id', $schedule->id)
->whereNotNull('backup_set_id')
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all()))
->pluck('backup_set_id')
->filter()
/** @var Collection<int, int> $allBackupSetIds */
$allBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id)
->where('type', 'backup_schedule_run')
->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id)
->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()
->values();
if ($deleteBackupSetIds->isEmpty()) {
$auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.retention_applied',
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => 0,
],
],
);
return;
}
/** @var Collection<int, int> $deleteBackupSetIds */
$deleteBackupSetIds = $allBackupSetIds
->reject(fn (int $backupSetId): bool => $keepBackupSetIds->contains($backupSetId))
->values();
$deletedCount = 0;
BackupSet::query()
->where('tenant_id', $schedule->tenant_id)
->whereIn('id', $deleteBackupSetIds->all())
->whereNull('deleted_at')
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
foreach ($sets as $set) {
$set->delete();
$deletedCount++;
}
});
if ($deleteBackupSetIds->isNotEmpty()) {
BackupSet::query()
->where('tenant_id', $schedule->tenant_id)
->whereIn('id', $deleteBackupSetIds->all())
->whereNull('deleted_at')
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
foreach ($sets as $set) {
$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(
tenant: $schedule->tenant,
@ -93,6 +121,7 @@ public function handle(AuditLogger $auditLogger): void
'metadata' => [
'keep_last' => $keepLast,
'deleted_backup_sets' => $deletedCount,
'operation_run_id' => (int) $operationRun->getKey(),
],
],
);

View File

@ -3,13 +3,12 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Directory\EntraGroupSyncService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use App\Support\OperationRunOutcome;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -51,81 +50,40 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
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 */
$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) {
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
EntraGroupSyncRun::STATUS_FAILED => 'failed',
default => 'failed',
'succeeded' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
default => OperationRunOutcome::Failed->value,
};
$failures = [];
@ -141,48 +99,19 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
'completed',
$opOutcome,
[
// NOTE: summary_counts are normalized to a fixed whitelist for Ops UX.
// Keep keys aligned with App\Support\OpsUx\OperationSummaryKeys.
'total' => $result['items_observed_count'],
'processed' => $result['items_observed_count'],
'updated' => $result['items_upserted_count'],
'failed' => $result['error_count'],
'total' => (int) $result['items_observed_count'],
'processed' => (int) $result['items_observed_count'],
'updated' => (int) $result['items_upserted_count'],
'failed' => (int) $result['error_count'],
],
$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(
tenant: $tenant,
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
action: $terminalStatus === 'succeeded'
? 'directory_groups.sync.succeeded'
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
: ($terminalStatus === 'partial'
? 'directory_groups.sync.partial'
: 'directory_groups.sync.failed'),
context: [
@ -195,41 +124,9 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
'error_category' => $result['error_category'],
],
actorId: $this->operationRun->user_id,
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
status: $terminalStatus === 'failed' ? 'failed' : 'success',
resourceType: 'operation_run',
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;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Drift\DriftFindingGenerator;
@ -45,8 +44,8 @@ public function handle(
): void {
Log::info('GenerateDriftFindingsJob: started', [
'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId,
'baseline_operation_run_id' => $this->baselineRunId,
'current_operation_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey,
]);
@ -78,13 +77,21 @@ public function handle(
throw new RuntimeException('Tenant not found.');
}
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
if (! $baseline instanceof InventorySyncRun) {
$baseline = OperationRun::query()
->whereKey($this->baselineRunId)
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
->first();
if (! $baseline instanceof OperationRun) {
throw new RuntimeException('Baseline run not found.');
}
$current = InventorySyncRun::query()->find($this->currentRunId);
if (! $current instanceof InventorySyncRun) {
$current = OperationRun::query()
->whereKey($this->currentRunId)
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
->first();
if (! $current instanceof OperationRun) {
throw new RuntimeException('Current run not found.');
}
@ -104,8 +111,8 @@ public function handle(
Log::info('GenerateDriftFindingsJob: completed', [
'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId,
'baseline_operation_run_id' => $this->baselineRunId,
'current_operation_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey,
'created_findings_count' => $created,
]);
@ -120,8 +127,8 @@ public function handle(
} catch (Throwable $e) {
Log::error('GenerateDriftFindingsJob: failed', [
'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId,
'baseline_operation_run_id' => $this->baselineRunId,
'current_operation_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey,
'error' => $e->getMessage(),
]);
@ -132,7 +139,7 @@ public function handle(
]);
$runs->appendFailures($this->operationRun, [[
'code' => 'drift.generate.failed',
'code' => 'drift_generate_findings.failed',
'message' => $e->getMessage(),
]]);

View File

@ -4,9 +4,9 @@
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
@ -15,6 +15,7 @@
use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@ -23,15 +24,23 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class RunBackupScheduleJob implements ShouldQueue
{
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 ?OperationRun $operationRun = null;
@ -63,317 +72,19 @@ public function handle(
return;
}
if ($this->backupScheduleId !== null) {
$this->handleFromScheduleId(
backupScheduleId: $this->backupScheduleId,
policySyncService: $policySyncService,
backupService: $backupService,
policyTypeResolver: $policyTypeResolver,
scheduleTimeService: $scheduleTimeService,
auditLogger: $auditLogger,
errorMapper: $errorMapper,
$backupScheduleId = $this->resolveBackupScheduleId();
if ($backupScheduleId <= 0) {
$this->markOperationRunFailed(
run: $this->operationRun,
summaryCounts: [],
reasonCode: 'schedule_not_provided',
reason: 'No backup schedule was provided for this run.',
);
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()
->with('tenant')
->find($backupScheduleId);
@ -402,15 +113,15 @@ private function handleFromScheduleId(
return;
}
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $schedule->getKey(),
]),
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
if ($this->operationRun->status === 'queued') {
$operationRunService->updateRun($this->operationRun, 'running');
}
@ -422,7 +133,7 @@ private function handleFromScheduleId(
$this->finishSchedule(
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
status: self::STATUS_SKIPPED,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
@ -430,11 +141,12 @@ private function handleFromScheduleId(
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
outcome: OperationRunOutcome::Blocked->value,
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
'failed' => 0,
'skipped' => 1,
],
failures: [
[
@ -447,7 +159,7 @@ private function handleFromScheduleId(
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
status: self::STATUS_SKIPPED,
errorMessage: 'Another run is already in progress for this schedule.',
);
@ -495,7 +207,7 @@ private function handleFromScheduleId(
if (empty($validTypes)) {
$this->finishSchedule(
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
status: self::STATUS_SKIPPED,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
@ -503,11 +215,12 @@ private function handleFromScheduleId(
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
outcome: OperationRunOutcome::Blocked->value,
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
'failed' => 0,
'skipped' => 1,
],
failures: [
[
@ -520,7 +233,7 @@ private function handleFromScheduleId(
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
status: self::STATUS_SKIPPED,
errorMessage: 'All configured policy types are unknown.',
);
@ -549,17 +262,17 @@ private function handleFromScheduleId(
);
$status = match ($backupSet->status) {
'completed' => BackupScheduleRun::STATUS_SUCCESS,
'partial' => BackupScheduleRun::STATUS_PARTIAL,
'failed' => BackupScheduleRun::STATUS_FAILED,
default => BackupScheduleRun::STATUS_SUCCESS,
'completed' => self::STATUS_SUCCESS,
'partial' => self::STATUS_PARTIAL,
'failed' => self::STATUS_FAILED,
default => self::STATUS_SUCCESS,
};
$errorCode = null;
$errorMessage = null;
if (! empty($unknownTypes)) {
$status = BackupScheduleRun::STATUS_PARTIAL;
$status = self::STATUS_PARTIAL;
$errorCode = 'UNKNOWN_POLICY_TYPE';
$errorMessage = 'Some configured policy types are unknown and were skipped.';
}
@ -626,9 +339,10 @@ private function handleFromScheduleId(
]);
$outcome = match ($status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
default => 'failed',
self::STATUS_SUCCESS => OperationRunOutcome::Succeeded->value,
self::STATUS_PARTIAL => OperationRunOutcome::PartiallySucceeded->value,
self::STATUS_SKIPPED => OperationRunOutcome::Blocked->value,
default => OperationRunOutcome::Failed->value,
};
$operationRunService->updateRun(
@ -653,8 +367,8 @@ private function handleFromScheduleId(
errorMessage: $errorMessage,
);
if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey()));
}
$auditLogger->log(
@ -670,14 +384,14 @@ private function handleFromScheduleId(
],
resourceType: 'operation_run',
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) {
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
if ($mapped['shouldRetry']) {
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
$operationRunService->updateRun($this->operationRun, 'running', OperationRunOutcome::Pending->value);
$this->release($mapped['delay']);
@ -688,7 +402,7 @@ private function handleFromScheduleId(
$this->finishSchedule(
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
status: self::STATUS_FAILED,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
@ -696,7 +410,7 @@ private function handleFromScheduleId(
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => 0,
'processed' => 0,
@ -713,7 +427,7 @@ private function handleFromScheduleId(
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
status: self::STATUS_FAILED,
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
{
$userId = $this->operationRun?->user_id;
@ -744,9 +469,9 @@ private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedu
return;
}
$user = \App\Models\User::query()->find($userId);
$user = User::query()->find($userId);
if (! $user) {
if (! $user instanceof User) {
return;
}
@ -774,16 +499,16 @@ private function notifyScheduleRunFinished(
return;
}
$user = \App\Models\User::query()->find($userId);
$user = User::query()->find($userId);
if (! $user) {
if (! $user instanceof User) {
return;
}
$title = match ($status) {
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
self::STATUS_SUCCESS => 'Backup completed',
self::STATUS_PARTIAL => 'Backup completed (partial)',
self::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed',
};
@ -796,8 +521,8 @@ private function notifyScheduleRunFinished(
}
match ($status) {
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
self::STATUS_SUCCESS => $notification->success(),
self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(),
};
@ -823,163 +548,6 @@ private function finishSchedule(
])->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(
OperationRun $run,
array $summaryCounts,
@ -992,7 +560,7 @@ private function markOperationRunFailed(
$operationRunService->updateRun(
$run,
status: 'completed',
outcome: 'failed',
outcome: OperationRunOutcome::Failed->value,
summaryCounts: $summaryCounts,
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\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
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.
// We might want to explicitly update the OperationRun if partial failures occur.
$result = $inventorySyncService->executeSelection(
$this->operationRun,
$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');
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : [];
$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);
$itemsUpserted = (int) ($result['items_upserted_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)),
],
failures: [
['code' => 'inventory.failed', 'message' => $reason],
['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason],
],
);

View File

@ -3,9 +3,9 @@
namespace App\Livewire;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@ -151,9 +151,9 @@ public function table(Table $table): Table
->icon('heroicon-o-user-group')
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())),
Action::make('open_sync_runs')
->label('Group Sync Runs')
->label('Operations')
->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);
}
public function runs(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function operationRuns(): HasMany
{
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
->whereIn('type', [
'backup_schedule.run_now',
'backup_schedule.retry',
'backup_schedule.scheduled',
'backup_schedule_run',
'backup_schedule_retention',
'backup_schedule_purge',
])
->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
{
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id');
return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id');
}
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

View File

@ -25,7 +25,7 @@ public function tenant(): 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

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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Arr;
class OperationRun extends Model
{
@ -65,4 +66,65 @@ public function scopeActive(Builder $query): Builder
{
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);
}
public function backupScheduleRuns(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function policyVersions(): HasMany
{
return $this->hasMany(PolicyVersion::class);
@ -260,11 +255,6 @@ public function entraGroups(): HasMany
return $this->hasMany(EntraGroup::class);
}
public function entraGroupSyncRuns(): HasMany
{
return $this->hasMany(EntraGroupSyncRun::class);
}
public function auditLogs(): HasMany
{
return $this->hasMany(AuditLog::class);

View File

@ -2,7 +2,6 @@
namespace App\Notifications;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Filament\Resources\RestoreRunResource;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
@ -61,16 +60,13 @@ public function toDatabase(object $notifiable): array
$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);
if ($tenant) {
$url = match ($runType) {
'bulk_operation' => OperationRunLinks::view($runId, $tenant),
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
'directory_groups' => OperationRunLinks::view($runId, $tenant),
default => null,
};
$url = $runType === 'restore'
? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
: OperationRunLinks::view($runId, $tenant);
if (! $url) {
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\EntraGroup;
use App\Models\EntraGroupSyncRun;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderCredential;
@ -16,7 +15,6 @@
use App\Observers\RestoreRunObserver;
use App\Policies\BackupSchedulePolicy;
use App\Policies\EntraGroupPolicy;
use App\Policies\EntraGroupSyncRunPolicy;
use App\Policies\FindingPolicy;
use App\Policies\OperationRunPolicy;
use App\Services\Graph\GraphClientInterface;
@ -136,7 +134,6 @@ public function boot(): void
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
Gate::policy(Finding::class, FindingPolicy::class);
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
Gate::policy(OperationRun::class, OperationRunPolicy::class);
}

View File

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

View File

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

View File

@ -3,7 +3,7 @@
namespace App\Services\Drift;
use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
@ -21,14 +21,14 @@ public function __construct(
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.');
}
/** @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');
if (! is_array($policyTypes)) {
@ -114,8 +114,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
$finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'baseline_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(),
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy',
'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM,
@ -187,8 +187,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
$finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'baseline_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(),
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'assignment',
'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM,
@ -262,8 +262,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
$finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'baseline_run_id' => $baseline->getKey(),
'current_run_id' => $current->getKey(),
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'scope_tag',
'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM,
@ -289,16 +289,16 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
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 PolicyVersion::query()
->where('tenant_id', $policy->tenant_id)
->where('policy_id', $policy->getKey())
->where('captured_at', '<=', $run->finished_at)
->where('captured_at', '<=', $run->completed_at)
->latest('captured_at')
->first();
}

View File

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

View File

@ -2,12 +2,14 @@
namespace App\Services\Drift;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
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()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory.sync')
->where('type', 'inventory_sync')
->where('status', 'completed')
->where('context->selection_hash', $selectionHash)
->orderByDesc('completed_at')

View File

@ -3,7 +3,6 @@
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
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.
*
* @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);
$normalizedSelection = $computed['selection'];
@ -54,40 +53,20 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()),
'context' => $normalizedSelection,
'started_at' => now(),
]);
$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,
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()),
'context' => array_merge($normalizedSelection, [
'selection_hash' => $selectionHash,
]),
'started_at' => now(),
]);
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
$status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED);
$status = (string) ($result['status'] ?? 'failed');
$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;
$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 = is_array($policyTypes) ? $policyTypes : [];
@ -98,6 +77,28 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
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([
'status' => OperationRunStatus::Completed->value,
'outcome' => $operationOutcome,
@ -109,16 +110,18 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
'items' => (int) ($result['items_observed_count'] ?? 0),
'updated' => (int) ($result['items_upserted_count'] ?? 0),
],
'failure_summary' => $failureSummary,
'context' => $updatedContext,
'completed_at' => now(),
]);
return $run->refresh();
return $operationRun->refresh();
}
/**
* 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 null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed

View File

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

View File

@ -14,10 +14,7 @@ final class BadgeCatalog
private const DOMAIN_MAPPERS = [
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::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::EntraGroupSyncRunStatus->value => Domains\EntraGroupSyncRunStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,

View File

@ -6,10 +6,7 @@ enum BadgeDomain: string
{
case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome';
case InventorySyncRunStatus = 'inventory_sync_run_status';
case BackupScheduleRunStatus = 'backup_schedule_run_status';
case BackupSetStatus = 'backup_set_status';
case EntraGroupSyncRunStatus = 'entra_group_sync_run_status';
case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity';
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.export' => 'Export policies to backup',
'provider.connection.check' => 'Provider connection check',
'inventory.sync' => 'Inventory sync',
'inventory_sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot',
'directory_groups.sync' => 'Directory groups sync',
'drift.generate' => 'Drift generation',
'entra_group_sync' => 'Directory groups sync',
'drift_generate_findings' => 'Drift generation',
'backup_set.add_policies' => 'Backup set update',
'backup_set.remove_policies' => 'Backup set update',
'backup_set.delete' => 'Archive backup sets',
'backup_set.restore' => 'Restore backup sets',
'backup_set.force_delete' => 'Delete backup sets',
'backup_schedule.run_now' => 'Backup schedule run',
'backup_schedule.retry' => 'Backup schedule retry',
'backup_schedule.scheduled' => 'Backup schedule run',
'backup_schedule_run' => 'Backup schedule run',
'backup_schedule_retention' => 'Backup schedule retention',
'backup_schedule_purge' => 'Backup schedule purge',
'restore.execute' => 'Restore execution',
'directory_role_definitions.sync' => 'Role definitions sync',
'restore_run.delete' => 'Delete restore runs',
@ -60,10 +60,10 @@ public static function expectedDurationSeconds(string $operationType): ?int
'policy.sync', 'policy.sync_one' => 90,
'provider.connection.check' => 30,
'policy.export' => 120,
'inventory.sync' => 180,
'inventory_sync' => 180,
'compliance.snapshot' => 180,
'directory_groups.sync' => 120,
'drift.generate' => 240,
'entra_group_sync' => 120,
'drift_generate_findings' => 240,
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');
}
if ($run->type === 'inventory.sync') {
if ($run->type === 'inventory_sync') {
$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);
}
if ($run->type === 'drift.generate') {
if ($run->type === 'drift_generate_findings') {
$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);
}

View File

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

View File

@ -15,9 +15,9 @@ public function requiredCapabilityForType(string $operationType): ?string
}
return match ($operationType) {
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'directory_groups.sync' => Capabilities::TENANT_SYNC,
'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'entra_group_sync' => Capabilities::TENANT_SYNC,
'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
'restore.execute' => 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\\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\\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\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set 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(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => hash('sha256', fake()->uuid()),
'baseline_run_id' => null,
'current_run_id' => null,
'baseline_operation_run_id' => null,
'current_operation_run_id' => null,
'fingerprint' => hash('sha256', fake()->uuid()),
'subject_type' => 'assignment',
'subject_external_id' => fake()->uuid(),

View File

@ -32,7 +32,7 @@ public function definition(): array
'warnings' => [],
],
'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'
CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique
ON operation_runs (tenant_id, run_identity_hash)
WHERE type = 'backup_schedule.scheduled'
WHERE type = 'backup_schedule_run'
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\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\OperationRun;
use Filament\Facades\Filament;
test('retention keeps last N backup sets per schedule', function () {
@ -35,18 +35,33 @@
]);
});
// Oldest → newest
$scheduledFor = now('UTC')->startOfMinute()->subMinutes(10);
$completedAt = now('UTC')->startOfMinute()->subMinutes(10);
foreach ($sets as $set) {
BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => $scheduledFor,
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0],
'backup_set_id' => $set->id,
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_run',
'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);
@ -64,4 +79,15 @@
foreach ($deleted as $set) {
$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]);
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_schedule.scheduled')
->where('type', 'backup_schedule_run')
->count())->toBe(1);
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
@ -48,7 +46,7 @@
return $job->backupScheduleId !== null
&& $job->backupScheduleRunId === 0
&& $job->operationRun?->tenant_id === $tenant->getKey()
&& $job->operationRun?->type === 'backup_schedule.scheduled';
&& $job->operationRun?->type === 'backup_schedule_run';
});
});
@ -77,7 +75,7 @@
$operationRunService->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule.scheduled',
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
@ -93,13 +91,11 @@
$dispatcher = app(BackupScheduleDispatcher::class);
$dispatcher->dispatchDue([$tenant->external_id]);
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
Bus::assertNotDispatched(RunBackupScheduleJob::class);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_schedule.scheduled')
->where('type', 'backup_schedule_run')
->count())->toBe(1);
$schedule->refresh();

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
@ -101,22 +100,9 @@
'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(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', [
'tenant' => $tenantA->id,
@ -130,8 +116,11 @@
expect(BackupSet::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(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
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);

View File

@ -1,7 +1,6 @@
<?php
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
@ -9,7 +8,7 @@
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();
$schedule = BackupSchedule::create([
@ -29,39 +28,24 @@
]);
$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([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule.run_now',
'status' => 'queued',
'type' => 'backup_schedule_run',
'status' => 'running',
'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' => [],
'failure_summary' => [],
'context' => [
'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', [
@ -72,21 +56,15 @@
$operationRun->refresh();
expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->failure_summary)->toBe([]);
expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s'));
expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s'));
expect($operationRun->outcome)->toBe('failed');
expect($operationRun->failure_summary)->toMatchArray([
[
'code' => 'backup_schedule.stalled',
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
'reason_code' => 'unknown_error',
],
]);
expect($operationRun->context)->toMatchArray([
'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'));
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
Artisan::call('tenantpilot:directory-groups:dispatch', [
'--tenant' => [$tenant->tenant_id],
]);
$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()
->where('tenant_id', $tenant->getKey())
->where('type', 'directory_groups.sync')
->where('type', 'entra_group_sync')
->where('context->slot_key', $slotKey)
->first();

View File

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

View File

@ -17,7 +17,7 @@
expect($run)->toBeInstanceOf(OperationRun::class)
->and($run->tenant_id)->toBe($tenant->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->context['selection_key'] ?? null)->toBe('groups-v1:all');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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