Files

312 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Jobs;
use App\Models\AiHealingLog;
use App\Services\AI\AiService;
use App\Services\SystemConfig\SystemConfigService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
class AiHealerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $logId;
public $faultyFile;
public $lineNum;
public function __construct($logId, $faultyFile = null, $lineNum = null)
{
$this->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 `<code>@can('view users')</code>` to `<code>@@can('view users')</code>`.\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.50.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;
}
}