Implements Spec 114 System Console Control Tower pages, widgets, triage actions, directory views, and enterprise polish (badges, repair workspace owners table, health indicator).
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;
|
|
}
|
|
}
|