Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

6 changed files with 206 additions and 211 deletions

View file

@ -10,56 +10,93 @@ return new class extends Migration
{ {
/** /**
* Agentic module tables - AI agents, tasks, sessions. * 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 public function up(): void
{ {
Schema::disableForeignKeyConstraints(); Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('agent_api_keys')) { // 1. Agent API Keys
Schema::create('agent_api_keys', function (Blueprint $table) { Schema::create('agent_api_keys', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); $table->uuid('uuid')->unique();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('name'); $table->string('name');
$table->string('key'); $table->string('key_hash', 64)->unique();
$table->json('permissions')->nullable(); $table->string('key_prefix', 12);
$table->unsignedInteger('rate_limit')->nullable(); $table->json('allowed_agents')->nullable();
$table->unsignedBigInteger('call_count')->default(0); $table->json('rate_limits')->nullable();
$table->timestamp('last_used_at')->nullable(); $table->boolean('is_active')->default(true);
$table->timestamp('expires_at')->nullable(); $table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable(); $table->timestamp('last_used_at')->nullable();
$table->unsignedBigInteger('usage_count')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['workspace_id', 'is_active']);
$table->index('key_prefix');
});
// 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(); $table->timestamps();
$table->index('workspace_id'); $table->index(['workspace_id', 'status']);
$table->index('key'); $table->index(['agent_type', 'status']);
$table->index('created_at');
}); });
}
if (! Schema::hasTable('agent_sessions')) { // 3. Agent Sessions
Schema::create('agent_sessions', function (Blueprint $table) { Schema::create('agent_sessions', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); $table->uuid('uuid')->unique();
$table->foreignId('agent_api_key_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('session_id')->unique(); $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('agent_type')->nullable(); $table->string('agent_type');
$table->string('status')->default('active'); $table->string('status', 32)->default('active');
$table->json('context_summary')->nullable(); $table->json('context')->nullable();
$table->json('work_log')->nullable(); $table->json('memory')->nullable();
$table->json('artifacts')->nullable(); $table->unsignedInteger('message_count')->default(0);
$table->json('handoff_notes')->nullable(); $table->unsignedInteger('total_tokens')->default(0);
$table->text('final_summary')->nullable(); $table->decimal('total_cost', 10, 6)->default(0);
$table->timestamp('started_at')->nullable(); $table->timestamp('last_activity_at')->nullable();
$table->timestamp('last_active_at')->nullable();
$table->timestamp('ended_at')->nullable();
$table->timestamps(); $table->timestamps();
$table->index('workspace_id'); $table->index(['workspace_id', 'status']);
$table->index('status'); $table->index(['agent_type', 'status']);
$table->index('agent_type'); $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']);
}); });
}
Schema::enableForeignKeyConstraints(); Schema::enableForeignKeyConstraints();
} }
@ -67,7 +104,9 @@ return new class extends Migration
public function down(): void public function down(): void
{ {
Schema::disableForeignKeyConstraints(); Schema::disableForeignKeyConstraints();
Schema::dropIfExists('agent_messages');
Schema::dropIfExists('agent_sessions'); Schema::dropIfExists('agent_sessions');
Schema::dropIfExists('agent_tasks');
Schema::dropIfExists('agent_api_keys'); Schema::dropIfExists('agent_api_keys');
Schema::enableForeignKeyConstraints(); Schema::enableForeignKeyConstraints();
} }

View file

@ -13,43 +13,17 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
if (! Schema::hasTable('agent_api_keys')) {
return;
}
Schema::table('agent_api_keys', function (Blueprint $table) { Schema::table('agent_api_keys', function (Blueprint $table) {
if (! Schema::hasColumn('agent_api_keys', 'ip_restriction_enabled')) { $table->boolean('ip_restriction_enabled')->default(false)->after('rate_limits');
$table->boolean('ip_restriction_enabled')->default(false); $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_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 public function down(): void
{ {
if (! Schema::hasTable('agent_api_keys')) {
return;
}
Schema::table('agent_api_keys', function (Blueprint $table) { Schema::table('agent_api_keys', function (Blueprint $table) {
$cols = []; $table->dropColumn(['ip_restriction_enabled', 'ip_whitelist', 'last_used_ip']);
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);
}
}); });
} }
}; };

View file

@ -11,22 +11,22 @@ return new class extends Migration
/** /**
* Create agent plans, phases, and workspace states tables. * Create agent plans, phases, and workspace states tables.
* *
* Guarded with hasTable() so this migration is idempotent and * These tables support the structured work plan system that enables
* can coexist with the consolidated app-level migration. * multi-agent handoff and context recovery across sessions.
*/ */
public function up(): void public function up(): void
{ {
Schema::disableForeignKeyConstraints(); Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('agent_plans')) { // 1. Agent Plans - structured work plans with phases
Schema::create('agent_plans', function (Blueprint $table) { Schema::create('agent_plans', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('slug')->unique(); $table->string('slug')->unique();
$table->string('title'); $table->string('title');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->longText('context')->nullable(); $table->longText('context')->nullable();
$table->json('phases')->nullable(); $table->json('phases')->nullable(); // Deprecated: use agent_phases table
$table->string('status', 32)->default('draft'); $table->string('status', 32)->default('draft');
$table->string('current_phase')->nullable(); $table->string('current_phase')->nullable();
$table->json('metadata')->nullable(); $table->json('metadata')->nullable();
@ -36,9 +36,8 @@ return new class extends Migration
$table->index(['workspace_id', 'status']); $table->index(['workspace_id', 'status']);
$table->index('slug'); $table->index('slug');
}); });
}
if (! Schema::hasTable('agent_phases')) { // 2. Agent Phases - individual phases within a plan
Schema::create('agent_phases', function (Blueprint $table) { Schema::create('agent_phases', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete(); $table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete();
@ -57,9 +56,8 @@ return new class extends Migration
$table->index(['agent_plan_id', 'order']); $table->index(['agent_plan_id', 'order']);
$table->index(['agent_plan_id', 'status']); $table->index(['agent_plan_id', 'status']);
}); });
}
if (! Schema::hasTable('agent_workspace_states')) { // 3. Agent Workspace States - shared context between sessions
Schema::create('agent_workspace_states', function (Blueprint $table) { Schema::create('agent_workspace_states', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete(); $table->foreignId('agent_plan_id')->constrained('agent_plans')->cascadeOnDelete();
@ -72,15 +70,23 @@ return new class extends Migration
$table->unique(['agent_plan_id', 'key']); $table->unique(['agent_plan_id', 'key']);
$table->index('key'); $table->index('key');
}); });
}
// Add agent_plan_id to agent_sessions if table exists // 4. Update agent_sessions to add agent_plan_id foreign key
// Note: Only run if agent_sessions table exists (from earlier migration)
if (Schema::hasTable('agent_sessions') && ! Schema::hasColumn('agent_sessions', 'agent_plan_id')) { if (Schema::hasTable('agent_sessions') && ! Schema::hasColumn('agent_sessions', 'agent_plan_id')) {
Schema::table('agent_sessions', function (Blueprint $table) { Schema::table('agent_sessions', function (Blueprint $table) {
$table->foreignId('agent_plan_id') $table->foreignId('agent_plan_id')
->nullable() ->nullable()
->after('workspace_id')
->constrained('agent_plans') ->constrained('agent_plans')
->nullOnDelete(); ->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');
}); });
} }
@ -91,10 +97,20 @@ return new class extends Migration
{ {
Schema::disableForeignKeyConstraints(); Schema::disableForeignKeyConstraints();
// Remove agent_sessions additions if table exists
if (Schema::hasTable('agent_sessions') && Schema::hasColumn('agent_sessions', 'agent_plan_id')) { if (Schema::hasTable('agent_sessions') && Schema::hasColumn('agent_sessions', 'agent_plan_id')) {
Schema::table('agent_sessions', function (Blueprint $table) { Schema::table('agent_sessions', function (Blueprint $table) {
$table->dropForeign(['agent_plan_id']); $table->dropForeign(['agent_plan_id']);
$table->dropColumn('agent_plan_id'); $table->dropColumn([
'agent_plan_id',
'context_summary',
'work_log',
'artifacts',
'handoff_notes',
'final_summary',
'started_at',
'ended_at',
]);
}); });
} }

View file

@ -63,7 +63,6 @@ class AgentPlan extends Model
]; ];
protected $casts = [ protected $casts = [
'context' => 'array',
'phases' => 'array', 'phases' => 'array',
'metadata' => 'array', 'metadata' => 'array',
]; ];

