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; } }