TenantAtlas/app/Services/OperationRunService.php
ahmido bd6df1f343 055-ops-ux-rollout (#64)
Kurzbeschreibung

Implementiert Feature 055 — Ops‑UX Constitution Rollout v1.3.0.
Behebt: globales BulkOperationProgress-Widget benötigt keinen manuellen Refresh mehr; ETA/Elapsed aktualisieren korrekt; Widget verschwindet automatisch.
Verbesserungen: zuverlässiges polling (Alpine factory + Livewire fallback), sofortiger Enqueue‑Signal-Dispatch, Failure‑Message‑Sanitization, neue Guard‑ und Regressionstests, Specs/Tasks aktualisiert.
Was geändert wurde (Auszug)

InventoryLanding.php
bulk-operation-progress.blade.php
OperationUxPresenter.php
SyncRestoreRunToOperationRun.php
PolicyResource.php
PolicyVersionResource.php
RestoreRunResource.php
tests/Feature/OpsUx/* (PollerRegistration, TerminalNotificationFailureMessageTest, CanonicalViewRunLinksTest, OperationCatalogCoverageTest, UnknownOperationTypeLabelTest)
InventorySyncButtonTest.php
tasks.md
Tests

Neue Tests hinzugefügt; php artisan test --group=ops-ux lokal grün (alle relevanten Tests laufen).
How to verify manually

Auf Branch wechseln: 055-ops-ux-rollout
In Filament: Inventory → Sync (oder relevante Bulk‑Aktion) auslösen.
Beobachten: Progress‑Widget erscheint sofort, ETA/Elapsed aktualisiert, Widget verschwindet nach Fertigstellung ohne Browser‑Refresh.
Optional: ./vendor/bin/sail exec app php artisan test --filter=OpsUx oder php artisan test --group=ops-ux
Besonderheiten / Hinweise

Einzelne, synchrone Policy‑Actions (ignore/restore/PolicyVersion single archive/restore/forceDelete) sind absichtlich inline und erzeugen kein OperationRun. Bulk‑Aktionen und restore.execute werden als Runs modelliert. Wenn gewünscht, kann ich die inline‑Actions auf OperationRunService umstellen, damit sie in Monitoring → Operations sichtbar werden.
Remote: Branch ist bereits gepusht (origin/055-ops-ux-rollout). PR kann in Gitea erstellt werden.
Links

Specs & tasks: tasks.md
Monitoring page: Operations.php

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #64
2026-01-18 14:50:15 +00:00

293 lines
9.3 KiB
PHP

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