diff --git a/Boot.php b/Boot.php index 60ec157..d246032 100644 --- a/Boot.php +++ b/Boot.php @@ -123,6 +123,25 @@ class Boot extends ServiceProvider // Event-driven handlers (for lazy loading once event system is integrated) // ------------------------------------------------------------------------- + /** + * Handle API routes registration event. + * + * Registers REST API endpoints for go-agentic Client consumption. + * Routes are prefixed with /api by the API middleware group. + */ + public function onApiRoutes(ApiRoutesRegistering $event): void + { + // Register agent API auth middleware alias + $event->middleware('agent.auth', Middleware\AgentApiAuth::class); + + // Agent API routes are registered at /v1/* (no /api prefix) + // because the go-agentic Client uses BaseURL + "/v1/plans" directly. + // We register here but with our own Route group to avoid the + // automatic /api prefix from LifecycleEventProvider. + \Illuminate\Support\Facades\Route::middleware('api') + ->group(__DIR__.'/Routes/api.php'); + } + /** * Handle admin panel booting event. */ @@ -163,6 +182,9 @@ class Boot extends ServiceProvider */ public function onConsole(ConsoleBooting $event): void { + // Register middleware alias for CLI context (artisan route:list) + $event->middleware('agent.auth', Middleware\AgentApiAuth::class); + $event->command(Console\Commands\TaskCommand::class); $event->command(Console\Commands\PlanCommand::class); $event->command(Console\Commands\GenerateCommand::class); diff --git a/Controllers/AgentApiController.php b/Controllers/AgentApiController.php new file mode 100644 index 0000000..6cb072b --- /dev/null +++ b/Controllers/AgentApiController.php @@ -0,0 +1,649 @@ +json([ + 'status' => 'ok', + 'service' => 'core-agentic', + 'timestamp' => now()->toIso8601String(), + ]); + } + + // ------------------------------------------------------------------------- + // Plans + // ------------------------------------------------------------------------- + + /** + * GET /v1/plans + * + * List plans with optional status filter. + * Query params: status, include_archived + */ + public function listPlans(Request $request): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $query = AgentPlan::where('workspace_id', $workspaceId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if (! $request->boolean('include_archived')) { + $query->notArchived(); + } + + $plans = $query->orderByStatus()->latest()->get(); + + return response()->json([ + 'plans' => $plans->map(fn (AgentPlan $p) => $this->formatPlan($p)), + 'total' => $plans->count(), + ]); + } + + /** + * GET /v1/plans/{slug} + * + * Get plan detail with phases. + */ + public function getPlan(Request $request, string $slug): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $plan = AgentPlan::where('workspace_id', $workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + return response()->json($this->formatPlanDetail($plan)); + } + + /** + * POST /v1/plans + * + * Create a new plan with optional phases. + */ + public function createPlan(Request $request): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'context' => 'nullable|array', + 'phases' => 'nullable|array', + 'phases.*.name' => 'required|string', + 'phases.*.description' => 'nullable|string', + 'phases.*.tasks' => 'nullable|array', + ]); + + $slug = $validated['slug'] ?? AgentPlan::generateSlug($validated['title']); + + $plan = AgentPlan::create([ + 'workspace_id' => $workspaceId, + 'slug' => $slug, + 'title' => $validated['title'], + 'description' => $validated['description'] ?? null, + 'context' => $validated['context'] ?? null, + 'status' => AgentPlan::STATUS_DRAFT, + ]); + + // Create phases if provided + $phaseCount = 0; + if (! empty($validated['phases'])) { + foreach ($validated['phases'] as $order => $phaseData) { + $tasks = []; + foreach ($phaseData['tasks'] ?? [] as $taskName) { + $tasks[] = ['name' => $taskName, 'status' => 'pending']; + } + + AgentPhase::create([ + 'agent_plan_id' => $plan->id, + 'order' => $order, + 'name' => $phaseData['name'], + 'description' => $phaseData['description'] ?? null, + 'tasks' => $tasks ?: null, + 'status' => AgentPhase::STATUS_PENDING, + ]); + $phaseCount++; + } + } + + return response()->json([ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $phaseCount, + ], 201); + } + + /** + * PATCH /v1/plans/{slug} + * + * Update plan status. + */ + public function updatePlan(Request $request, string $slug): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $plan = AgentPlan::where('workspace_id', $workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $validated = $request->validate([ + 'status' => 'required|string|in:draft,active,completed,archived', + ]); + + match ($validated['status']) { + 'active' => $plan->activate(), + 'completed' => $plan->complete(), + 'archived' => $plan->archive(), + default => $plan->update(['status' => $validated['status']]), + }; + + return response()->json([ + 'slug' => $plan->slug, + 'status' => $plan->fresh()->status, + ]); + } + + /** + * DELETE /v1/plans/{slug} + * + * Archive a plan with optional reason. + */ + public function archivePlan(Request $request, string $slug): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $plan = AgentPlan::where('workspace_id', $workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $reason = $request->input('reason'); + $plan->archive($reason); + + return response()->json([ + 'slug' => $plan->slug, + 'status' => 'archived', + 'archived_at' => now()->toIso8601String(), + ]); + } + + // ------------------------------------------------------------------------- + // Phases + // ------------------------------------------------------------------------- + + /** + * GET /v1/plans/{slug}/phases/{phase} + * + * Get a phase by order number. + */ + public function getPhase(Request $request, string $slug, string $phase): JsonResponse + { + $plan = $this->findPlan($request, $slug); + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first(); + if (! $agentPhase) { + return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404); + } + + return response()->json($this->formatPhase($agentPhase)); + } + + /** + * PATCH /v1/plans/{slug}/phases/{phase} + * + * Update phase status and/or notes. + */ + public function updatePhase(Request $request, string $slug, string $phase): JsonResponse + { + $plan = $this->findPlan($request, $slug); + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first(); + if (! $agentPhase) { + return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404); + } + + $status = $request->input('status'); + $notes = $request->input('notes'); + + if ($status) { + match ($status) { + 'in_progress' => $agentPhase->start(), + 'completed' => $agentPhase->complete(), + 'blocked' => $agentPhase->block($notes), + 'skipped' => $agentPhase->skip($notes), + 'pending' => $agentPhase->reset(), + default => null, + }; + } + + if ($notes && ! in_array($status, ['blocked', 'skipped'])) { + $agentPhase->addCheckpoint($notes); + } + + return response()->json([ + 'slug' => $slug, + 'phase' => (int) $phase, + 'status' => $agentPhase->fresh()->status, + ]); + } + + /** + * POST /v1/plans/{slug}/phases/{phase}/checkpoint + * + * Add a checkpoint to a phase. + */ + public function addCheckpoint(Request $request, string $slug, string $phase): JsonResponse + { + $plan = $this->findPlan($request, $slug); + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first(); + if (! $agentPhase) { + return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404); + } + + $validated = $request->validate([ + 'note' => 'required|string', + 'context' => 'nullable|array', + ]); + + $agentPhase->addCheckpoint($validated['note'], $validated['context'] ?? []); + + return response()->json([ + 'slug' => $slug, + 'phase' => (int) $phase, + 'checkpoints' => count($agentPhase->fresh()->getCheckpoints()), + ]); + } + + /** + * PATCH /v1/plans/{slug}/phases/{phase}/tasks/{taskIdx} + * + * Update a task within a phase. + */ + public function updateTask(Request $request, string $slug, string $phase, int $taskIdx): JsonResponse + { + $plan = $this->findPlan($request, $slug); + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first(); + if (! $agentPhase) { + return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404); + } + + $tasks = $agentPhase->tasks ?? []; + if (! isset($tasks[$taskIdx])) { + return response()->json(['error' => 'not_found', 'message' => 'Task not found'], 404); + } + + $status = $request->input('status'); + $notes = $request->input('notes'); + + if (is_string($tasks[$taskIdx])) { + $tasks[$taskIdx] = ['name' => $tasks[$taskIdx], 'status' => $status ?? 'pending']; + } else { + if ($status) { + $tasks[$taskIdx]['status'] = $status; + } + } + + if ($notes) { + $tasks[$taskIdx]['notes'] = $notes; + } + + $agentPhase->update(['tasks' => $tasks]); + + return response()->json([ + 'slug' => $slug, + 'phase' => (int) $phase, + 'task' => $taskIdx, + 'status' => $tasks[$taskIdx]['status'] ?? 'pending', + ]); + } + + /** + * POST /v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle + * + * Toggle a task between pending and completed. + */ + public function toggleTask(Request $request, string $slug, string $phase, int $taskIdx): JsonResponse + { + $plan = $this->findPlan($request, $slug); + if (! $plan) { + return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404); + } + + $agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first(); + if (! $agentPhase) { + return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404); + } + + $tasks = $agentPhase->tasks ?? []; + if (! isset($tasks[$taskIdx])) { + return response()->json(['error' => 'not_found', 'message' => 'Task not found'], 404); + } + + if (is_string($tasks[$taskIdx])) { + $tasks[$taskIdx] = ['name' => $tasks[$taskIdx], 'status' => 'completed']; + } else { + $current = $tasks[$taskIdx]['status'] ?? 'pending'; + $tasks[$taskIdx]['status'] = $current === 'completed' ? 'pending' : 'completed'; + } + + $agentPhase->update(['tasks' => $tasks]); + + return response()->json([ + 'slug' => $slug, + 'phase' => (int) $phase, + 'task' => $taskIdx, + 'status' => $tasks[$taskIdx]['status'] ?? 'pending', + ]); + } + + // ------------------------------------------------------------------------- + // Sessions + // ------------------------------------------------------------------------- + + /** + * GET /v1/sessions + * + * List sessions with optional filters. + * Query params: status, plan_slug, limit + */ + public function listSessions(Request $request): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $query = AgentSession::where('workspace_id', $workspaceId); + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + if ($planSlug = $request->query('plan_slug')) { + $plan = AgentPlan::where('workspace_id', $workspaceId) + ->where('slug', $planSlug) + ->first(); + if ($plan) { + $query->where('agent_plan_id', $plan->id); + } else { + return response()->json(['sessions' => [], 'total' => 0]); + } + } + + $limit = (int) ($request->query('limit') ?: 50); + $sessions = $query->latest('started_at')->limit($limit)->get(); + + return response()->json([ + 'sessions' => $sessions->map(fn (AgentSession $s) => $this->formatSession($s)), + 'total' => $sessions->count(), + ]); + } + + /** + * GET /v1/sessions/{sessionId} + * + * Get session detail. + */ + public function getSession(Request $request, string $sessionId): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $session = AgentSession::where('workspace_id', $workspaceId) + ->where('session_id', $sessionId) + ->first(); + + if (! $session) { + return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404); + } + + return response()->json($this->formatSession($session)); + } + + /** + * POST /v1/sessions + * + * Start a new session. + */ + public function startSession(Request $request): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + $apiKey = $request->attributes->get('agent_api_key'); + + $validated = $request->validate([ + 'agent_type' => 'required|string', + 'plan_slug' => 'nullable|string', + 'context' => 'nullable|array', + ]); + + $plan = null; + if (! empty($validated['plan_slug'])) { + $plan = AgentPlan::where('workspace_id', $workspaceId) + ->where('slug', $validated['plan_slug']) + ->first(); + } + + $session = AgentSession::create([ + 'workspace_id' => $workspaceId, + 'agent_api_key_id' => $apiKey?->id, + 'agent_plan_id' => $plan?->id, + 'session_id' => 'sess_' . \Ramsey\Uuid\Uuid::uuid4()->toString(), + 'agent_type' => $validated['agent_type'], + 'status' => AgentSession::STATUS_ACTIVE, + 'context_summary' => $validated['context'] ?? [], + 'work_log' => [], + 'artifacts' => [], + 'started_at' => now(), + 'last_active_at' => now(), + ]); + + return response()->json([ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan' => $plan?->slug, + 'status' => $session->status, + ], 201); + } + + /** + * POST /v1/sessions/{sessionId}/end + * + * End a session. + */ + public function endSession(Request $request, string $sessionId): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $session = AgentSession::where('workspace_id', $workspaceId) + ->where('session_id', $sessionId) + ->first(); + + if (! $session) { + return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404); + } + + $validated = $request->validate([ + 'status' => 'required|string|in:completed,failed', + 'summary' => 'nullable|string', + ]); + + $session->end($validated['status'], $validated['summary'] ?? null); + + return response()->json([ + 'session_id' => $session->session_id, + 'status' => $session->fresh()->status, + 'duration' => $session->getDurationFormatted(), + ]); + } + + /** + * POST /v1/sessions/{sessionId}/continue + * + * Continue from a previous session (multi-agent handoff). + */ + public function continueSession(Request $request, string $sessionId): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $previousSession = AgentSession::where('workspace_id', $workspaceId) + ->where('session_id', $sessionId) + ->first(); + + if (! $previousSession) { + return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404); + } + + $validated = $request->validate([ + 'agent_type' => 'required|string', + ]); + + $newSession = $previousSession->createReplaySession($validated['agent_type']); + + return response()->json([ + 'session_id' => $newSession->session_id, + 'agent_type' => $newSession->agent_type, + 'plan' => $newSession->plan?->slug, + 'status' => $newSession->status, + 'continued_from' => $previousSession->session_id, + ], 201); + } + + // ------------------------------------------------------------------------- + // Formatters (match go-agentic JSON contract) + // ------------------------------------------------------------------------- + + private function formatPlan(AgentPlan $plan): array + { + $progress = $plan->getProgress(); + + return [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'description' => $plan->description, + 'status' => $plan->status, + 'current_phase' => $plan->current_phase !== null ? (int) $plan->current_phase : null, + 'progress' => $progress, + 'metadata' => $plan->metadata, + 'created_at' => $plan->created_at?->toIso8601String(), + 'updated_at' => $plan->updated_at?->toIso8601String(), + ]; + } + + private function formatPlanDetail(AgentPlan $plan): array + { + $data = $this->formatPlan($plan); + $data['phases'] = $plan->agentPhases->map(fn (AgentPhase $p) => $this->formatPhase($p))->all(); + + return $data; + } + + private function formatPhase(AgentPhase $phase): array + { + $taskProgress = $phase->getTaskProgress(); + + return [ + 'id' => $phase->id, + 'order' => $phase->order, + 'name' => $phase->name, + 'description' => $phase->description, + 'status' => $phase->status, + 'tasks' => $phase->tasks, + 'task_progress' => [ + 'total' => $taskProgress['total'], + 'completed' => $taskProgress['completed'], + 'pending' => $taskProgress['remaining'], + 'percentage' => (int) $taskProgress['percentage'], + ], + 'remaining_tasks' => $phase->getRemainingTasks(), + 'dependencies' => $phase->dependencies, + 'dependency_blockers' => $phase->checkDependencies(), + 'can_start' => $phase->canStart(), + 'checkpoints' => $phase->getCheckpoints(), + 'started_at' => $phase->started_at?->toIso8601String(), + 'completed_at' => $phase->completed_at?->toIso8601String(), + 'metadata' => $phase->metadata, + ]; + } + + private function formatSession(AgentSession $session): array + { + return [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'status' => $session->status, + 'plan_slug' => $session->plan?->slug, + 'plan' => $session->plan?->slug, + 'duration' => $session->getDurationFormatted(), + 'started_at' => $session->started_at?->toIso8601String(), + 'last_active_at' => $session->last_active_at?->toIso8601String(), + 'ended_at' => $session->ended_at?->toIso8601String(), + 'action_count' => count($session->work_log ?? []), + 'artifact_count' => count($session->artifacts ?? []), + 'context_summary' => $session->context_summary, + 'handoff_notes' => $session->handoff_notes ? ($session->handoff_notes['summary'] ?? '') : null, + ]; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function findPlan(Request $request, string $slug): ?AgentPlan + { + $workspaceId = $request->attributes->get('workspace_id'); + + return AgentPlan::where('workspace_id', $workspaceId) + ->where('slug', $slug) + ->first(); + } +} diff --git a/Routes/api.php b/Routes/api.php deleted file mode 100644 index 1cd2ab0..0000000 --- a/Routes/api.php +++ /dev/null @@ -1,69 +0,0 @@ - response()->json(['status' => 'ok', 'timestamp' => now()->toIso8601String()])); - -// Authenticated endpoints -Route::middleware(['api.auth', 'api.scope.enforce'])->group(function () { - - // Brain (OpenBrain knowledge store) - Route::prefix('brain')->name('brain.')->group(function () { - Route::post('remember', [BrainController::class, 'remember'])->name('remember'); - Route::post('recall', [BrainController::class, 'recall'])->name('recall'); - Route::delete('forget/{id}', [BrainController::class, 'forget'])->name('forget') - ->where('id', '[0-9a-f-]+'); - Route::get('list', [BrainController::class, 'list'])->name('list'); - }); - - // Plans - Route::prefix('plans')->name('plans.')->group(function () { - Route::get('/', [PlanController::class, 'index'])->name('index'); - Route::get('{slug}', [PlanController::class, 'show'])->name('show'); - Route::post('/', [PlanController::class, 'store'])->name('store'); - Route::patch('{slug}', [PlanController::class, 'update'])->name('update'); - Route::delete('{slug}', [PlanController::class, 'destroy'])->name('destroy'); - - // Phases (nested under plans) - Route::prefix('{slug}/phases')->name('phases.')->group(function () { - Route::get('{phase}', [PhaseController::class, 'show'])->name('show'); - Route::patch('{phase}', [PhaseController::class, 'update'])->name('update'); - Route::post('{phase}/checkpoint', [PhaseController::class, 'checkpoint'])->name('checkpoint'); - - // Tasks (nested under phases) - Route::prefix('{phase}/tasks')->name('tasks.')->group(function () { - Route::patch('{index}', [TaskController::class, 'update'])->name('update') - ->where('index', '[0-9]+'); - Route::post('{index}/toggle', [TaskController::class, 'toggle'])->name('toggle') - ->where('index', '[0-9]+'); - }); - }); - }); - - // Sessions - Route::prefix('sessions')->name('sessions.')->group(function () { - Route::get('/', [SessionController::class, 'index'])->name('index'); - Route::get('{id}', [SessionController::class, 'show'])->name('show'); - Route::post('/', [SessionController::class, 'store'])->name('store'); - Route::post('{id}/end', [SessionController::class, 'end'])->name('end'); - Route::post('{id}/continue', [SessionController::class, 'continue'])->name('continue'); - }); -});