diff --git a/Migrations/0001_01_01_000001_create_agentic_tables.php b/Migrations/0001_01_01_000001_create_agentic_tables.php index b27a47f..919f9c7 100644 --- a/Migrations/0001_01_01_000001_create_agentic_tables.php +++ b/Migrations/0001_01_01_000001_create_agentic_tables.php @@ -10,93 +10,56 @@ return new class extends Migration { /** * Agentic module tables - AI agents, tasks, sessions. + * + * Guarded with hasTable() so this migration is idempotent and + * can coexist with the consolidated app-level migration. */ public function up(): void { Schema::disableForeignKeyConstraints(); - // 1. Agent API Keys - Schema::create('agent_api_keys', function (Blueprint $table) { - $table->id(); - $table->uuid('uuid')->unique(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->string('name'); - $table->string('key_hash', 64)->unique(); - $table->string('key_prefix', 12); - $table->json('allowed_agents')->nullable(); - $table->json('rate_limits')->nullable(); - $table->boolean('is_active')->default(true); - $table->timestamp('expires_at')->nullable(); - $table->timestamp('last_used_at')->nullable(); - $table->unsignedBigInteger('usage_count')->default(0); - $table->timestamps(); - $table->softDeletes(); + if (! Schema::hasTable('agent_api_keys')) { + Schema::create('agent_api_keys', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('key'); + $table->json('permissions')->nullable(); + $table->unsignedInteger('rate_limit')->nullable(); + $table->unsignedBigInteger('call_count')->default(0); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); - $table->index(['workspace_id', 'is_active']); - $table->index('key_prefix'); - }); + $table->index('workspace_id'); + $table->index('key'); + }); + } - // 2. Agent Tasks - Schema::create('agent_tasks', function (Blueprint $table) { - $table->id(); - $table->uuid('uuid')->unique(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->foreignId('api_key_id')->nullable()->constrained('agent_api_keys')->nullOnDelete(); - $table->string('agent_type'); - $table->string('status', 32)->default('pending'); - $table->text('prompt'); - $table->json('context')->nullable(); - $table->json('result')->nullable(); - $table->json('tool_calls')->nullable(); - $table->unsignedInteger('input_tokens')->default(0); - $table->unsignedInteger('output_tokens')->default(0); - $table->decimal('cost', 10, 6)->default(0); - $table->unsignedInteger('duration_ms')->nullable(); - $table->text('error_message')->nullable(); - $table->timestamp('started_at')->nullable(); - $table->timestamp('completed_at')->nullable(); - $table->timestamps(); + if (! Schema::hasTable('agent_sessions')) { + Schema::create('agent_sessions', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('agent_api_key_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id')->unique(); + $table->string('agent_type')->nullable(); + $table->string('status')->default('active'); + $table->json('context_summary')->nullable(); + $table->json('work_log')->nullable(); + $table->json('artifacts')->nullable(); + $table->json('handoff_notes')->nullable(); + $table->text('final_summary')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('last_active_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamps(); - $table->index(['workspace_id', 'status']); - $table->index(['agent_type', 'status']); - $table->index('created_at'); - }); - - // 3. Agent Sessions - Schema::create('agent_sessions', function (Blueprint $table) { - $table->id(); - $table->uuid('uuid')->unique(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->string('agent_type'); - $table->string('status', 32)->default('active'); - $table->json('context')->nullable(); - $table->json('memory')->nullable(); - $table->unsignedInteger('message_count')->default(0); - $table->unsignedInteger('total_tokens')->default(0); - $table->decimal('total_cost', 10, 6)->default(0); - $table->timestamp('last_activity_at')->nullable(); - $table->timestamps(); - - $table->index(['workspace_id', 'status']); - $table->index(['agent_type', 'status']); - $table->index('last_activity_at'); - }); - - // 4. Agent Messages - Schema::create('agent_messages', function (Blueprint $table) { - $table->id(); - $table->foreignId('session_id')->constrained('agent_sessions')->cascadeOnDelete(); - $table->string('role', 32); - $table->longText('content'); - $table->json('tool_calls')->nullable(); - $table->json('tool_results')->nullable(); - $table->unsignedInteger('tokens')->default(0); - $table->timestamps(); - - $table->index(['session_id', 'created_at']); - }); + $table->index('workspace_id'); + $table->index('status'); + $table->index('agent_type'); + }); + } Schema::enableForeignKeyConstraints(); } @@ -104,9 +67,7 @@ return new class extends Migration public function down(): void { Schema::disableForeignKeyConstraints(); - Schema::dropIfExists('agent_messages'); Schema::dropIfExists('agent_sessions'); - Schema::dropIfExists('agent_tasks'); Schema::dropIfExists('agent_api_keys'); Schema::enableForeignKeyConstraints(); } diff --git a/Migrations/0001_01_01_000002_add_ip_whitelist_to_agent_api_keys.php b/Migrations/0001_01_01_000002_add_ip_whitelist_to_agent_api_keys.php index a1c5bdb..fd495ec 100644 --- a/Migrations/0001_01_01_000002_add_ip_whitelist_to_agent_api_keys.php +++ b/Migrations/0001_01_01_000002_add_ip_whitelist_to_agent_api_keys.php @@ -13,17 +13,43 @@ return new class extends Migration */ public function up(): void { + if (! Schema::hasTable('agent_api_keys')) { + return; + } + Schema::table('agent_api_keys', function (Blueprint $table) { - $table->boolean('ip_restriction_enabled')->default(false)->after('rate_limits'); - $table->json('ip_whitelist')->nullable()->after('ip_restriction_enabled'); - $table->string('last_used_ip', 45)->nullable()->after('last_used_at'); + if (! Schema::hasColumn('agent_api_keys', 'ip_restriction_enabled')) { + $table->boolean('ip_restriction_enabled')->default(false); + } + if (! Schema::hasColumn('agent_api_keys', 'ip_whitelist')) { + $table->json('ip_whitelist')->nullable(); + } + if (! Schema::hasColumn('agent_api_keys', 'last_used_ip')) { + $table->string('last_used_ip', 45)->nullable(); + } }); } public function down(): void { + if (! Schema::hasTable('agent_api_keys')) { + return; + } + Schema::table('agent_api_keys', function (Blueprint $table) { - $table->dropColumn(['ip_restriction_enabled', 'ip_whitelist', 'last_used_ip']); + $cols = []; + if (Schema::hasColumn('agent_api_keys', 'ip_restriction_enabled')) { + $cols[] = 'ip_restriction_enabled'; + } + if (Schema::hasColumn('agent_api_keys', 'ip_whitelist')) { + $cols[] = 'ip_whitelist'; + } + if (Schema::hasColumn('agent_api_keys', 'last_used_ip')) { + $cols[] = 'last_used_ip'; + } + if ($cols) { + $table->dropColumn($cols); + } }); } }; diff --git a/Migrations/0001_01_01_000003_create_agent_plans_tables.php b/Migrations/0001_01_01_000003_create_agent_plans_tables.php index 3fbdb3d..200584b 100644 --- a/Migrations/0001_01_01_000003_create_agent_plans_tables.php +++ b/Migrations/0001_01_01_000003_create_agent_plans_tables.php @@ -11,82 +11,76 @@ return new class extends Migration /** * Create agent plans, phases, and workspace states tables. * - * These tables support the structured work plan system that enables - * multi-agent handoff and context recovery across sessions. + * Guarded with hasTable() so this migration is idempotent and + * can coexist with the consolidated app-level migration. */ public function up(): void { Schema::disableForeignKeyConstraints(); - // 1. Agent Plans - structured work plans with phases - Schema::create('agent_plans', function (Blueprint $table) { - $table->id(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->string('slug')->unique(); - $table->string('title'); - $table->text('description')->nullable(); - $table->longText('context')->nullable(); - $table->json('phases')->nullable(); // Deprecated: use agent_phases table - $table->string('status', 32)->default('draft'); - $table->string('current_phase')->nullable(); - $table->json('metadata')->nullable(); - $table->string('source_file')->nullable(); - $table->timestamps(); + if (! Schema::hasTable('agent_plans')) { + Schema::create('agent_plans', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('slug')->unique(); + $table->string('title'); + $table->text('description')->nullable(); + $table->longText('context')->nullable(); + $table->json('phases')->nullable(); + $table->string('status', 32)->default('draft'); + $table->string('current_phase')->nullable(); + $table->json('metadata')->nullable(); + $table->string('source_file')->nullable(); + $table->timestamps(); - $table->index(['workspace_id', 'status']); - $table->index('slug'); - }); + $table->index(['workspace_id', 'status']); + $table->index('slug'); + }); + } - // 2. Agent Phases - individual phases within a plan - Schema::create('agent_phases', function (Blueprint $table) { - $table->id(); - $table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete(); - $table->unsignedInteger('order')->default(0); - $table->string('name'); - $table->text('description')->nullable(); - $table->json('tasks')->nullable(); - $table->json('dependencies')->nullable(); - $table->string('status', 32)->default('pending'); - $table->json('completion_criteria')->nullable(); - $table->timestamp('started_at')->nullable(); - $table->timestamp('completed_at')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); + if (! Schema::hasTable('agent_phases')) { + Schema::create('agent_phases', function (Blueprint $table) { + $table->id(); + $table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete(); + $table->unsignedInteger('order')->default(0); + $table->string('name'); + $table->text('description')->nullable(); + $table->json('tasks')->nullable(); + $table->json('dependencies')->nullable(); + $table->string('status', 32)->default('pending'); + $table->json('completion_criteria')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); - $table->index(['agent_plan_id', 'order']); - $table->index(['agent_plan_id', 'status']); - }); + $table->index(['agent_plan_id', 'order']); + $table->index(['agent_plan_id', 'status']); + }); + } - // 3. Agent Workspace States - shared context between sessions - Schema::create('agent_workspace_states', function (Blueprint $table) { - $table->id(); - $table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete(); - $table->string('key'); - $table->json('value')->nullable(); - $table->string('type', 32)->default('json'); - $table->text('description')->nullable(); - $table->timestamps(); + if (! Schema::hasTable('agent_workspace_states')) { + Schema::create('agent_workspace_states', function (Blueprint $table) { + $table->id(); + $table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete(); + $table->string('key'); + $table->json('value')->nullable(); + $table->string('type', 32)->default('json'); + $table->text('description')->nullable(); + $table->timestamps(); - $table->unique(['agent_plan_id', 'key']); - $table->index('key'); - }); + $table->unique(['agent_plan_id', 'key']); + $table->index('key'); + }); + } - // 4. Update agent_sessions to add agent_plan_id foreign key - // Note: Only run if agent_sessions table exists (from earlier migration) + // Add agent_plan_id to agent_sessions if table exists if (Schema::hasTable('agent_sessions') && ! Schema::hasColumn('agent_sessions', 'agent_plan_id')) { Schema::table('agent_sessions', function (Blueprint $table) { $table->foreignId('agent_plan_id') ->nullable() - ->after('workspace_id') ->constrained('agent_plans') ->nullOnDelete(); - $table->json('context_summary')->nullable()->after('context'); - $table->json('work_log')->nullable()->after('context_summary'); - $table->json('artifacts')->nullable()->after('work_log'); - $table->json('handoff_notes')->nullable()->after('artifacts'); - $table->text('final_summary')->nullable()->after('handoff_notes'); - $table->timestamp('started_at')->nullable()->after('final_summary'); - $table->timestamp('ended_at')->nullable()->after('started_at'); }); } @@ -97,20 +91,10 @@ return new class extends Migration { Schema::disableForeignKeyConstraints(); - // Remove agent_sessions additions if table exists if (Schema::hasTable('agent_sessions') && Schema::hasColumn('agent_sessions', 'agent_plan_id')) { Schema::table('agent_sessions', function (Blueprint $table) { $table->dropForeign(['agent_plan_id']); - $table->dropColumn([ - 'agent_plan_id', - 'context_summary', - 'work_log', - 'artifacts', - 'handoff_notes', - 'final_summary', - 'started_at', - 'ended_at', - ]); + $table->dropColumn('agent_plan_id'); }); } diff --git a/View/Modal/Admin/Dashboard.php b/View/Modal/Admin/Dashboard.php index d6b91d3..5f623c3 100644 --- a/View/Modal/Admin/Dashboard.php +++ b/View/Modal/Admin/Dashboard.php @@ -28,19 +28,34 @@ class Dashboard extends Component public function stats(): array { return $this->cacheWithLock('admin.agents.dashboard.stats', 60, function () { - $activePlans = AgentPlan::active()->count(); - $totalPlans = AgentPlan::notArchived()->count(); - $activeSessions = AgentSession::active()->count(); - $todaySessions = AgentSession::whereDate('started_at', today())->count(); + try { + $activePlans = AgentPlan::active()->count(); + $totalPlans = AgentPlan::notArchived()->count(); + } catch (\Throwable) { + $activePlans = 0; + $totalPlans = 0; + } - // Tool call stats for last 7 days - $toolStats = McpToolCallStat::last7Days() - ->selectRaw('SUM(call_count) as total_calls') - ->selectRaw('SUM(success_count) as total_success') - ->first(); + try { + $activeSessions = AgentSession::active()->count(); + $todaySessions = AgentSession::whereDate('started_at', today())->count(); + } catch (\Throwable) { + $activeSessions = 0; + $todaySessions = 0; + } + + try { + $toolStats = McpToolCallStat::last7Days() + ->selectRaw('SUM(call_count) as total_calls') + ->selectRaw('SUM(success_count) as total_success') + ->first(); + $totalCalls = $toolStats->total_calls ?? 0; + $totalSuccess = $toolStats->total_success ?? 0; + } catch (\Throwable) { + $totalCalls = 0; + $totalSuccess = 0; + } - $totalCalls = $toolStats->total_calls ?? 0; - $totalSuccess = $toolStats->total_success ?? 0; $successRate = $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0; return [ @@ -124,40 +139,46 @@ class Dashboard extends Component return $this->cacheWithLock('admin.agents.dashboard.activity', 30, function () { $activities = []; - // Recent plan updates - $plans = AgentPlan::with('workspace') - ->latest('updated_at') - ->take(5) - ->get(); + try { + $plans = AgentPlan::with('workspace') + ->latest('updated_at') + ->take(5) + ->get(); - foreach ($plans as $plan) { - $activities[] = [ - 'type' => 'plan', - 'icon' => 'clipboard-list', - 'title' => "Plan \"{$plan->title}\"", - 'description' => "Status: {$plan->status}", - 'workspace' => $plan->workspace?->name ?? 'Unknown', - 'time' => $plan->updated_at, - 'link' => route('hub.agents.plans.show', $plan->slug), - ]; + foreach ($plans as $plan) { + $activities[] = [ + 'type' => 'plan', + 'icon' => 'clipboard-list', + 'title' => "Plan \"{$plan->title}\"", + 'description' => "Status: {$plan->status}", + 'workspace' => $plan->workspace?->name ?? 'Unknown', + 'time' => $plan->updated_at, + 'link' => route('hub.agents.plans.show', $plan->slug), + ]; + } + } catch (\Throwable) { + // Table may not exist yet } - // Recent sessions - $sessions = AgentSession::with(['plan', 'workspace']) - ->latest('last_active_at') - ->take(5) - ->get(); + try { + $sessions = AgentSession::with(['plan', 'workspace']) + ->latest('last_active_at') + ->take(5) + ->get(); - foreach ($sessions as $session) { - $activities[] = [ - 'type' => 'session', - 'icon' => 'play', - 'title' => "Session {$session->session_id}", - 'description' => $session->plan?->title ?? 'No plan', - 'workspace' => $session->workspace?->name ?? 'Unknown', - 'time' => $session->last_active_at ?? $session->created_at, - 'link' => route('hub.agents.sessions.show', $session->id), - ]; + foreach ($sessions as $session) { + $activities[] = [ + 'type' => 'session', + 'icon' => 'play', + 'title' => "Session {$session->session_id}", + 'description' => $session->plan?->title ?? 'No plan', + 'workspace' => $session->workspace?->name ?? 'Unknown', + 'time' => $session->last_active_at ?? $session->created_at, + 'link' => route('hub.agents.sessions.show', $session->id), + ]; + } + } catch (\Throwable) { + // Table may not exist yet } // Sort by time descending @@ -171,7 +192,11 @@ class Dashboard extends Component public function topTools(): \Illuminate\Support\Collection { return $this->cacheWithLock('admin.agents.dashboard.toptools', 300, function () { - return McpToolCallStat::getTopTools(days: 7, limit: 5); + try { + return McpToolCallStat::getTopTools(days: 7, limit: 5); + } catch (\Throwable) { + return collect(); + } }); } @@ -179,7 +204,11 @@ class Dashboard extends Component public function dailyTrend(): \Illuminate\Support\Collection { return $this->cacheWithLock('admin.agents.dashboard.dailytrend', 300, function () { - return McpToolCallStat::getDailyTrend(days: 7); + try { + return McpToolCallStat::getDailyTrend(days: 7); + } catch (\Throwable) { + return collect(); + } }); } @@ -187,11 +216,15 @@ class Dashboard extends Component public function blockedPlans(): int { return (int) $this->cacheWithLock('admin.agents.dashboard.blocked', 60, function () { - return AgentPlan::active() - ->whereHas('agentPhases', function ($query) { - $query->where('status', 'blocked'); - }) - ->count(); + try { + return AgentPlan::active() + ->whereHas('agentPhases', function ($query) { + $query->where('status', 'blocked'); + }) + ->count(); + } catch (\Throwable) { + return 0; + } }); }