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