, * tenant_ids?: array, * workspace_ids?: array, * limit?: int, * dry_run?: bool * } $options * @return array{candidates:int,reconciled:int,skipped:int,changes:array>} */ public function reconcile(array $options = []): array { $types = array_values(array_filter( $options['types'] ?? $this->policy->coveredTypeNames(), static fn (mixed $type): bool => is_string($type) && trim($type) !== '', )); $tenantIds = array_values(array_filter( $options['tenant_ids'] ?? [], static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0, )); $workspaceIds = array_values(array_filter( $options['workspace_ids'] ?? [], static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0, )); $limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500); $dryRun = (bool) ($options['dry_run'] ?? false); $runs = OperationRun::query() ->with(['tenant', 'user']) ->whereIn('type', $types) ->whereIn('status', [ OperationRunStatus::Queued->value, OperationRunStatus::Running->value, ]) ->when( $tenantIds !== [], fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds), ) ->when( $workspaceIds !== [], fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds), ) ->orderBy('id') ->limit($limit) ->get(); $changes = []; $reconciled = 0; $skipped = 0; foreach ($runs as $run) { $change = $this->reconcileRun($run, $dryRun); if ($change === null) { $skipped++; continue; } $changes[] = $change; if (($change['applied'] ?? false) === true) { $reconciled++; } } return [ 'candidates' => $runs->count(), 'reconciled' => $reconciled, 'skipped' => $skipped, 'changes' => $changes, ]; } /** * @return array|null */ public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array { $assessment = $this->assessment($run); if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) { return null; } $before = [ 'status' => (string) $run->status, 'outcome' => (string) $run->outcome, 'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value, ]; $after = [ 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Failed->value, 'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value, ]; if ($dryRun) { return [ 'applied' => false, 'operation_run_id' => (int) $run->getKey(), 'type' => (string) $run->type, 'before' => $before, 'after' => $after, 'reason_code' => $assessment['reason_code'], 'reason_message' => $assessment['reason_message'], 'evidence' => $assessment['evidence'], ]; } $updated = $this->operationRunService->forceFailNonTerminalRun( run: $run, reasonCode: (string) $assessment['reason_code'], message: (string) $assessment['reason_message'], source: 'scheduled_reconciler', evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [], ); return [ 'applied' => true, 'operation_run_id' => (int) $updated->getKey(), 'type' => (string) $updated->type, 'before' => $before, 'after' => $after, 'reason_code' => $assessment['reason_code'], 'reason_message' => $assessment['reason_message'], 'evidence' => $assessment['evidence'], ]; } /** * @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array}|null */ public function assessment(OperationRun $run): ?array { if ((string) $run->status === OperationRunStatus::Completed->value) { return null; } if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) { return null; } $freshnessState = OperationRunFreshnessState::forRun($run, $this->policy); if (! $freshnessState->isLikelyStale()) { return null; } $reason = (string) $run->status === OperationRunStatus::Queued->value ? LifecycleReconciliationReason::StaleQueued : LifecycleReconciliationReason::StaleRunning; $referenceTime = (string) $run->status === OperationRunStatus::Queued->value ? $run->created_at : ($run->started_at ?? $run->created_at); $thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value ? $this->policy->queuedStaleAfterSeconds((string) $run->type) : $this->policy->runningStaleAfterSeconds((string) $run->type); $legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray(); return [ 'should_reconcile' => true, 'reason_code' => $reason->value, 'reason_message' => $reason->defaultMessage(), 'evidence' => [ 'evaluated_at' => now()->toIso8601String(), 'freshness_state' => $freshnessState->value, 'threshold_seconds' => $thresholdSeconds, 'reference_time' => $referenceTime?->toIso8601String(), 'status' => (string) $run->status, 'execution_legitimacy' => $legitimacy, 'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type) ? 'direct_and_scheduled' : 'scheduled_only', ], ]; } }