From 6f0618692a0552448daf9d9f90accd2fe82be51f Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Mar 2026 13:58:45 +0000 Subject: [PATCH] feat: add plan/session/phase/task Actions + slim MCP tools Extract business logic from MCP tool handlers into 15 Action classes (Plan 5, Session 5, Phase 3, Task 2) following the Brain pattern. MCP tools become thin wrappers calling Action::run(). Add framework-level REST controllers and routes as sensible defaults for consumers. Co-Authored-By: Virgil --- Actions/Brain/ListKnowledge.php | 1 - Actions/Phase/AddCheckpoint.php | 69 ++++++++ Actions/Phase/GetPhase.php | 66 +++++++ Actions/Phase/UpdatePhaseStatus.php | 79 +++++++++ Actions/Plan/ArchivePlan.php | 51 ++++++ Actions/Plan/CreatePlan.php | 89 ++++++++++ Actions/Plan/GetPlan.php | 50 ++++++ Actions/Plan/ListPlans.php | 59 +++++++ Actions/Plan/UpdatePlanStatus.php | 54 ++++++ Actions/Session/ContinueSession.php | 56 ++++++ Actions/Session/EndSession.php | 56 ++++++ Actions/Session/GetSession.php | 49 ++++++ Actions/Session/ListSessions.php | 68 ++++++++ Actions/Session/StartSession.php | 56 ++++++ Actions/Task/ToggleTask.php | 90 ++++++++++ Actions/Task/UpdateTask.php | 101 +++++++++++ Controllers/Api/PhaseController.php | 115 ++++++++++++ Controllers/Api/PlanController.php | 170 ++++++++++++++++++ Controllers/Api/SessionController.php | 173 +++++++++++++++++++ Controllers/Api/TaskController.php | 68 ++++++++ Mcp/Tools/Agent/Phase/PhaseAddCheckpoint.php | 54 ++---- Mcp/Tools/Agent/Phase/PhaseGet.php | 68 +++----- Mcp/Tools/Agent/Phase/PhaseUpdateStatus.php | 69 +++----- Mcp/Tools/Agent/Plan/PlanArchive.php | 37 ++-- Mcp/Tools/Agent/Plan/PlanCreate.php | 65 ++----- Mcp/Tools/Agent/Plan/PlanGet.php | 55 ++---- Mcp/Tools/Agent/Plan/PlanList.php | 51 ++---- Mcp/Tools/Agent/Plan/PlanUpdateStatus.php | 36 ++-- Mcp/Tools/Agent/Session/SessionContinue.php | 45 +++-- Mcp/Tools/Agent/Session/SessionEnd.php | 37 ++-- Mcp/Tools/Agent/Session/SessionList.php | 72 +++----- Mcp/Tools/Agent/Session/SessionStart.php | 51 ++---- Mcp/Tools/Agent/Task/TaskToggle.php | 73 ++------ Mcp/Tools/Agent/Task/TaskUpdate.php | 80 ++------- Routes/api.php | 52 +++++- 35 files changed, 1814 insertions(+), 551 deletions(-) create mode 100644 Actions/Phase/AddCheckpoint.php create mode 100644 Actions/Phase/GetPhase.php create mode 100644 Actions/Phase/UpdatePhaseStatus.php create mode 100644 Actions/Plan/ArchivePlan.php create mode 100644 Actions/Plan/CreatePlan.php create mode 100644 Actions/Plan/GetPlan.php create mode 100644 Actions/Plan/ListPlans.php create mode 100644 Actions/Plan/UpdatePlanStatus.php create mode 100644 Actions/Session/ContinueSession.php create mode 100644 Actions/Session/EndSession.php create mode 100644 Actions/Session/GetSession.php create mode 100644 Actions/Session/ListSessions.php create mode 100644 Actions/Session/StartSession.php create mode 100644 Actions/Task/ToggleTask.php create mode 100644 Actions/Task/UpdateTask.php create mode 100644 Controllers/Api/PhaseController.php create mode 100644 Controllers/Api/PlanController.php create mode 100644 Controllers/Api/SessionController.php create mode 100644 Controllers/Api/TaskController.php diff --git a/Actions/Brain/ListKnowledge.php b/Actions/Brain/ListKnowledge.php index e4c0e94..8484e51 100644 --- a/Actions/Brain/ListKnowledge.php +++ b/Actions/Brain/ListKnowledge.php @@ -6,7 +6,6 @@ namespace Core\Mod\Agentic\Actions\Brain; use Core\Actions\Action; use Core\Mod\Agentic\Models\BrainMemory; -use Illuminate\Support\Collection; /** * List memories in the shared OpenBrain knowledge store. diff --git a/Actions/Phase/AddCheckpoint.php b/Actions/Phase/AddCheckpoint.php new file mode 100644 index 0000000..fe138c0 --- /dev/null +++ b/Actions/Phase/AddCheckpoint.php @@ -0,0 +1,69 @@ +where('slug', $planSlug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$planSlug}"); + } + + $resolved = $this->resolvePhase($plan, $phase); + + if (! $resolved) { + throw new \InvalidArgumentException("Phase not found: {$phase}"); + } + + $resolved->addCheckpoint($note, $context); + + return $resolved->fresh(); + } + + private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where('name', $identifier) + ->first(); + } +} diff --git a/Actions/Phase/GetPhase.php b/Actions/Phase/GetPhase.php new file mode 100644 index 0000000..3e9647c --- /dev/null +++ b/Actions/Phase/GetPhase.php @@ -0,0 +1,66 @@ +where('slug', $planSlug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$planSlug}"); + } + + $resolved = $this->resolvePhase($plan, $phase); + + if (! $resolved) { + throw new \InvalidArgumentException("Phase not found: {$phase}"); + } + + return $resolved; + } + + private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where(function ($query) use ($identifier) { + $query->where('name', $identifier) + ->orWhere('order', $identifier); + }) + ->first(); + } +} diff --git a/Actions/Phase/UpdatePhaseStatus.php b/Actions/Phase/UpdatePhaseStatus.php new file mode 100644 index 0000000..a01e620 --- /dev/null +++ b/Actions/Phase/UpdatePhaseStatus.php @@ -0,0 +1,79 @@ +where('slug', $planSlug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$planSlug}"); + } + + $resolved = $this->resolvePhase($plan, $phase); + + if (! $resolved) { + throw new \InvalidArgumentException("Phase not found: {$phase}"); + } + + if ($notes !== null && $notes !== '') { + $resolved->addCheckpoint($notes, ['status_change' => $status]); + } + + $resolved->update(['status' => $status]); + + return $resolved->fresh(); + } + + private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where(function ($query) use ($identifier) { + $query->where('name', $identifier) + ->orWhere('order', $identifier); + }) + ->first(); + } +} diff --git a/Actions/Plan/ArchivePlan.php b/Actions/Plan/ArchivePlan.php new file mode 100644 index 0000000..4ba21fe --- /dev/null +++ b/Actions/Plan/ArchivePlan.php @@ -0,0 +1,51 @@ +where('slug', $slug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$slug}"); + } + + $plan->archive($reason); + + return $plan->fresh(); + } +} diff --git a/Actions/Plan/CreatePlan.php b/Actions/Plan/CreatePlan.php new file mode 100644 index 0000000..3f1fcfc --- /dev/null +++ b/Actions/Plan/CreatePlan.php @@ -0,0 +1,89 @@ + 'Deploy v2', + * 'phases' => [['name' => 'Build', 'tasks' => ['compile', 'test']]], + * ], 1); + */ +class CreatePlan +{ + use Action; + + /** + * @param array{title: string, slug?: string, description?: string, context?: array, phases?: array} $data + * + * @throws \InvalidArgumentException + */ + public function handle(array $data, int $workspaceId): AgentPlan + { + $title = $data['title'] ?? null; + if (! is_string($title) || $title === '' || mb_strlen($title) > 255) { + throw new \InvalidArgumentException('title is required and must be a non-empty string (max 255 characters)'); + } + + $slug = $data['slug'] ?? null; + if ($slug !== null) { + if (! is_string($slug) || mb_strlen($slug) > 255) { + throw new \InvalidArgumentException('slug must be a string (max 255 characters)'); + } + } else { + $slug = Str::slug($title).'-'.Str::random(6); + } + + if (AgentPlan::where('slug', $slug)->exists()) { + throw new \InvalidArgumentException("Plan with slug '{$slug}' already exists"); + } + + $plan = AgentPlan::create([ + 'slug' => $slug, + 'title' => $title, + 'description' => $data['description'] ?? null, + 'status' => AgentPlan::STATUS_DRAFT, + 'context' => $data['context'] ?? [], + 'workspace_id' => $workspaceId, + ]); + + if (! empty($data['phases'])) { + foreach ($data['phases'] as $order => $phaseData) { + $tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [ + 'name' => $task, + 'status' => 'pending', + ])->all(); + + AgentPhase::create([ + 'agent_plan_id' => $plan->id, + 'name' => $phaseData['name'] ?? 'Phase '.($order + 1), + 'description' => $phaseData['description'] ?? null, + 'order' => $order + 1, + 'status' => AgentPhase::STATUS_PENDING, + 'tasks' => $tasks, + ]); + } + } + + return $plan->load('agentPhases'); + } +} diff --git a/Actions/Plan/GetPlan.php b/Actions/Plan/GetPlan.php new file mode 100644 index 0000000..a050446 --- /dev/null +++ b/Actions/Plan/GetPlan.php @@ -0,0 +1,50 @@ +forWorkspace($workspaceId) + ->where('slug', $slug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$slug}"); + } + + return $plan; + } +} diff --git a/Actions/Plan/ListPlans.php b/Actions/Plan/ListPlans.php new file mode 100644 index 0000000..aa00efa --- /dev/null +++ b/Actions/Plan/ListPlans.php @@ -0,0 +1,59 @@ + + */ + public function handle(int $workspaceId, ?string $status = null, bool $includeArchived = false): Collection + { + if ($status !== null) { + $valid = ['draft', 'active', 'paused', 'completed', 'archived']; + if (! in_array($status, $valid, true)) { + throw new \InvalidArgumentException( + sprintf('status must be one of: %s', implode(', ', $valid)) + ); + } + } + + $query = AgentPlan::with('agentPhases') + ->forWorkspace($workspaceId) + ->orderBy('updated_at', 'desc'); + + if (! $includeArchived && $status !== 'archived') { + $query->notArchived(); + } + + if ($status !== null) { + $query->where('status', $status); + } + + return $query->get(); + } +} diff --git a/Actions/Plan/UpdatePlanStatus.php b/Actions/Plan/UpdatePlanStatus.php new file mode 100644 index 0000000..be505a4 --- /dev/null +++ b/Actions/Plan/UpdatePlanStatus.php @@ -0,0 +1,54 @@ +where('slug', $slug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$slug}"); + } + + $plan->update(['status' => $status]); + + return $plan->fresh(); + } +} diff --git a/Actions/Session/ContinueSession.php b/Actions/Session/ContinueSession.php new file mode 100644 index 0000000..eb366e4 --- /dev/null +++ b/Actions/Session/ContinueSession.php @@ -0,0 +1,56 @@ +sessionService->continueFrom($previousSessionId, $agentType); + + if (! $session) { + throw new \InvalidArgumentException("Previous session not found: {$previousSessionId}"); + } + + return $session; + } +} diff --git a/Actions/Session/EndSession.php b/Actions/Session/EndSession.php new file mode 100644 index 0000000..d27c8ee --- /dev/null +++ b/Actions/Session/EndSession.php @@ -0,0 +1,56 @@ +sessionService->end($sessionId, $status, $summary); + + if (! $session) { + throw new \InvalidArgumentException("Session not found: {$sessionId}"); + } + + return $session; + } +} diff --git a/Actions/Session/GetSession.php b/Actions/Session/GetSession.php new file mode 100644 index 0000000..c74f1b0 --- /dev/null +++ b/Actions/Session/GetSession.php @@ -0,0 +1,49 @@ +where('session_id', $sessionId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $session) { + throw new \InvalidArgumentException("Session not found: {$sessionId}"); + } + + return $session; + } +} diff --git a/Actions/Session/ListSessions.php b/Actions/Session/ListSessions.php new file mode 100644 index 0000000..3987919 --- /dev/null +++ b/Actions/Session/ListSessions.php @@ -0,0 +1,68 @@ + + */ + public function handle(int $workspaceId, ?string $status = null, ?string $planSlug = null, ?int $limit = null): Collection + { + if ($status !== null) { + $valid = ['active', 'paused', 'completed', 'failed']; + if (! in_array($status, $valid, true)) { + throw new \InvalidArgumentException( + sprintf('status must be one of: %s', implode(', ', $valid)) + ); + } + } + + // Active sessions use the optimised service method + if ($status === 'active' || $status === null) { + return $this->sessionService->getActiveSessions($workspaceId); + } + + $query = AgentSession::query() + ->where('workspace_id', $workspaceId) + ->where('status', $status) + ->orderBy('last_active_at', 'desc'); + + if ($planSlug !== null) { + $query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug)); + } + + if ($limit !== null && $limit > 0) { + $query->limit(min($limit, 1000)); + } + + return $query->get(); + } +} diff --git a/Actions/Session/StartSession.php b/Actions/Session/StartSession.php new file mode 100644 index 0000000..cee098b --- /dev/null +++ b/Actions/Session/StartSession.php @@ -0,0 +1,56 @@ + 'testing']); + */ +class StartSession +{ + use Action; + + public function __construct( + private AgentSessionService $sessionService, + ) {} + + /** + * @throws \InvalidArgumentException + */ + public function handle(string $agentType, ?string $planSlug, int $workspaceId, array $context = []): AgentSession + { + if ($agentType === '') { + throw new \InvalidArgumentException('agent_type is required'); + } + + $plan = null; + if ($planSlug !== null && $planSlug !== '') { + $plan = AgentPlan::where('slug', $planSlug)->first(); + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$planSlug}"); + } + } + + return $this->sessionService->start($agentType, $plan, $workspaceId, $context); + } +} diff --git a/Actions/Task/ToggleTask.php b/Actions/Task/ToggleTask.php new file mode 100644 index 0000000..0955c60 --- /dev/null +++ b/Actions/Task/ToggleTask.php @@ -0,0 +1,90 @@ + completed). + * + * Quick convenience method for marking tasks done or undone. + * + * Usage: + * $result = ToggleTask::run('deploy-v2', '1', 0, 1); + */ +class ToggleTask +{ + use Action; + + /** + * @return array{task: array, plan_progress: array} + * + * @throws \InvalidArgumentException + */ + public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId): array + { + $plan = AgentPlan::forWorkspace($workspaceId) + ->where('slug', $planSlug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$planSlug}"); + } + + $resolved = $this->resolvePhase($plan, $phase); + + if (! $resolved) { + throw new \InvalidArgumentException("Phase not found: {$phase}"); + } + + $tasks = $resolved->tasks ?? []; + + if (! isset($tasks[$taskIndex])) { + throw new \InvalidArgumentException("Task not found at index: {$taskIndex}"); + } + + $currentStatus = is_string($tasks[$taskIndex]) + ? 'pending' + : ($tasks[$taskIndex]['status'] ?? 'pending'); + + $newStatus = $currentStatus === 'completed' ? 'pending' : 'completed'; + + if (is_string($tasks[$taskIndex])) { + $tasks[$taskIndex] = [ + 'name' => $tasks[$taskIndex], + 'status' => $newStatus, + ]; + } else { + $tasks[$taskIndex]['status'] = $newStatus; + } + + $resolved->update(['tasks' => $tasks]); + + return [ + 'task' => $tasks[$taskIndex], + 'plan_progress' => $plan->fresh()->getProgress(), + ]; + } + + private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where('name', $identifier) + ->first(); + } +} diff --git a/Actions/Task/UpdateTask.php b/Actions/Task/UpdateTask.php new file mode 100644 index 0000000..4bcde4e --- /dev/null +++ b/Actions/Task/UpdateTask.php @@ -0,0 +1,101 @@ +where('slug', $planSlug) + ->first(); + + if (! $plan) { + throw new \InvalidArgumentException("Plan not found: {$planSlug}"); + } + + $resolved = $this->resolvePhase($plan, $phase); + + if (! $resolved) { + throw new \InvalidArgumentException("Phase not found: {$phase}"); + } + + $tasks = $resolved->tasks ?? []; + + if (! isset($tasks[$taskIndex])) { + throw new \InvalidArgumentException("Task not found at index: {$taskIndex}"); + } + + // Normalise legacy string-format tasks + if (is_string($tasks[$taskIndex])) { + $tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending']; + } + + if ($status !== null) { + $tasks[$taskIndex]['status'] = $status; + } + + if ($notes !== null) { + $tasks[$taskIndex]['notes'] = $notes; + } + + $resolved->update(['tasks' => $tasks]); + + return [ + 'task' => $tasks[$taskIndex], + 'plan_progress' => $plan->fresh()->getProgress(), + ]; + } + + private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase + { + if (is_numeric($identifier)) { + return $plan->agentPhases()->where('order', (int) $identifier)->first(); + } + + return $plan->agentPhases() + ->where(function ($query) use ($identifier) { + $query->where('name', $identifier) + ->orWhere('order', $identifier); + }) + ->first(); + } +} diff --git a/Controllers/Api/PhaseController.php b/Controllers/Api/PhaseController.php new file mode 100644 index 0000000..412f881 --- /dev/null +++ b/Controllers/Api/PhaseController.php @@ -0,0 +1,115 @@ +attributes->get('workspace'); + + try { + $resolved = GetPhase::run($slug, $phase, $workspace->id); + + return response()->json([ + 'data' => [ + 'order' => $resolved->order, + 'name' => $resolved->name, + 'description' => $resolved->description, + 'status' => $resolved->status, + 'tasks' => $resolved->tasks, + 'checkpoints' => $resolved->getCheckpoints(), + 'dependencies' => $resolved->dependencies, + 'task_progress' => $resolved->getTaskProgress(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * PATCH /api/plans/{slug}/phases/{phase} + */ + public function update(Request $request, string $slug, string $phase): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|string|in:pending,in_progress,completed,blocked,skipped', + 'notes' => 'nullable|string|max:5000', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $resolved = UpdatePhaseStatus::run( + $slug, + $phase, + $validated['status'], + $workspace->id, + $validated['notes'] ?? null, + ); + + return response()->json([ + 'data' => [ + 'order' => $resolved->order, + 'name' => $resolved->name, + 'status' => $resolved->status, + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * POST /api/plans/{slug}/phases/{phase}/checkpoint + */ + public function checkpoint(Request $request, string $slug, string $phase): JsonResponse + { + $validated = $request->validate([ + 'note' => 'required|string|max:5000', + 'context' => 'nullable|array', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $resolved = AddCheckpoint::run( + $slug, + $phase, + $validated['note'], + $workspace->id, + $validated['context'] ?? [], + ); + + return response()->json([ + 'data' => [ + 'checkpoints' => $resolved->getCheckpoints(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } +} diff --git a/Controllers/Api/PlanController.php b/Controllers/Api/PlanController.php new file mode 100644 index 0000000..e71e19c --- /dev/null +++ b/Controllers/Api/PlanController.php @@ -0,0 +1,170 @@ +validate([ + 'status' => 'nullable|string|in:draft,active,paused,completed,archived', + 'include_archived' => 'nullable|boolean', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $plans = ListPlans::run( + $workspace->id, + $validated['status'] ?? null, + (bool) ($validated['include_archived'] ?? false), + ); + + return response()->json([ + 'data' => $plans->map(fn ($plan) => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'progress' => $plan->getProgress(), + 'updated_at' => $plan->updated_at->toIso8601String(), + ])->values()->all(), + 'total' => $plans->count(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * GET /api/plans/{slug} + */ + public function show(Request $request, string $slug): JsonResponse + { + $workspace = $request->attributes->get('workspace'); + + try { + $plan = GetPlan::run($slug, $workspace->id); + + return response()->json([ + 'data' => $plan->toMcpContext(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * POST /api/plans + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'slug' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:10000', + 'context' => 'nullable|array', + 'phases' => 'nullable|array', + 'phases.*.name' => 'required_with:phases|string', + 'phases.*.description' => 'nullable|string', + 'phases.*.tasks' => 'nullable|array', + 'phases.*.tasks.*' => 'string', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $plan = CreatePlan::run($validated, $workspace->id); + + return response()->json([ + 'data' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $plan->agentPhases->count(), + ], + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * PATCH /api/plans/{slug} + */ + public function update(Request $request, string $slug): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|string|in:draft,active,paused,completed', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $plan = UpdatePlanStatus::run($slug, $validated['status'], $workspace->id); + + return response()->json([ + 'data' => [ + 'slug' => $plan->slug, + 'status' => $plan->status, + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * DELETE /api/plans/{slug} + */ + public function destroy(Request $request, string $slug): JsonResponse + { + $request->validate([ + 'reason' => 'nullable|string|max:500', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $plan = ArchivePlan::run($slug, $workspace->id, $request->input('reason')); + + return response()->json([ + 'data' => [ + 'slug' => $plan->slug, + 'status' => 'archived', + 'archived_at' => $plan->archived_at?->toIso8601String(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } +} diff --git a/Controllers/Api/SessionController.php b/Controllers/Api/SessionController.php new file mode 100644 index 0000000..956beff --- /dev/null +++ b/Controllers/Api/SessionController.php @@ -0,0 +1,173 @@ +validate([ + 'status' => 'nullable|string|in:active,paused,completed,failed', + 'plan_slug' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:1000', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $sessions = ListSessions::run( + $workspace->id, + $validated['status'] ?? null, + $validated['plan_slug'] ?? null, + $validated['limit'] ?? null, + ); + + return response()->json([ + 'data' => $sessions->map(fn ($session) => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'status' => $session->status, + 'plan' => $session->plan?->slug, + 'duration' => $session->getDurationFormatted(), + 'started_at' => $session->started_at->toIso8601String(), + 'last_active_at' => $session->last_active_at->toIso8601String(), + ])->values()->all(), + 'total' => $sessions->count(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * GET /api/sessions/{id} + */ + public function show(Request $request, string $id): JsonResponse + { + $workspace = $request->attributes->get('workspace'); + + try { + $session = GetSession::run($id, $workspace->id); + + return response()->json([ + 'data' => $session->toMcpContext(), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * POST /api/sessions + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_type' => 'required|string|max:50', + 'plan_slug' => 'nullable|string|max:255', + 'context' => 'nullable|array', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $session = StartSession::run( + $validated['agent_type'], + $validated['plan_slug'] ?? null, + $workspace->id, + $validated['context'] ?? [], + ); + + return response()->json([ + 'data' => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan' => $session->plan?->slug, + 'status' => $session->status, + ], + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'validation_error', + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * POST /api/sessions/{id}/end + */ + public function end(Request $request, string $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|string|in:completed,handed_off,paused,failed', + 'summary' => 'nullable|string|max:10000', + ]); + + try { + $session = EndSession::run($id, $validated['status'], $validated['summary'] ?? null); + + return response()->json([ + 'data' => [ + 'session_id' => $session->session_id, + 'status' => $session->status, + 'duration' => $session->getDurationFormatted(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * POST /api/sessions/{id}/continue + */ + public function continue(Request $request, string $id): JsonResponse + { + $validated = $request->validate([ + 'agent_type' => 'required|string|max:50', + ]); + + try { + $session = ContinueSession::run($id, $validated['agent_type']); + + return response()->json([ + 'data' => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan' => $session->plan?->slug, + 'status' => $session->status, + 'continued_from' => $session->context_summary['continued_from'] ?? null, + ], + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } +} diff --git a/Controllers/Api/TaskController.php b/Controllers/Api/TaskController.php new file mode 100644 index 0000000..38f608a --- /dev/null +++ b/Controllers/Api/TaskController.php @@ -0,0 +1,68 @@ +validate([ + 'status' => 'nullable|string|in:pending,in_progress,completed,blocked,skipped', + 'notes' => 'nullable|string|max:5000', + ]); + + $workspace = $request->attributes->get('workspace'); + + try { + $result = UpdateTask::run( + $slug, + $phase, + $index, + $workspace->id, + $validated['status'] ?? null, + $validated['notes'] ?? null, + ); + + return response()->json([ + 'data' => $result, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } + + /** + * POST /api/plans/{slug}/phases/{phase}/tasks/{index}/toggle + */ + public function toggle(Request $request, string $slug, string $phase, int $index): JsonResponse + { + $workspace = $request->attributes->get('workspace'); + + try { + $result = ToggleTask::run($slug, $phase, $index, $workspace->id); + + return response()->json([ + 'data' => $result, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'error' => 'not_found', + 'message' => $e->getMessage(), + ], 404); + } + } +} diff --git a/Mcp/Tools/Agent/Phase/PhaseAddCheckpoint.php b/Mcp/Tools/Agent/Phase/PhaseAddCheckpoint.php index e0d33b1..a2d8e84 100644 --- a/Mcp/Tools/Agent/Phase/PhaseAddCheckpoint.php +++ b/Mcp/Tools/Agent/Phase/PhaseAddCheckpoint.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase; +use Core\Mod\Agentic\Actions\Phase\AddCheckpoint; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPhase; -use Core\Mod\Agentic\Models\AgentPlan; /** * Add a checkpoint note to a phase. @@ -55,44 +54,25 @@ class PhaseAddCheckpoint extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $planSlug = $this->require($args, 'plan_slug'); - $phaseIdentifier = $this->require($args, 'phase'); - $note = $this->require($args, 'note'); + $phase = AddCheckpoint::run( + $args['plan_slug'] ?? '', + $args['phase'] ?? '', + $args['note'] ?? '', + (int) $workspaceId, + $args['context'] ?? [], + ); + + return $this->success([ + 'checkpoints' => $phase->getCheckpoints(), + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $planSlug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$planSlug}"); - } - - $phase = $this->findPhase($plan, $phaseIdentifier); - - if (! $phase) { - return $this->error("Phase not found: {$phaseIdentifier}"); - } - - $phase->addCheckpoint($note, $args['context'] ?? []); - - return $this->success([ - 'checkpoints' => $phase->fresh()->checkpoints, - ]); - } - - /** - * Find a phase by order number or name. - */ - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where('name', $identifier) - ->first(); } } diff --git a/Mcp/Tools/Agent/Phase/PhaseGet.php b/Mcp/Tools/Agent/Phase/PhaseGet.php index 55f2fd7..1afc535 100644 --- a/Mcp/Tools/Agent/Phase/PhaseGet.php +++ b/Mcp/Tools/Agent/Phase/PhaseGet.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase; +use Core\Mod\Agentic\Actions\Phase\GetPhase; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPhase; -use Core\Mod\Agentic\Models\AgentPlan; /** * Get details of a specific phase within a plan. @@ -47,52 +46,31 @@ class PhaseGet extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $planSlug = $this->require($args, 'plan_slug'); - $phaseIdentifier = $this->require($args, 'phase'); + $phase = GetPhase::run( + $args['plan_slug'] ?? '', + $args['phase'] ?? '', + (int) $workspaceId, + ); + + return $this->success([ + 'phase' => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'description' => $phase->description, + 'status' => $phase->status, + 'tasks' => $phase->tasks, + 'checkpoints' => $phase->getCheckpoints(), + 'dependencies' => $phase->dependencies, + ], + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $planSlug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$planSlug}"); - } - - $phase = $this->findPhase($plan, $phaseIdentifier); - - if (! $phase) { - return $this->error("Phase not found: {$phaseIdentifier}"); - } - - return [ - 'phase' => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'description' => $phase->description, - 'status' => $phase->status, - 'tasks' => $phase->tasks, - 'checkpoints' => $phase->checkpoints, - 'dependencies' => $phase->dependencies, - ], - ]; - } - - /** - * Find a phase by order number or name. - */ - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where(function ($query) use ($identifier) { - $query->where('name', $identifier) - ->orWhere('order', $identifier); - }) - ->first(); } } diff --git a/Mcp/Tools/Agent/Phase/PhaseUpdateStatus.php b/Mcp/Tools/Agent/Phase/PhaseUpdateStatus.php index df4be7c..ef4bff1 100644 --- a/Mcp/Tools/Agent/Phase/PhaseUpdateStatus.php +++ b/Mcp/Tools/Agent/Phase/PhaseUpdateStatus.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPhase; -use Core\Mod\Agentic\Models\AgentPlan; /** * Update the status of a phase. @@ -69,55 +68,29 @@ class PhaseUpdateStatus extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $planSlug = $this->require($args, 'plan_slug'); - $phaseIdentifier = $this->require($args, 'phase'); - $status = $this->require($args, 'status'); + $phase = UpdatePhaseStatus::run( + $args['plan_slug'] ?? '', + $args['phase'] ?? '', + $args['status'] ?? '', + (int) $workspaceId, + $args['notes'] ?? null, + ); + + return $this->success([ + 'phase' => [ + 'order' => $phase->order, + 'name' => $phase->name, + 'status' => $phase->status, + ], + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $planSlug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$planSlug}"); - } - - $phase = $this->findPhase($plan, $phaseIdentifier); - - if (! $phase) { - return $this->error("Phase not found: {$phaseIdentifier}"); - } - - if (! empty($args['notes'])) { - $phase->addCheckpoint($args['notes'], ['status_change' => $status]); - } - - $phase->update(['status' => $status]); - - return $this->success([ - 'phase' => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'status' => $phase->fresh()->status, - ], - ]); - } - - /** - * Find a phase by order number or name. - */ - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where(function ($query) use ($identifier) { - $query->where('name', $identifier) - ->orWhere('order', $identifier); - }) - ->first(); } } diff --git a/Mcp/Tools/Agent/Plan/PlanArchive.php b/Mcp/Tools/Agent/Plan/PlanArchive.php index 2f9c270..3eedd6f 100644 --- a/Mcp/Tools/Agent/Plan/PlanArchive.php +++ b/Mcp/Tools/Agent/Plan/PlanArchive.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; +use Core\Mod\Agentic\Actions\Plan\ArchivePlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPlan; /** * Archive a completed or abandoned plan. @@ -46,26 +46,27 @@ class PlanArchive extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $slug = $this->require($args, 'slug'); + $plan = ArchivePlan::run( + $args['slug'] ?? '', + (int) $workspaceId, + $args['reason'] ?? null, + ); + + return $this->success([ + 'plan' => [ + 'slug' => $plan->slug, + 'status' => 'archived', + 'archived_at' => $plan->archived_at?->toIso8601String(), + ], + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $slug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$slug}"); - } - - $plan->archive($args['reason'] ?? null); - - return $this->success([ - 'plan' => [ - 'slug' => $plan->slug, - 'status' => 'archived', - 'archived_at' => $plan->archived_at?->toIso8601String(), - ], - ]); } } diff --git a/Mcp/Tools/Agent/Plan/PlanCreate.php b/Mcp/Tools/Agent/Plan/PlanCreate.php index 3e74189..dfd877a 100644 --- a/Mcp/Tools/Agent/Plan/PlanCreate.php +++ b/Mcp/Tools/Agent/Plan/PlanCreate.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Plan\CreatePlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPhase; -use Core\Mod\Agentic\Models\AgentPlan; -use Illuminate\Support\Str; /** * Create a new work plan with phases and tasks. @@ -84,61 +82,24 @@ class PlanCreate extends AgentTool public function handle(array $args, array $context = []): array { - try { - $title = $this->requireString($args, 'title', 255); - $slug = $this->optionalString($args, 'slug', null, 255) ?? Str::slug($title).'-'.Str::random(6); - $description = $this->optionalString($args, 'description', null, 10000); - } catch (\InvalidArgumentException $e) { - return $this->error($e->getMessage()); - } - - if (AgentPlan::where('slug', $slug)->exists()) { - return $this->error("Plan with slug '{$slug}' already exists"); - } - - // Determine workspace_id - never fall back to hardcoded value in multi-tenant environment $workspaceId = $context['workspace_id'] ?? null; if ($workspaceId === null) { return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); } - $plan = AgentPlan::create([ - 'slug' => $slug, - 'title' => $title, - 'description' => $description, - 'status' => 'draft', - 'context' => $args['context'] ?? [], - 'workspace_id' => $workspaceId, - ]); + try { + $plan = CreatePlan::run($args, (int) $workspaceId); - // Create phases if provided - if (! empty($args['phases'])) { - foreach ($args['phases'] as $order => $phaseData) { - $tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [ - 'name' => $task, - 'status' => 'pending', - ])->all(); - - AgentPhase::create([ - 'agent_plan_id' => $plan->id, - 'name' => $phaseData['name'], - 'description' => $phaseData['description'] ?? null, - 'order' => $order + 1, - 'status' => 'pending', - 'tasks' => $tasks, - ]); - } + return $this->success([ + 'plan' => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'phases' => $plan->agentPhases->count(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); } - - $plan->load('agentPhases'); - - return $this->success([ - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'phases' => $plan->agentPhases->count(), - ], - ]); } } diff --git a/Mcp/Tools/Agent/Plan/PlanGet.php b/Mcp/Tools/Agent/Plan/PlanGet.php index 2929bc7..ce1f77c 100644 --- a/Mcp/Tools/Agent/Plan/PlanGet.php +++ b/Mcp/Tools/Agent/Plan/PlanGet.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Plan\GetPlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPlan; /** * Get detailed information about a specific plan. @@ -62,56 +62,23 @@ class PlanGet extends AgentTool public function handle(array $args, array $context = []): array { - try { - $slug = $this->require($args, 'slug'); - } catch (\InvalidArgumentException $e) { - return $this->error($e->getMessage()); - } - - // Validate workspace context for tenant isolation $workspaceId = $context['workspace_id'] ?? null; if ($workspaceId === null) { return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); } - $format = $this->optional($args, 'format', 'json'); + try { + $plan = GetPlan::run($args['slug'] ?? '', (int) $workspaceId); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } - // Use circuit breaker for Agentic module database calls - return $this->withCircuitBreaker('agentic', function () use ($slug, $format, $workspaceId) { - // Query plan with workspace scope to prevent cross-tenant access - $plan = AgentPlan::with('agentPhases') - ->forWorkspace($workspaceId) - ->where('slug', $slug) - ->first(); + $format = $args['format'] ?? 'json'; - if (! $plan) { - return $this->error("Plan not found: {$slug}"); - } + if ($format === 'markdown') { + return $this->success(['markdown' => $plan->toMarkdown()]); + } - if ($format === 'markdown') { - return $this->success(['markdown' => $plan->toMarkdown()]); - } - - return $this->success([ - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'description' => $plan->description, - 'status' => $plan->status, - 'context' => $plan->context, - 'progress' => $plan->getProgress(), - 'phases' => $plan->agentPhases->map(fn ($phase) => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'description' => $phase->description, - 'status' => $phase->status, - 'tasks' => $phase->tasks, - 'checkpoints' => $phase->checkpoints, - ])->all(), - 'created_at' => $plan->created_at->toIso8601String(), - 'updated_at' => $plan->updated_at->toIso8601String(), - ], - ]); - }, fn () => $this->error('Agentic service temporarily unavailable', 'service_unavailable')); + return $this->success(['plan' => $plan->toMcpContext()]); } } diff --git a/Mcp/Tools/Agent/Plan/PlanList.php b/Mcp/Tools/Agent/Plan/PlanList.php index 68e0250..c003669 100644 --- a/Mcp/Tools/Agent/Plan/PlanList.php +++ b/Mcp/Tools/Agent/Plan/PlanList.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Plan\ListPlans; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPlan; /** * List all work plans with their current status and progress. @@ -61,43 +61,30 @@ class PlanList extends AgentTool public function handle(array $args, array $context = []): array { - try { - $status = $this->optionalEnum($args, 'status', ['draft', 'active', 'paused', 'completed', 'archived']); - $includeArchived = (bool) ($args['include_archived'] ?? false); - } catch (\InvalidArgumentException $e) { - return $this->error($e->getMessage()); - } - - // Validate workspace context for tenant isolation $workspaceId = $context['workspace_id'] ?? null; if ($workspaceId === null) { return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); } - // Query plans with workspace scope to prevent cross-tenant access - $query = AgentPlan::with('agentPhases') - ->forWorkspace($workspaceId) - ->orderBy('updated_at', 'desc'); + try { + $plans = ListPlans::run( + (int) $workspaceId, + $args['status'] ?? null, + (bool) ($args['include_archived'] ?? false), + ); - if (! $includeArchived && $status !== 'archived') { - $query->notArchived(); + return $this->success([ + 'plans' => $plans->map(fn ($plan) => [ + 'slug' => $plan->slug, + 'title' => $plan->title, + 'status' => $plan->status, + 'progress' => $plan->getProgress(), + 'updated_at' => $plan->updated_at->toIso8601String(), + ])->all(), + 'total' => $plans->count(), + ]); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); } - - if ($status !== null) { - $query->where('status', $status); - } - - $plans = $query->get(); - - return $this->success([ - 'plans' => $plans->map(fn ($plan) => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'progress' => $plan->getProgress(), - 'updated_at' => $plan->updated_at->toIso8601String(), - ])->all(), - 'total' => $plans->count(), - ]); } } diff --git a/Mcp/Tools/Agent/Plan/PlanUpdateStatus.php b/Mcp/Tools/Agent/Plan/PlanUpdateStatus.php index 9581fe0..6a4c917 100644 --- a/Mcp/Tools/Agent/Plan/PlanUpdateStatus.php +++ b/Mcp/Tools/Agent/Plan/PlanUpdateStatus.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; +use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPlan; /** * Update the status of a plan. @@ -47,26 +47,26 @@ class PlanUpdateStatus extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $slug = $this->require($args, 'slug'); - $status = $this->require($args, 'status'); + $plan = UpdatePlanStatus::run( + $args['slug'] ?? '', + $args['status'] ?? '', + (int) $workspaceId, + ); + + return $this->success([ + 'plan' => [ + 'slug' => $plan->slug, + 'status' => $plan->status, + ], + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $slug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$slug}"); - } - - $plan->update(['status' => $status]); - - return $this->success([ - 'plan' => [ - 'slug' => $plan->slug, - 'status' => $plan->fresh()->status, - ], - ]); } } diff --git a/Mcp/Tools/Agent/Session/SessionContinue.php b/Mcp/Tools/Agent/Session/SessionContinue.php index 4faea62..712088d 100644 --- a/Mcp/Tools/Agent/Session/SessionContinue.php +++ b/Mcp/Tools/Agent/Session/SessionContinue.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; +use Core\Mod\Agentic\Actions\Session\ContinueSession; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Services\AgentSessionService; /** * Continue from a previous session (multi-agent handoff). @@ -47,32 +47,27 @@ class SessionContinue extends AgentTool public function handle(array $args, array $context = []): array { try { - $previousSessionId = $this->require($args, 'previous_session_id'); - $agentType = $this->require($args, 'agent_type'); + $session = ContinueSession::run( + $args['previous_session_id'] ?? '', + $args['agent_type'] ?? '', + ); + + $inheritedContext = $session->context_summary ?? []; + + return $this->success([ + 'session' => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'status' => $session->status, + 'plan' => $session->plan?->slug, + ], + 'continued_from' => $inheritedContext['continued_from'] ?? null, + 'previous_agent' => $inheritedContext['previous_agent'] ?? null, + 'handoff_notes' => $inheritedContext['handoff_notes'] ?? null, + 'inherited_context' => $inheritedContext['inherited_context'] ?? null, + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $sessionService = app(AgentSessionService::class); - $session = $sessionService->continueFrom($previousSessionId, $agentType); - - if (! $session) { - return $this->error("Previous session not found: {$previousSessionId}"); - } - - $inheritedContext = $session->context_summary ?? []; - - return $this->success([ - 'session' => [ - 'session_id' => $session->session_id, - 'agent_type' => $session->agent_type, - 'status' => $session->status, - 'plan' => $session->plan?->slug, - ], - 'continued_from' => $inheritedContext['continued_from'] ?? null, - 'previous_agent' => $inheritedContext['previous_agent'] ?? null, - 'handoff_notes' => $inheritedContext['handoff_notes'] ?? null, - 'inherited_context' => $inheritedContext['inherited_context'] ?? null, - ]); } } diff --git a/Mcp/Tools/Agent/Session/SessionEnd.php b/Mcp/Tools/Agent/Session/SessionEnd.php index e1d407b..34f57e5 100644 --- a/Mcp/Tools/Agent/Session/SessionEnd.php +++ b/Mcp/Tools/Agent/Session/SessionEnd.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; +use Core\Mod\Agentic\Actions\Session\EndSession; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentSession; /** * End the current session. @@ -47,32 +47,27 @@ class SessionEnd extends AgentTool public function handle(array $args, array $context = []): array { - try { - $status = $this->require($args, 'status'); - } catch (\InvalidArgumentException $e) { - return $this->error($e->getMessage()); - } - $sessionId = $context['session_id'] ?? null; - if (! $sessionId) { return $this->error('No active session'); } - $session = AgentSession::where('session_id', $sessionId)->first(); + try { + $session = EndSession::run( + $sessionId, + $args['status'] ?? '', + $args['summary'] ?? null, + ); - if (! $session) { - return $this->error('Session not found'); + return $this->success([ + 'session' => [ + 'session_id' => $session->session_id, + 'status' => $session->status, + 'duration' => $session->getDurationFormatted(), + ], + ]); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); } - - $session->end($status, $this->optional($args, 'summary')); - - return $this->success([ - 'session' => [ - 'session_id' => $session->session_id, - 'status' => $session->status, - 'duration' => $session->getDurationFormatted(), - ], - ]); } } diff --git a/Mcp/Tools/Agent/Session/SessionList.php b/Mcp/Tools/Agent/Session/SessionList.php index 7d0cf5a..551147c 100644 --- a/Mcp/Tools/Agent/Session/SessionList.php +++ b/Mcp/Tools/Agent/Session/SessionList.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; +use Core\Mod\Agentic\Actions\Session\ListSessions; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Services\AgentSessionService; /** * List sessions, optionally filtered by status. @@ -50,54 +50,34 @@ class SessionList extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $status = $this->optionalEnum($args, 'status', ['active', 'paused', 'completed', 'failed']); - $planSlug = $this->optionalString($args, 'plan_slug', null, 255); - $limit = $this->optionalInt($args, 'limit', null, min: 1, max: 1000); + $sessions = ListSessions::run( + (int) $workspaceId, + $args['status'] ?? null, + $args['plan_slug'] ?? null, + isset($args['limit']) ? (int) $args['limit'] : null, + ); + + return $this->success([ + 'sessions' => $sessions->map(fn ($session) => [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'status' => $session->status, + 'plan' => $session->plan?->slug, + 'duration' => $session->getDurationFormatted(), + 'started_at' => $session->started_at->toIso8601String(), + 'last_active_at' => $session->last_active_at->toIso8601String(), + 'has_handoff' => ! empty($session->handoff_notes), + ])->all(), + 'total' => $sessions->count(), + ]); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $sessionService = app(AgentSessionService::class); - - // Get active sessions (default) - if ($status === 'active' || $status === null) { - $sessions = $sessionService->getActiveSessions($context['workspace_id'] ?? null); - } else { - // Query with filters - $query = \Core\Mod\Agentic\Models\AgentSession::query() - ->orderBy('last_active_at', 'desc'); - - // Apply workspace filter if provided - if (! empty($context['workspace_id'])) { - $query->where('workspace_id', $context['workspace_id']); - } - - $query->where('status', $status); - - if ($planSlug !== null) { - $query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug)); - } - - if ($limit !== null) { - $query->limit($limit); - } - - $sessions = $query->get(); - } - - return [ - 'sessions' => $sessions->map(fn ($session) => [ - 'session_id' => $session->session_id, - 'agent_type' => $session->agent_type, - 'status' => $session->status, - 'plan' => $session->plan?->slug, - 'duration' => $session->getDurationFormatted(), - 'started_at' => $session->started_at->toIso8601String(), - 'last_active_at' => $session->last_active_at->toIso8601String(), - 'has_handoff' => ! empty($session->handoff_notes), - ])->all(), - 'total' => $sessions->count(), - ]; } } diff --git a/Mcp/Tools/Agent/Session/SessionStart.php b/Mcp/Tools/Agent/Session/SessionStart.php index 3e3458f..f2605c4 100644 --- a/Mcp/Tools/Agent/Session/SessionStart.php +++ b/Mcp/Tools/Agent/Session/SessionStart.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Session\StartSession; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPlan; -use Core\Mod\Agentic\Models\AgentSession; -use Illuminate\Support\Str; /** * Start a new agent session for a plan. @@ -70,48 +68,29 @@ class SessionStart extends AgentTool public function handle(array $args, array $context = []): array { - try { - $agentType = $this->require($args, 'agent_type'); - } catch (\InvalidArgumentException $e) { - return $this->error($e->getMessage()); + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai'); } - // Use circuit breaker for Agentic module database calls - return $this->withCircuitBreaker('agentic', function () use ($args, $context, $agentType) { - $plan = null; - if (! empty($args['plan_slug'])) { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - } - - $sessionId = 'ses_'.Str::random(12); - - // Determine workspace_id - never fall back to hardcoded value in multi-tenant environment - $workspaceId = $context['workspace_id'] ?? $plan?->workspace_id ?? null; - if ($workspaceId === null) { - return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai'); - } - - $session = AgentSession::create([ - 'session_id' => $sessionId, - 'agent_plan_id' => $plan?->id, - 'workspace_id' => $workspaceId, - 'agent_type' => $agentType, - 'status' => 'active', - 'started_at' => now(), - 'last_active_at' => now(), - 'context_summary' => $args['context'] ?? [], - 'work_log' => [], - 'artifacts' => [], - ]); + try { + $session = StartSession::run( + $args['agent_type'] ?? '', + $args['plan_slug'] ?? null, + (int) $workspaceId, + $args['context'] ?? [], + ); return $this->success([ 'session' => [ 'session_id' => $session->session_id, 'agent_type' => $session->agent_type, - 'plan' => $plan?->slug, + 'plan' => $session->plan?->slug, 'status' => $session->status, ], ]); - }, fn () => $this->error('Agentic service temporarily unavailable. Session cannot be created.', 'service_unavailable')); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } } } diff --git a/Mcp/Tools/Agent/Task/TaskToggle.php b/Mcp/Tools/Agent/Task/TaskToggle.php index eec0a77..266ec76 100644 --- a/Mcp/Tools/Agent/Task/TaskToggle.php +++ b/Mcp/Tools/Agent/Task/TaskToggle.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Task\ToggleTask; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPhase; -use Core\Mod\Agentic\Models\AgentPlan; /** * Toggle a task completion status. @@ -64,66 +63,22 @@ class TaskToggle extends AgentTool public function handle(array $args, array $context = []): array { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } + try { - $planSlug = $this->requireString($args, 'plan_slug', 255); - $phaseIdentifier = $this->requireString($args, 'phase', 255); - $taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000); + $result = ToggleTask::run( + $args['plan_slug'] ?? '', + $args['phase'] ?? '', + (int) ($args['task_index'] ?? 0), + (int) $workspaceId, + ); + + return $this->success($result); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $planSlug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$planSlug}"); - } - - $phase = $this->findPhase($plan, $phaseIdentifier); - - if (! $phase) { - return $this->error("Phase not found: {$phaseIdentifier}"); - } - - $tasks = $phase->tasks ?? []; - - if (! isset($tasks[$taskIndex])) { - return $this->error("Task not found at index: {$taskIndex}"); - } - - $currentStatus = is_string($tasks[$taskIndex]) - ? 'pending' - : ($tasks[$taskIndex]['status'] ?? 'pending'); - - $newStatus = $currentStatus === 'completed' ? 'pending' : 'completed'; - - if (is_string($tasks[$taskIndex])) { - $tasks[$taskIndex] = [ - 'name' => $tasks[$taskIndex], - 'status' => $newStatus, - ]; - } else { - $tasks[$taskIndex]['status'] = $newStatus; - } - - $phase->update(['tasks' => $tasks]); - - return $this->success([ - 'task' => $tasks[$taskIndex], - 'plan_progress' => $plan->fresh()->getProgress(), - ]); - } - - /** - * Find a phase by order number or name. - */ - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where('name', $identifier) - ->first(); } } diff --git a/Mcp/Tools/Agent/Task/TaskUpdate.php b/Mcp/Tools/Agent/Task/TaskUpdate.php index 9f724b2..09d2c96 100644 --- a/Mcp/Tools/Agent/Task/TaskUpdate.php +++ b/Mcp/Tools/Agent/Task/TaskUpdate.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task; use Core\Mcp\Dependencies\ToolDependency; +use Core\Mod\Agentic\Actions\Task\UpdateTask; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; -use Core\Mod\Agentic\Models\AgentPhase; -use Core\Mod\Agentic\Models\AgentPlan; /** * Update task details (status, notes). @@ -73,71 +72,24 @@ class TaskUpdate extends AgentTool public function handle(array $args, array $context = []): array { - try { - $planSlug = $this->requireString($args, 'plan_slug', 255); - $phaseIdentifier = $this->requireString($args, 'phase', 255); - $taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000); + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required'); + } - // Validate optional status enum - $status = $this->optionalEnum($args, 'status', ['pending', 'in_progress', 'completed', 'blocked', 'skipped']); - $notes = $this->optionalString($args, 'notes', null, 5000); + try { + $result = UpdateTask::run( + $args['plan_slug'] ?? '', + $args['phase'] ?? '', + (int) ($args['task_index'] ?? 0), + (int) $workspaceId, + $args['status'] ?? null, + $args['notes'] ?? null, + ); + + return $this->success($result); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } - - $plan = AgentPlan::where('slug', $planSlug)->first(); - - if (! $plan) { - return $this->error("Plan not found: {$planSlug}"); - } - - $phase = $this->findPhase($plan, $phaseIdentifier); - - if (! $phase) { - return $this->error("Phase not found: {$phaseIdentifier}"); - } - - $tasks = $phase->tasks ?? []; - - if (! isset($tasks[$taskIndex])) { - return $this->error("Task not found at index: {$taskIndex}"); - } - - // Normalise task to array format - if (is_string($tasks[$taskIndex])) { - $tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending']; - } - - // Update fields using pre-validated values - if ($status !== null) { - $tasks[$taskIndex]['status'] = $status; - } - - if ($notes !== null) { - $tasks[$taskIndex]['notes'] = $notes; - } - - $phase->update(['tasks' => $tasks]); - - return $this->success([ - 'task' => $tasks[$taskIndex], - ]); - } - - /** - * Find a phase by order number or name. - */ - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where(function ($query) use ($identifier) { - $query->where('name', $identifier) - ->orWhere('order', $identifier); - }) - ->first(); } } diff --git a/Routes/api.php b/Routes/api.php index 2aff966..1cd2ab0 100644 --- a/Routes/api.php +++ b/Routes/api.php @@ -3,6 +3,10 @@ declare(strict_types=1); use Core\Mod\Agentic\Controllers\Api\BrainController; +use Core\Mod\Agentic\Controllers\Api\PhaseController; +use Core\Mod\Agentic\Controllers\Api\PlanController; +use Core\Mod\Agentic\Controllers\Api\SessionController; +use Core\Mod\Agentic\Controllers\Api\TaskController; use Illuminate\Support\Facades\Route; /* @@ -10,18 +14,56 @@ use Illuminate\Support\Facades\Route; | Agentic API Routes |-------------------------------------------------------------------------- | -| Brain (OpenBrain knowledge store) endpoints. +| Brain, Plans, Sessions, Phases, and Tasks endpoints. | Auto-wrapped with 'api' middleware and /api prefix by ApiRoutesRegistering. | */ -Route::middleware(['api.auth', 'api.scope.enforce']) - ->prefix('brain') - ->name('brain.') - ->group(function () { +// Health check (no auth required) +Route::get('health', fn () => 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'); + }); +});