View file

@ -58,14 +58,14 @@ class Boot extends ServiceProvider implements ServiceDefinition
/** /**
* Admin menu items for this service. * Admin menu items for this service.
* *
* Agentic has its own top-level group right after Dashboard * Agentic is positioned in the dashboard group (not services)
* this is the primary capability of the platform. * as it's a cross-cutting AI capability, not a standalone product.
*/ */
public function adminMenuItems(): array public function adminMenuItems(): array
{ {
return [ return [
[ [
'group' => 'agents', 'group' => 'dashboard',
'priority' => 5, 'priority' => 5,
'entitlement' => 'core.srv.agentic', 'entitlement' => 'core.srv.agentic',
'item' => fn () => [ 'item' => fn () => [

View file

@ -28,34 +28,19 @@ class Dashboard extends Component
public function stats(): array public function stats(): array
{ {
return $this->cacheWithLock('admin.agents.dashboard.stats', 60, function () { return $this->cacheWithLock('admin.agents.dashboard.stats', 60, function () {
try {
$activePlans = AgentPlan::active()->count(); $activePlans = AgentPlan::active()->count();
$totalPlans = AgentPlan::notArchived()->count(); $totalPlans = AgentPlan::notArchived()->count();
} catch (\Throwable) {
$activePlans = 0;
$totalPlans = 0;
}
try {
$activeSessions = AgentSession::active()->count(); $activeSessions = AgentSession::active()->count();
$todaySessions = AgentSession::whereDate('started_at', today())->count(); $todaySessions = AgentSession::whereDate('started_at', today())->count();
} catch (\Throwable) {
$activeSessions = 0;
$todaySessions = 0;
}
try { // Tool call stats for last 7 days
$toolStats = McpToolCallStat::last7Days() $toolStats = McpToolCallStat::last7Days()
->selectRaw('SUM(call_count) as total_calls') ->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(success_count) as total_success') ->selectRaw('SUM(success_count) as total_success')
->first(); ->first();
$totalCalls = $toolStats->total_calls ?? 0; $totalCalls = $toolStats->total_calls ?? 0;
$totalSuccess = $toolStats->total_success ?? 0; $totalSuccess = $toolStats->total_success ?? 0;
} catch (\Throwable) {
$totalCalls = 0;
$totalSuccess = 0;
}
$successRate = $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0; $successRate = $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0;
return [ return [
@ -139,7 +124,7 @@ class Dashboard extends Component
return $this->cacheWithLock('admin.agents.dashboard.activity', 30, function () { return $this->cacheWithLock('admin.agents.dashboard.activity', 30, function () {
$activities = []; $activities = [];
try { // Recent plan updates
$plans = AgentPlan::with('workspace') $plans = AgentPlan::with('workspace')
->latest('updated_at') ->latest('updated_at')
->take(5) ->take(5)
@ -156,11 +141,8 @@ class Dashboard extends Component
'link' => route('hub.agents.plans.show', $plan->slug), 'link' => route('hub.agents.plans.show', $plan->slug),
]; ];
} }
} catch (\Throwable) {
// Table may not exist yet
}
try { // Recent sessions
$sessions = AgentSession::with(['plan', 'workspace']) $sessions = AgentSession::with(['plan', 'workspace'])
->latest('last_active_at') ->latest('last_active_at')
->take(5) ->take(5)
@ -177,9 +159,6 @@ class Dashboard extends Component
'link' => route('hub.agents.sessions.show', $session->id), 'link' => route('hub.agents.sessions.show', $session->id),
]; ];
} }
} catch (\Throwable) {
// Table may not exist yet
}
// Sort by time descending // Sort by time descending
usort($activities, fn ($a, $b) => $b['time'] <=> $a['time']); usort($activities, fn ($a, $b) => $b['time'] <=> $a['time']);
@ -192,11 +171,7 @@ class Dashboard extends Component
public function topTools(): \Illuminate\Support\Collection public function topTools(): \Illuminate\Support\Collection
{ {
return $this->cacheWithLock('admin.agents.dashboard.toptools', 300, function () { return $this->cacheWithLock('admin.agents.dashboard.toptools', 300, function () {
try {
return McpToolCallStat::getTopTools(days: 7, limit: 5); return McpToolCallStat::getTopTools(days: 7, limit: 5);
} catch (\Throwable) {
return collect();
}
}); });
} }
@ -204,11 +179,7 @@ class Dashboard extends Component
public function dailyTrend(): \Illuminate\Support\Collection public function dailyTrend(): \Illuminate\Support\Collection
{ {
return $this->cacheWithLock('admin.agents.dashboard.dailytrend', 300, function () { return $this->cacheWithLock('admin.agents.dashboard.dailytrend', 300, function () {
try {
return McpToolCallStat::getDailyTrend(days: 7); return McpToolCallStat::getDailyTrend(days: 7);
} catch (\Throwable) {
return collect();
}
}); });
} }
@ -216,15 +187,11 @@ class Dashboard extends Component
public function blockedPlans(): int public function blockedPlans(): int
{ {
return (int) $this->cacheWithLock('admin.agents.dashboard.blocked', 60, function () { return (int) $this->cacheWithLock('admin.agents.dashboard.blocked', 60, function () {
try {
return AgentPlan::active() return AgentPlan::active()
->whereHas('agentPhases', function ($query) { ->whereHas('agentPhases', function ($query) {
$query->where('status', 'blocked'); $query->where('status', 'blocked');
}) })
->count(); ->count();
} catch (\Throwable) {
return 0;
}
}); });
} }