TenantAtlas/app/Services/Operations/OperationLifecycleReconciler.php
ahmido 845d21db6d feat: harden operation lifecycle monitoring (#190)
## Summary
- harden operation-run lifecycle handling with explicit reconciliation policy, stale-run healing, failed-job bridging, and monitoring visibility
- refactor audit log event inspection into a Filament slide-over and remove the stale inline detail/header-action coupling
- align panel theme asset resolution and supporting Filament UI updates, including the rounded 2xl theme token regression fix

## Testing
- ran focused Pest coverage for the affected audit-log inspection flow and related visibility tests
- ran formatting with `vendor/bin/sail bin pint --dirty --format agent`
- manually verified the updated audit-log slide-over flow in the integrated browser

## Notes
- branch includes the Spec 160 artifacts under `specs/160-operation-lifecycle-guarantees/`
- the full test suite was not rerun as part of this final commit/PR step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #190
2026-03-23 21:53:19 +00:00

201 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder;
final class OperationLifecycleReconciler
{
public function __construct(
private readonly OperationLifecyclePolicy $policy,
private readonly OperationRunService $operationRunService,
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
) {}
/**
* @param array{
* types?: array<int, string>,
* tenant_ids?: array<int, int>,
* workspace_ids?: array<int, int>,
* limit?: int,
* dry_run?: bool
* } $options
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
*/
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<string, mixed>|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<string, mixed>}|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',
],
];
}
}