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
203 lines
6.4 KiB
PHP
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;
|
|
}
|
|
}
|