Compare commits

..

3 commits
dev ... main

Author SHA1 Message Date
Snider
cda896ebe0 fix(migrations): make idempotent and align schemas with models
Some checks are pending
CI / PHP 8.2 (push) Waiting to run
CI / PHP 8.3 (push) Waiting to run
CI / PHP 8.4 (push) Waiting to run
CI / Assets (push) Waiting to run
- All package migrations now guarded with hasTable()/hasColumn()
  so they coexist with the consolidated app-level migration
- Migration 000001: aligned agent_api_keys and agent_sessions
  schemas with current model expectations (key not key_hash,
  session_id not uuid, etc.)
- Migration 000002: hasColumn guards for ALTER TABLE safety
- Migration 000003: hasTable guards for all CREATE TABLE calls
- Dashboard: wrap all queries in try/catch so /hub/agents loads
  even when tables haven't been migrated yet

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-08 21:07:57 +00:00
Snider
c439194c18 feat(menu): move Agentic to dedicated agents group
Moves from dashboard group to the new agents group in AdminMenuRegistry,
giving it top-level visibility as the platform's primary capability.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-08 20:10:24 +00:00
Snider
bf7c0d7d61 fix(models): add context array cast to AgentPlan
The context column (longText) was missing its array cast, causing
"Array to string conversion" errors when creating plans via MCP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:58:44 +00:00
6 changed files with 211 additions and 206 deletions

View file

@ -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();
}

View file

@ -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);
}
});
}
};

View file

@ -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');
});
}

View file

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

View file

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

View file

@ -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;
}
});
}