TenantAtlas/app/Services/SystemConsole/OperationRunTriageService.php
ahmido 0cf612826f feat(114): system console control tower (merged) (#139)
Feature branch PR for Spec 114.

This branch contains the merged agent session work (see merge commit on branch).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #139
2026-02-28 00:15:31 +00:00

203 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\SystemConsole;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use InvalidArgumentException;
final class OperationRunTriageService
{
private const RETRYABLE_TYPES = [
'inventory_sync',
'policy.sync',
'policy.sync_one',
'entra_group_sync',
'drift_generate_findings',
'findings.lifecycle.backfill',
'rbac.health_check',
'entra.admin_roles.scan',
'tenant.review_pack.generate',
];
private const CANCELABLE_TYPES = [
'inventory_sync',
'policy.sync',
'policy.sync_one',
'entra_group_sync',
'drift_generate_findings',
'findings.lifecycle.backfill',
'rbac.health_check',
'entra.admin_roles.scan',
'tenant.review_pack.generate',
];
public function __construct(
private readonly OperationRunService $operationRunService,
private readonly SystemConsoleAuditLogger $auditLogger,
) {}
public function canRetry(OperationRun $run): bool
{
return (string) $run->status === OperationRunStatus::Completed->value
&& (string) $run->outcome === OperationRunOutcome::Failed->value
&& in_array((string) $run->type, self::RETRYABLE_TYPES, true);
}
public function canCancel(OperationRun $run): bool
{
return in_array((string) $run->status, [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
], true)
&& in_array((string) $run->type, self::CANCELABLE_TYPES, true);
}
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
{
if (! $this->canRetry($run)) {
throw new InvalidArgumentException('Operation run is not retryable.');
}
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'retry_of_run_id' => (int) $run->getKey(),
'retried_at' => now()->toISOString(),
'retried_by' => [
'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name,
'email' => $actor->email,
],
],
);
$retryRun = OperationRun::query()->create([
'workspace_id' => (int) $run->workspace_id,
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
'user_id' => null,
'initiator_name' => $actor->name ?? 'Platform operator',
'type' => (string) $run->type,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
'summary_counts' => [],
'failure_summary' => [],
'context' => $context,
'started_at' => null,
'completed_at' => null,
]);
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.retry',
metadata: [
'source_run_id' => (int) $run->getKey(),
'new_run_id' => (int) $retryRun->getKey(),
'operation_type' => (string) $run->type,
],
run: $retryRun,
);
return $retryRun;
}
public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
{
if (! $this->canCancel($run)) {
throw new InvalidArgumentException('Operation run is not cancelable.');
}
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'cancelled_at' => now()->toISOString(),
'cancelled_by' => [
'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name,
'email' => $actor->email,
],
],
);
$run->update([
'context' => $context,
]);
$run->refresh();
$cancelledRun = $this->operationRunService->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'run.cancelled',
'message' => 'Run cancelled by platform operator triage action.',
],
],
);
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.cancel',
metadata: [
'operation_type' => (string) $run->type,
],
run: $cancelledRun,
);
return $cancelledRun;
}
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
{
$reason = trim($reason);
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
throw new InvalidArgumentException('Investigation reason must be between 5 and 500 characters.');
}
$context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [],
[
'investigated' => [
'reason' => $reason,
'investigated_at' => now()->toISOString(),
'investigated_by' => [
'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name,
'email' => $actor->email,
],
],
],
);
$run->update([
'context' => $context,
]);
$run->refresh();
$this->auditLogger->log(
actor: $actor,
action: 'platform.system_console.mark_investigated',
metadata: [
'reason' => $reason,
'operation_type' => (string) $run->type,
],
run: $run,
);
return $run;
}
}