feat: add app and database modules
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
<?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.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user