logId = $logId; $this->faultyFile = $faultyFile; $this->lineNum = $lineNum; } public function handle(AiService $aiService, SystemConfigService $cfg) { $healingLog = AiHealingLog::find($this->logId); if (!$healingLog) { return; } $healingLog->update(['status' => 'diagnosing']); // ── Capability gates ──────────────────────────────────────────────── // These honour the toggles on the AI Self-Healing config panel. $allowCache = (bool) $cfg->get('ai_healing_allow_cache', true); $allowQueue = (bool) $cfg->get('ai_healing_allow_queue', true); $allowMaintenance = (bool) $cfg->get('ai_healing_allow_maintenance', false); $allowDb = (bool) $cfg->get('ai_healing_allow_db', false); $commandCapabilityMap = [ 'cache:clear' => $allowCache, 'view:clear' => $allowCache, 'config:clear' => $allowCache, 'route:clear' => $allowCache, 'optimize:clear' => $allowCache, 'queue:restart' => $allowQueue, 'down' => $allowMaintenance, 'up' => $allowMaintenance, 'migrate' => $allowDb, 'migrate:fresh' => false, // never permitted — destructive ]; $stackTrace = $healingLog->stack_trace; $faultyFile = $this->faultyFile; $lineNum = (int) $this->lineNum; if (!$faultyFile || str_contains($faultyFile, 'vendor/')) { if (preg_match('/(\/var\/www\/html\/(?!vendor\/)[^\s:]+\.php):(\d+)/', $stackTrace, $matches)) { $faultyFile = $matches[1]; $lineNum = (int) $matches[2]; } } if ($faultyFile && str_contains($faultyFile, 'storage/framework/views')) { $compiledContent = @file_get_contents($faultyFile); if ($compiledContent && preg_match('/PATH (.*?) ENDPATH/', $compiledContent, $m)) { $faultyFile = trim($m[1]); } } $fileContext = ''; if ($faultyFile && file_exists($faultyFile)) { $lines = file($faultyFile); $start = max(0, $lineNum - 25); $end = min(count($lines), $lineNum + 25); $fileContext = implode('', array_slice($lines, $start, $end - $start)); } $enabledCommands = array_keys(array_filter($commandCapabilityMap, fn ($v) => $v === true)); $enabledList = $enabledCommands ? implode(', ', $enabledCommands) : '(none — only code edits are permitted)'; $isBlade = $faultyFile && str_ends_with($faultyFile, '.blade.php'); $isView = $isBlade || str_contains($healingLog->error_type, 'View') || str_contains($healingLog->error_message, 'storage/framework/views'); $prompt = "You are an autonomous Laravel 11 self-healing agent. The user has explicitly authorised you to apply fixes WITHOUT human confirmation when the error is unambiguous. A timestamped .bak backup is written before every code edit, so the user has a rollback path.\n\n"; $prompt .= "EXCEPTION DETAILS:\n"; $prompt .= 'Error Type: ' . $healingLog->error_type . "\n"; $prompt .= 'Error Message: ' . $healingLog->error_message . "\n"; if ($fileContext) { $prompt .= "The error originated in file: {$faultyFile}\n"; $prompt .= "Here is the code snippet around the error (±25 lines):\n```php\n{$fileContext}\n```\n"; } else { $prompt .= 'Stack Trace (first 1200 chars): ' . substr($stackTrace, 0, 1200) . "\n\n"; } if ($isView) { $prompt .= "\nBLADE-SPECIFIC HINTS:\n"; $prompt .= "- Literal `@directive` in HTML output (e.g. documentation that mentions @can, @if, @foreach) MUST be escaped as `@@directive` so Blade outputs it as text. Example: change `@can('view users')` to `@@can('view users')`.\n"; $prompt .= "- Literal `{{ }}` in HTML must be escaped as `@{{ }}`. Literal `{!! !!}` as `@{!! !!}`.\n"; $prompt .= "- 'unexpected end of file, expecting elseif/else/endif' usually means an unescaped `@can` / `@if` / `@auth` directive in plain text — Blade thinks it must close it. Find the directive that has no matching `@endX` and either escape it (`@@X`) or add the missing `@endX`.\n"; $prompt .= "- Unmatched `@foreach`/`@endforeach`, `@if`/`@endif`, `@can`/`@endcan` pairs cause the same EOF error.\n"; $prompt .= "- When the error's file is in `storage/framework/views/` (compiled cache), the real bug is in the source `.blade.php` shown above.\n\n"; } $prompt .= "===== DECISION POLICY =====\n"; $prompt .= "Classify this error into one of three tiers and act accordingly:\n\n"; $prompt .= "TIER 1 — AUTO-FIX MANDATORY (confidence ≥ 0.8). You MUST return a code_edit or action_command. Examples:\n"; $prompt .= " • Typo in variable / method / class / route name visible in the snippet\n"; $prompt .= " • Missing or wrong `use` import statement\n"; $prompt .= " • Unescaped Blade directive in plain text (apply @@ escape)\n"; $prompt .= " • Unmatched `@if`/`@endif`, `@foreach`/`@endforeach`, `@can`/`@endcan` (add the missing closer)\n"; $prompt .= " • `Undefined array key` where a safe default is obvious → use `?? default`\n"; $prompt .= " • `Trying to access property of null` → apply `?->` null-safe operator\n"; $prompt .= " • `Class \"X\" not found` after a clear rename / typo → fix the FQCN\n"; $prompt .= " • Stale Blade / route / config cache → return action_command (view:clear, route:clear, optimize:clear)\n"; $prompt .= " • Failed worker after code deploy → return action_command queue:restart (if allowed)\n"; $prompt .= " • Wrong helper signature with obvious right form (Cache::set → Cache::put)\n"; $prompt .= "\nTIER 2 — BEST-EFFORT FIX (confidence 0.5–0.79). Attempt the most likely fix if you can identify a concrete change. Examples:\n"; $prompt .= " • Type mismatch where a cast solves it (e.g. `(int) \$id`)\n"; $prompt .= " • SQL function not available in this driver — replace with portable PHP equivalent (e.g. Postgres lacks DATE_FORMAT; use Carbon bucketing in PHP)\n"; $prompt .= " • Missing model attribute that has a safe fallback\n"; $prompt .= "\nTIER 3 — DO NOT AUTO-FIX (confidence < 0.5). Set both code_edit and action_command to null, write a precise diagnosis. Examples:\n"; $prompt .= " • Business logic / data semantics (you cannot infer intent)\n"; $prompt .= " • Multi-file refactor (you can only edit one file per fix)\n"; $prompt .= " • Race conditions / concurrency issues\n"; $prompt .= " • Anything in vendor/ (never touch)\n"; $prompt .= " • The fix would require deleting / re-creating significant code blocks\n"; $prompt .= " • The exception is itself coming from the healing pipeline (avoid loops)\n"; $prompt .= "\nGENERAL RULES:\n"; $prompt .= "- Bias toward decisive ACTION for Tier 1 and Tier 2. The user has authorised auto-fix and there is a backup.\n"; $prompt .= "- For code_edit, `target_content` MUST be an EXACT substring that exists in the file (preserve all whitespace and indentation).\n"; $prompt .= "- `replacement_content` must be the minimal change that fixes the bug. Do NOT refactor surrounding code.\n"; $prompt .= "- Only use commands from the allow-list: {$enabledList}\n"; $prompt .= "- If you return code_edit, action_command MUST be null (apply exactly one remedy).\n"; $prompt .= "- Diagnosis should be ≤ 2 sentences, plain language, explaining what was wrong AND what you changed.\n"; $prompt .= "\nRESPOND ONLY WITH VALID JSON (no markdown fences, no prose around it):\n"; $prompt .= "{\n"; $prompt .= " \"confidence\": 0.0-1.0,\n"; $prompt .= " \"tier\": 1|2|3,\n"; $prompt .= " \"diagnosis\": \"What was wrong + what you changed (or why you can't fix it).\",\n"; $prompt .= " \"action_command\": \"cache:clear\" | null,\n"; $prompt .= " \"code_edit\": {\"file_path\":\"...\",\"target_content\":\"...\",\"replacement_content\":\"...\"} | null\n"; $prompt .= "}"; try { $aiResult = $aiService->provider()->generate($prompt); if (!$aiResult['success']) { throw new \Exception($aiResult['error'] ?? 'Unknown AI Provider error'); } $response = $aiResult['response']; $jsonStr = trim(preg_replace('/```json|```/', '', $response)); $result = json_decode($jsonStr, true); if (json_last_error() !== JSON_ERROR_NONE) { $healingLog->update([ 'status' => 'failed', 'ai_diagnosis' => 'AI response was not valid JSON: ' . $response, 'action_taken' => 'Invalid JSON from provider', ]); return; } $diagnosis = $result['diagnosis'] ?? 'AI could not provide a diagnosis.'; $action = $result['action_command'] ?? null; $codeEdit = $result['code_edit'] ?? null; $confidence = isset($result['confidence']) ? (float) $result['confidence'] : null; $tier = isset($result['tier']) ? (int) $result['tier'] : null; if ($confidence !== null || $tier !== null) { $tag = ''; if ($confidence !== null) { $tag .= '[conf=' . number_format($confidence, 2) . ']'; } if ($tier !== null) { $tag .= '[tier=' . $tier . ']'; } $diagnosis = trim($tag . ' ' . $diagnosis); } // ── Try code edit first ───────────────────────────────────────── if (is_array($codeEdit) && isset($codeEdit['file_path'], $codeEdit['target_content'], $codeEdit['replacement_content'])) { $applied = $this->applyCodeEdit($healingLog, $codeEdit, $diagnosis); if ($applied !== null) { return; // resolved or hard-failed } // If $applied is null, the edit couldn't be applied (e.g., file missing) — fall through to command attempt. } // ── Try Artisan command ──────────────────────────────────────── if (is_string($action) && $action !== '') { if (!array_key_exists($action, $commandCapabilityMap)) { $healingLog->update([ 'status' => 'failed', 'action_taken' => "Refused: command '{$action}' is not in security policy", 'ai_diagnosis' => $diagnosis, ]); return; } if (!$commandCapabilityMap[$action]) { $healingLog->update([ 'status' => 'failed', 'action_taken' => "Refused: command '{$action}' is currently disabled by capability toggle", 'ai_diagnosis' => $diagnosis, ]); return; } try { Artisan::call($action); $healingLog->update([ 'status' => 'resolved', 'action_taken' => 'Executed: ' . $action, 'ai_diagnosis' => $diagnosis, ]); Log::info("AI Healer executed Artisan command: {$action}"); } catch (\Throwable $e) { $healingLog->update([ 'status' => 'failed', 'action_taken' => "Artisan '{$action}' threw: " . $e->getMessage(), 'ai_diagnosis' => $diagnosis, ]); } return; } // ── Nothing actionable ───────────────────────────────────────── $healingLog->update([ 'status' => 'failed', 'ai_diagnosis' => $diagnosis, 'action_taken' => 'No fix applied (AI provided no actionable remedy)', ]); } catch (\Throwable $e) { Log::error('AI Healer failed: ' . $e->getMessage()); $healingLog->update([ 'status' => 'failed', 'ai_diagnosis' => 'Internal Error: ' . $e->getMessage(), 'action_taken' => 'Healer pipeline crashed', ]); } } /** * Apply a code edit suggested by the AI. * Returns true on resolved, false on hard fail, null if it should fall through to command attempt. */ private function applyCodeEdit(AiHealingLog $log, array $edit, string $diagnosis): ?bool { $filePath = $edit['file_path']; if (!file_exists($filePath) || !is_writable($filePath)) { // Fall through — let the action_command branch try instead. return null; } $content = file_get_contents($filePath); if (!str_contains($content, $edit['target_content'])) { $log->update([ 'status' => 'failed', 'action_taken' => 'Code edit failed: target snippet not found in ' . basename($filePath), 'ai_diagnosis' => $diagnosis, ]); return false; } $newContent = str_replace($edit['target_content'], $edit['replacement_content'], $content); if (trim($newContent) === '') { $log->update([ 'status' => 'failed', 'action_taken' => 'Code edit aborted: replacement would empty the file', 'ai_diagnosis' => $diagnosis, ]); return false; } // Write a .bak before editing — gives the user a rollback path. $backupPath = $filePath . '.bak.' . date('YmdHis'); @file_put_contents($backupPath, $content); file_put_contents($filePath, $newContent); Artisan::call('optimize:clear'); $log->update([ 'status' => 'resolved', 'action_taken' => 'CODE_EDIT|' . $filePath . '|' . $backupPath, 'ai_diagnosis' => $diagnosis, 'original_code' => $content, 'fixed_code' => $newContent, ]); return true; } }