diff --git a/php/Actions/Auth/ProvisionAgentKey.php b/php/Actions/Auth/ProvisionAgentKey.php new file mode 100644 index 0000000..75f8c04 --- /dev/null +++ b/php/Actions/Auth/ProvisionAgentKey.php @@ -0,0 +1,47 @@ + $permissions + * + * @throws \InvalidArgumentException + */ + public function handle( + int $workspaceId, + string $oauthUserId, + ?string $name = null, + array $permissions = [], + int $rateLimit = 100, + ?string $expiresAt = null + ): AgentApiKey { + if ($workspaceId <= 0) { + throw new \InvalidArgumentException('workspace_id is required'); + } + + if ($oauthUserId === '') { + throw new \InvalidArgumentException('oauth_user_id is required'); + } + + $service = app(AgentApiKeyService::class); + + return $service->create( + $workspaceId, + $name ?: 'agent-'.$oauthUserId, + $permissions, + $rateLimit, + $expiresAt ? Carbon::parse($expiresAt) : null, + ); + } +} diff --git a/php/Actions/Auth/RevokeAgentKey.php b/php/Actions/Auth/RevokeAgentKey.php new file mode 100644 index 0000000..3d3cbe3 --- /dev/null +++ b/php/Actions/Auth/RevokeAgentKey.php @@ -0,0 +1,32 @@ +where('workspace_id', $workspaceId) + ->find($keyId); + + if (! $key) { + throw new \InvalidArgumentException('API key not found'); + } + + app(AgentApiKeyService::class)->revoke($key); + + return true; + } +} diff --git a/php/Actions/Credits/AwardCredits.php b/php/Actions/Credits/AwardCredits.php new file mode 100644 index 0000000..e16bda0 --- /dev/null +++ b/php/Actions/Credits/AwardCredits.php @@ -0,0 +1,53 @@ +where('workspace_id', $workspaceId)->find($fleetNodeId) + : FleetNode::query()->where('workspace_id', $workspaceId)->where('agent_id', $agentId)->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $previousBalance = (int) CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where('fleet_node_id', $node->id) + ->latest('id') + ->value('balance_after'); + + return CreditEntry::create([ + 'workspace_id' => $workspaceId, + 'fleet_node_id' => $node->id, + 'task_type' => $taskType, + 'amount' => $amount, + 'balance_after' => $previousBalance + $amount, + 'description' => $description, + ]); + } +} diff --git a/php/Actions/Credits/GetBalance.php b/php/Actions/Credits/GetBalance.php new file mode 100644 index 0000000..6dee1d5 --- /dev/null +++ b/php/Actions/Credits/GetBalance.php @@ -0,0 +1,46 @@ + + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId): array + { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $balance = (int) CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where('fleet_node_id', $node->id) + ->latest('id') + ->value('balance_after'); + + return [ + 'agent_id' => $agentId, + 'balance' => $balance, + 'entries' => CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where('fleet_node_id', $node->id) + ->count(), + ]; + } +} diff --git a/php/Actions/Credits/GetCreditHistory.php b/php/Actions/Credits/GetCreditHistory.php new file mode 100644 index 0000000..b98a0f4 --- /dev/null +++ b/php/Actions/Credits/GetCreditHistory.php @@ -0,0 +1,37 @@ +where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + return CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where('fleet_node_id', $node->id) + ->latest() + ->limit($limit) + ->get(); + } +} diff --git a/php/Actions/Fleet/AssignTask.php b/php/Actions/Fleet/AssignTask.php new file mode 100644 index 0000000..6672018 --- /dev/null +++ b/php/Actions/Fleet/AssignTask.php @@ -0,0 +1,58 @@ +where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + if ($task === '' || $repo === '') { + throw new \InvalidArgumentException('repo and task are required'); + } + + $fleetTask = FleetTask::create([ + 'workspace_id' => $workspaceId, + 'fleet_node_id' => $node->id, + 'repo' => $repo, + 'branch' => $branch, + 'task' => $task, + 'template' => $template, + 'agent_model' => $agentModel, + 'status' => FleetTask::STATUS_ASSIGNED, + ]); + + $node->update([ + 'status' => FleetNode::STATUS_BUSY, + 'current_task_id' => $fleetTask->id, + ]); + + return $fleetTask->fresh(); + } +} diff --git a/php/Actions/Fleet/CompleteTask.php b/php/Actions/Fleet/CompleteTask.php new file mode 100644 index 0000000..065858c --- /dev/null +++ b/php/Actions/Fleet/CompleteTask.php @@ -0,0 +1,70 @@ + $result + * @param array $findings + * @param array $changes + * @param array $report + * + * @throws \InvalidArgumentException + */ + public function handle( + int $workspaceId, + string $agentId, + int $taskId, + array $result = [], + array $findings = [], + array $changes = [], + array $report = [] + ): FleetTask { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + $fleetTask = FleetTask::query() + ->where('workspace_id', $workspaceId) + ->find($taskId); + + if (! $node || ! $fleetTask) { + throw new \InvalidArgumentException('Fleet task not found'); + } + + $status = ($result['status'] ?? '') === 'failed' + ? FleetTask::STATUS_FAILED + : FleetTask::STATUS_COMPLETED; + + $fleetTask->update([ + 'status' => $status, + 'result' => $result, + 'findings' => $findings, + 'changes' => $changes, + 'report' => $report, + 'completed_at' => now(), + ]); + + $node->update([ + 'status' => FleetNode::STATUS_ONLINE, + 'current_task_id' => null, + 'last_heartbeat_at' => now(), + ]); + + $creditAmount = max(1, count($findings) + 1); + AwardCredits::run($workspaceId, $agentId, 'fleet-task', $creditAmount, $node->id, 'Fleet task completed'); + + return $fleetTask->fresh(); + } +} diff --git a/php/Actions/Fleet/DeregisterNode.php b/php/Actions/Fleet/DeregisterNode.php new file mode 100644 index 0000000..13b259a --- /dev/null +++ b/php/Actions/Fleet/DeregisterNode.php @@ -0,0 +1,36 @@ +where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $node->update([ + 'status' => FleetNode::STATUS_OFFLINE, + 'current_task_id' => null, + 'last_heartbeat_at' => now(), + ]); + + return true; + } +} diff --git a/php/Actions/Fleet/GetFleetStats.php b/php/Actions/Fleet/GetFleetStats.php new file mode 100644 index 0000000..e8db1e4 --- /dev/null +++ b/php/Actions/Fleet/GetFleetStats.php @@ -0,0 +1,32 @@ + + */ + public function handle(int $workspaceId): array + { + $nodes = FleetNode::query()->where('workspace_id', $workspaceId); + $tasks = FleetTask::query()->where('workspace_id', $workspaceId); + + return [ + 'nodes_online' => (clone $nodes)->online()->count(), + 'tasks_today' => (clone $tasks)->whereDate('created_at', today())->count(), + 'tasks_week' => (clone $tasks)->where('created_at', '>=', now()->subDays(7))->count(), + 'repos_touched' => (clone $tasks)->distinct('repo')->count('repo'), + 'findings_total' => (clone $tasks)->get()->sum(static fn (FleetTask $task) => count($task->findings ?? [])), + 'compute_hours' => 0, + ]; + } +} diff --git a/php/Actions/Fleet/GetNextTask.php b/php/Actions/Fleet/GetNextTask.php new file mode 100644 index 0000000..98296f3 --- /dev/null +++ b/php/Actions/Fleet/GetNextTask.php @@ -0,0 +1,49 @@ + $capabilities + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId, array $capabilities = []): ?FleetTask + { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $task = FleetTask::pendingForNode($node)->first(); + + if (! $task) { + return null; + } + + $task->update([ + 'status' => FleetTask::STATUS_IN_PROGRESS, + 'started_at' => $task->started_at ?? now(), + ]); + + $node->update([ + 'status' => FleetNode::STATUS_BUSY, + 'current_task_id' => $task->id, + ]); + + return $task->fresh(); + } +} diff --git a/php/Actions/Fleet/ListNodes.php b/php/Actions/Fleet/ListNodes.php new file mode 100644 index 0000000..2c2c0e3 --- /dev/null +++ b/php/Actions/Fleet/ListNodes.php @@ -0,0 +1,29 @@ +where('workspace_id', $workspaceId); + + if ($status !== null && $status !== '') { + $query->where('status', $status); + } + + if ($platform !== null && $platform !== '') { + $query->where('platform', $platform); + } + + return $query->orderBy('agent_id')->get(); + } +} diff --git a/php/Actions/Fleet/NodeHeartbeat.php b/php/Actions/Fleet/NodeHeartbeat.php new file mode 100644 index 0000000..043ec02 --- /dev/null +++ b/php/Actions/Fleet/NodeHeartbeat.php @@ -0,0 +1,38 @@ + $computeBudget + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId, string $status, array $computeBudget = []): FleetNode + { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $node->update([ + 'status' => $status !== '' ? $status : $node->status, + 'compute_budget' => $computeBudget !== [] ? $computeBudget : $node->compute_budget, + 'last_heartbeat_at' => now(), + ]); + + return $node->fresh(); + } +} diff --git a/php/Actions/Fleet/RegisterNode.php b/php/Actions/Fleet/RegisterNode.php new file mode 100644 index 0000000..a4f4b61 --- /dev/null +++ b/php/Actions/Fleet/RegisterNode.php @@ -0,0 +1,43 @@ + $models + * @param array $capabilities + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId, string $platform, array $models = [], array $capabilities = []): FleetNode + { + if ($workspaceId <= 0) { + throw new \InvalidArgumentException('workspace_id is required'); + } + + if ($agentId === '') { + throw new \InvalidArgumentException('agent_id is required'); + } + + return FleetNode::updateOrCreate( + ['agent_id' => $agentId], + [ + 'workspace_id' => $workspaceId, + 'platform' => $platform !== '' ? $platform : 'unknown', + 'models' => $models, + 'capabilities' => $capabilities, + 'status' => FleetNode::STATUS_ONLINE, + 'registered_at' => now(), + 'last_heartbeat_at' => now(), + ], + ); + } +} diff --git a/php/Actions/Subscription/DetectCapabilities.php b/php/Actions/Subscription/DetectCapabilities.php new file mode 100644 index 0000000..9cbce8b --- /dev/null +++ b/php/Actions/Subscription/DetectCapabilities.php @@ -0,0 +1,30 @@ + $apiKeys + * @return array + */ + public function handle(array $apiKeys = []): array + { + $resolved = [ + 'claude' => ($apiKeys['claude'] ?? '') !== '' || (string) config('agentic.claude.api_key', '') !== '', + 'gemini' => ($apiKeys['gemini'] ?? '') !== '' || (string) config('agentic.gemini.api_key', '') !== '', + 'openai' => ($apiKeys['openai'] ?? '') !== '' || (string) config('agentic.openai.api_key', '') !== '', + ]; + + return [ + 'providers' => $resolved, + 'available' => array_keys(array_filter($resolved)), + ]; + } +} diff --git a/php/Actions/Subscription/GetNodeBudget.php b/php/Actions/Subscription/GetNodeBudget.php new file mode 100644 index 0000000..5383ce1 --- /dev/null +++ b/php/Actions/Subscription/GetNodeBudget.php @@ -0,0 +1,32 @@ + + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId): array + { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + return $node->compute_budget ?? []; + } +} diff --git a/php/Actions/Subscription/UpdateBudget.php b/php/Actions/Subscription/UpdateBudget.php new file mode 100644 index 0000000..b38ff7f --- /dev/null +++ b/php/Actions/Subscription/UpdateBudget.php @@ -0,0 +1,38 @@ + $limits + * @return array + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId, array $limits): array + { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $node->update([ + 'compute_budget' => array_merge($node->compute_budget ?? [], $limits), + 'last_heartbeat_at' => now(), + ]); + + return $node->fresh()->compute_budget ?? []; + } +} diff --git a/php/Actions/Sync/GetAgentSyncStatus.php b/php/Actions/Sync/GetAgentSyncStatus.php new file mode 100644 index 0000000..7a30977 --- /dev/null +++ b/php/Actions/Sync/GetAgentSyncStatus.php @@ -0,0 +1,50 @@ + + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId): array + { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if (! $node) { + throw new \InvalidArgumentException('Fleet node not found'); + } + + $lastPush = SyncRecord::query() + ->where('fleet_node_id', $node->id) + ->where('direction', 'push') + ->latest('synced_at') + ->first(); + + $lastPull = SyncRecord::query() + ->where('fleet_node_id', $node->id) + ->where('direction', 'pull') + ->latest('synced_at') + ->first(); + + return [ + 'agent_id' => $node->agent_id, + 'status' => $node->status, + 'last_push_at' => $lastPush?->synced_at?->toIso8601String(), + 'last_pull_at' => $lastPull?->synced_at?->toIso8601String(), + ]; + } +} diff --git a/php/Actions/Sync/PullFleetContext.php b/php/Actions/Sync/PullFleetContext.php new file mode 100644 index 0000000..b7d4823 --- /dev/null +++ b/php/Actions/Sync/PullFleetContext.php @@ -0,0 +1,56 @@ +> + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId, ?string $since = null): array + { + if ($agentId === '') { + throw new \InvalidArgumentException('agent_id is required'); + } + + $query = BrainMemory::query() + ->forWorkspace($workspaceId) + ->active() + ->latest(); + + if ($since !== null && $since !== '') { + $query->where('created_at', '>=', Carbon::parse($since)); + } + + $items = $query->limit(25)->get(); + + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->first(); + + if ($node) { + SyncRecord::create([ + 'fleet_node_id' => $node->id, + 'direction' => 'pull', + 'payload_size' => strlen((string) json_encode($items->toArray())), + 'items_count' => $items->count(), + 'synced_at' => now(), + ]); + } + + return $items->map(fn (BrainMemory $memory) => $memory->toMcpContext())->values()->all(); + } +} diff --git a/php/Actions/Sync/PushDispatchHistory.php b/php/Actions/Sync/PushDispatchHistory.php new file mode 100644 index 0000000..2a11665 --- /dev/null +++ b/php/Actions/Sync/PushDispatchHistory.php @@ -0,0 +1,79 @@ +> $dispatches + * @return array{synced: int} + * + * @throws \InvalidArgumentException + */ + public function handle(int $workspaceId, string $agentId, array $dispatches): array + { + if ($agentId === '') { + throw new \InvalidArgumentException('agent_id is required'); + } + + $node = FleetNode::firstOrCreate( + ['agent_id' => $agentId], + [ + 'workspace_id' => $workspaceId, + 'platform' => 'remote', + 'status' => FleetNode::STATUS_ONLINE, + 'registered_at' => now(), + 'last_heartbeat_at' => now(), + ], + ); + + $synced = 0; + + foreach ($dispatches as $dispatch) { + $repo = (string) ($dispatch['repo'] ?? ''); + $status = (string) ($dispatch['status'] ?? 'completed'); + $workspace = (string) ($dispatch['workspace'] ?? ''); + $task = (string) ($dispatch['task'] ?? ''); + + if ($repo === '' && $workspace === '') { + continue; + } + + BrainMemory::create([ + 'workspace_id' => $workspaceId, + 'agent_id' => $agentId, + 'type' => 'observation', + 'content' => trim("Repo: {$repo}\nWorkspace: {$workspace}\nStatus: {$status}\nTask: {$task}"), + 'tags' => array_values(array_filter([ + 'sync', + $repo !== '' ? $repo : null, + $status, + ])), + 'project' => $repo !== '' ? $repo : null, + 'confidence' => 0.7, + 'source' => 'sync.push', + ]); + + $synced++; + } + + SyncRecord::create([ + 'fleet_node_id' => $node->id, + 'direction' => 'push', + 'payload_size' => strlen((string) json_encode($dispatches)), + 'items_count' => count($dispatches), + 'synced_at' => now(), + ]); + + return ['synced' => $synced]; + } +} diff --git a/php/Boot.php b/php/Boot.php index 3de23e8..9713d8c 100644 --- a/php/Boot.php +++ b/php/Boot.php @@ -198,14 +198,52 @@ class Boot extends ServiceProvider { $registry = $this->app->make(Services\AgentToolRegistry::class); - $registry->registerMany([ - new Mcp\Tools\Agent\Brain\BrainRemember(), - new Mcp\Tools\Agent\Brain\BrainRecall(), - new Mcp\Tools\Agent\Brain\BrainForget(), - new Mcp\Tools\Agent\Brain\BrainList(), - new Mcp\Tools\Agent\Messaging\AgentSend(), - new Mcp\Tools\Agent\Messaging\AgentInbox(), - new Mcp\Tools\Agent\Messaging\AgentConversation(), - ]); + $toolClasses = [ + Mcp\Tools\Agent\Brain\BrainRemember::class, + Mcp\Tools\Agent\Brain\BrainRecall::class, + Mcp\Tools\Agent\Brain\BrainForget::class, + Mcp\Tools\Agent\Brain\BrainList::class, + Mcp\Tools\Agent\Messaging\AgentSend::class, + Mcp\Tools\Agent\Messaging\AgentInbox::class, + Mcp\Tools\Agent\Messaging\AgentConversation::class, + Mcp\Tools\Agent\Plan\PlanCreate::class, + Mcp\Tools\Agent\Plan\PlanGet::class, + Mcp\Tools\Agent\Plan\PlanList::class, + Mcp\Tools\Agent\Plan\PlanUpdateStatus::class, + Mcp\Tools\Agent\Plan\PlanArchive::class, + Mcp\Tools\Agent\Phase\PhaseGet::class, + Mcp\Tools\Agent\Phase\PhaseUpdateStatus::class, + Mcp\Tools\Agent\Phase\PhaseAddCheckpoint::class, + Mcp\Tools\Agent\Session\SessionStart::class, + Mcp\Tools\Agent\Session\SessionEnd::class, + Mcp\Tools\Agent\Session\SessionLog::class, + Mcp\Tools\Agent\Session\SessionHandoff::class, + Mcp\Tools\Agent\Session\SessionResume::class, + Mcp\Tools\Agent\Session\SessionReplay::class, + Mcp\Tools\Agent\Session\SessionContinue::class, + Mcp\Tools\Agent\Session\SessionArtifact::class, + Mcp\Tools\Agent\Session\SessionList::class, + Mcp\Tools\Agent\State\StateSet::class, + Mcp\Tools\Agent\State\StateGet::class, + Mcp\Tools\Agent\State\StateList::class, + Mcp\Tools\Agent\Task\TaskUpdate::class, + Mcp\Tools\Agent\Task\TaskToggle::class, + Mcp\Tools\Agent\Template\TemplateList::class, + Mcp\Tools\Agent\Template\TemplatePreview::class, + Mcp\Tools\Agent\Template\TemplateCreatePlan::class, + Mcp\Tools\Agent\Content\ContentGenerate::class, + Mcp\Tools\Agent\Content\ContentBatchGenerate::class, + Mcp\Tools\Agent\Content\ContentBriefCreate::class, + Mcp\Tools\Agent\Content\ContentBriefGet::class, + Mcp\Tools\Agent\Content\ContentBriefList::class, + Mcp\Tools\Agent\Content\ContentStatus::class, + Mcp\Tools\Agent\Content\ContentUsageStats::class, + Mcp\Tools\Agent\Content\ContentFromPlan::class, + ]; + + $registry->registerMany(array_map( + static fn (string $toolClass) => new $toolClass(), + $toolClasses, + )); } } diff --git a/php/Controllers/Api/AuthController.php b/php/Controllers/Api/AuthController.php new file mode 100644 index 0000000..da95fe7 --- /dev/null +++ b/php/Controllers/Api/AuthController.php @@ -0,0 +1,58 @@ +validate([ + 'workspace_id' => 'required|integer', + 'oauth_user_id' => 'required|string|max:255', + 'name' => 'nullable|string|max:255', + 'permissions' => 'nullable|array', + 'permissions.*' => 'string', + 'rate_limit' => 'nullable|integer|min:1', + 'expires_at' => 'nullable|date', + ]); + + $key = ProvisionAgentKey::run( + (int) $validated['workspace_id'], + $validated['oauth_user_id'], + $validated['name'] ?? null, + $validated['permissions'] ?? [], + (int) ($validated['rate_limit'] ?? 100), + $validated['expires_at'] ?? null, + ); + + return response()->json([ + 'data' => [ + 'id' => $key->id, + 'name' => $key->name, + 'plain_text_key' => $key->plainTextKey, + 'permissions' => $key->permissions ?? [], + 'rate_limit' => $key->rate_limit, + ], + ], 201); + } + + public function revoke(Request $request, int $keyId): JsonResponse + { + RevokeAgentKey::run((int) $request->attributes->get('workspace_id'), $keyId); + + return response()->json([ + 'data' => [ + 'key_id' => $keyId, + 'revoked' => true, + ], + ]); + } +} diff --git a/php/Controllers/Api/BrainController.php b/php/Controllers/Api/BrainController.php index bbc2c28..59e2df8 100644 --- a/php/Controllers/Api/BrainController.php +++ b/php/Controllers/Api/BrainController.php @@ -33,11 +33,12 @@ class BrainController extends Controller ]); $workspace = $request->attributes->get('workspace'); - $apiKey = $request->attributes->get('api_key'); + $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); + $apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key'); $agentId = $apiKey?->name ?? 'api'; try { - $memory = RememberKnowledge::run($validated, $workspace->id, $agentId); + $memory = RememberKnowledge::run($validated, $workspaceId, $agentId); return response()->json([ 'data' => $memory->toMcpContext(), @@ -73,11 +74,12 @@ class BrainController extends Controller ]); $workspace = $request->attributes->get('workspace'); + $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); try { $result = RecallKnowledge::run( $validated['query'], - $workspace->id, + $workspaceId, $validated['filter'] ?? [], $validated['top_k'] ?? 5, ); @@ -110,11 +112,12 @@ class BrainController extends Controller ]); $workspace = $request->attributes->get('workspace'); - $apiKey = $request->attributes->get('api_key'); + $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); + $apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key'); $agentId = $apiKey?->name ?? 'api'; try { - $result = ForgetKnowledge::run($id, $workspace->id, $agentId, $request->input('reason')); + $result = ForgetKnowledge::run($id, $workspaceId, $agentId, $request->input('reason')); return response()->json([ 'data' => $result, @@ -147,9 +150,10 @@ class BrainController extends Controller ]); $workspace = $request->attributes->get('workspace'); + $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); try { - $result = ListKnowledge::run($workspace->id, $validated); + $result = ListKnowledge::run($workspaceId, $validated); return response()->json([ 'data' => $result, diff --git a/php/Controllers/Api/CreditsController.php b/php/Controllers/Api/CreditsController.php new file mode 100644 index 0000000..126cc1a --- /dev/null +++ b/php/Controllers/Api/CreditsController.php @@ -0,0 +1,71 @@ +validate([ + 'agent_id' => 'required|string|max:255', + 'task_type' => 'required|string|max:255', + 'amount' => 'required|integer|not_in:0', + 'fleet_node_id' => 'nullable|integer', + 'description' => 'nullable|string|max:1000', + ]); + + $entry = AwardCredits::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['task_type'], + (int) $validated['amount'], + isset($validated['fleet_node_id']) ? (int) $validated['fleet_node_id'] : null, + $validated['description'] ?? null, + ); + + return response()->json(['data' => $this->formatEntry($entry)], 201); + } + + public function balance(Request $request, string $agentId): JsonResponse + { + $balance = GetBalance::run((int) $request->attributes->get('workspace_id'), $agentId); + + return response()->json(['data' => $balance]); + } + + public function history(Request $request, string $agentId): JsonResponse + { + $limit = (int) $request->query('limit', 50); + $entries = GetCreditHistory::run((int) $request->attributes->get('workspace_id'), $agentId, $limit); + + return response()->json([ + 'data' => $entries->map(fn (CreditEntry $entry) => $this->formatEntry($entry))->values()->all(), + 'total' => $entries->count(), + ]); + } + + /** + * @return array + */ + private function formatEntry(CreditEntry $entry): array + { + return [ + 'id' => $entry->id, + 'task_type' => $entry->task_type, + 'amount' => $entry->amount, + 'balance_after' => $entry->balance_after, + 'description' => $entry->description, + 'created_at' => $entry->created_at?->toIso8601String(), + ]; + } +} diff --git a/php/Controllers/Api/FleetController.php b/php/Controllers/Api/FleetController.php new file mode 100644 index 0000000..d17ce25 --- /dev/null +++ b/php/Controllers/Api/FleetController.php @@ -0,0 +1,233 @@ +validate([ + 'agent_id' => 'required|string|max:255', + 'platform' => 'required|string|max:64', + 'models' => 'nullable|array', + 'models.*' => 'string', + 'capabilities' => 'nullable|array', + ]); + + $node = RegisterNode::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['platform'], + $validated['models'] ?? [], + $validated['capabilities'] ?? [], + ); + + return response()->json(['data' => $this->formatNode($node)], 201); + } + + public function heartbeat(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'status' => 'required|string|max:32', + 'compute_budget' => 'nullable|array', + ]); + + $node = NodeHeartbeat::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['status'], + $validated['compute_budget'] ?? [], + ); + + return response()->json(['data' => $this->formatNode($node)]); + } + + public function deregister(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + ]); + + DeregisterNode::run((int) $request->attributes->get('workspace_id'), $validated['agent_id']); + + return response()->json(['data' => ['agent_id' => $validated['agent_id'], 'deregistered' => true]]); + } + + public function index(Request $request): JsonResponse + { + $validated = $request->validate([ + 'status' => 'nullable|string|max:32', + 'platform' => 'nullable|string|max:64', + ]); + + $nodes = ListNodes::run( + (int) $request->attributes->get('workspace_id'), + $validated['status'] ?? null, + $validated['platform'] ?? null, + ); + + return response()->json([ + 'data' => $nodes->map(fn (FleetNode $node) => $this->formatNode($node))->values()->all(), + 'total' => $nodes->count(), + ]); + } + + public function assignTask(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'repo' => 'required|string|max:255', + 'branch' => 'nullable|string|max:255', + 'task' => 'required|string|max:10000', + 'template' => 'nullable|string|max:255', + 'agent_model' => 'nullable|string|max:255', + ]); + + $fleetTask = AssignTask::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['task'], + $validated['repo'], + $validated['template'] ?? null, + $validated['branch'] ?? null, + $validated['agent_model'] ?? null, + ); + + return response()->json(['data' => $this->formatTask($fleetTask)], 201); + } + + public function completeTask(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'task_id' => 'required|integer', + 'result' => 'nullable|array', + 'findings' => 'nullable|array', + 'changes' => 'nullable|array', + 'report' => 'nullable|array', + ]); + + $fleetTask = CompleteTask::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + (int) $validated['task_id'], + $validated['result'] ?? [], + $validated['findings'] ?? [], + $validated['changes'] ?? [], + $validated['report'] ?? [], + ); + + return response()->json(['data' => $this->formatTask($fleetTask)]); + } + + public function nextTask(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'capabilities' => 'nullable|array', + ]); + + $fleetTask = GetNextTask::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['capabilities'] ?? [], + ); + + return response()->json(['data' => $fleetTask ? $this->formatTask($fleetTask) : null]); + } + + public function events(Request $request): StreamedResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + ]); + + $workspaceId = (int) $request->attributes->get('workspace_id'); + $agentId = $validated['agent_id']; + + return response()->stream(function () use ($workspaceId, $agentId): void { + echo "event: ready\n"; + echo 'data: '.json_encode(['agent_id' => $agentId])."\n\n"; + + $fleetTask = GetNextTask::run($workspaceId, $agentId, []); + if ($fleetTask instanceof FleetTask) { + echo "event: task.assigned\n"; + echo 'data: '.json_encode($this->formatTask($fleetTask))."\n\n"; + } + + @ob_flush(); + flush(); + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]); + } + + public function stats(Request $request): JsonResponse + { + $stats = GetFleetStats::run((int) $request->attributes->get('workspace_id')); + + return response()->json(['data' => $stats]); + } + + /** + * @return array + */ + private function formatNode(FleetNode $node): array + { + return [ + 'id' => $node->id, + 'agent_id' => $node->agent_id, + 'platform' => $node->platform, + 'models' => $node->models ?? [], + 'capabilities' => $node->capabilities ?? [], + 'status' => $node->status, + 'compute_budget' => $node->compute_budget ?? [], + 'current_task_id' => $node->current_task_id, + 'last_heartbeat_at' => $node->last_heartbeat_at?->toIso8601String(), + 'registered_at' => $node->registered_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + private function formatTask(FleetTask $fleetTask): array + { + return [ + 'id' => $fleetTask->id, + 'repo' => $fleetTask->repo, + 'branch' => $fleetTask->branch, + 'task' => $fleetTask->task, + 'template' => $fleetTask->template, + 'agent_model' => $fleetTask->agent_model, + 'status' => $fleetTask->status, + 'result' => $fleetTask->result ?? [], + 'findings' => $fleetTask->findings ?? [], + 'changes' => $fleetTask->changes ?? [], + 'report' => $fleetTask->report ?? [], + 'started_at' => $fleetTask->started_at?->toIso8601String(), + 'completed_at' => $fleetTask->completed_at?->toIso8601String(), + ]; + } +} diff --git a/php/Controllers/Api/MessageController.php b/php/Controllers/Api/MessageController.php index 8fa3e07..a07f4f1 100644 --- a/php/Controllers/Api/MessageController.php +++ b/php/Controllers/Api/MessageController.php @@ -69,15 +69,19 @@ class MessageController extends Controller $validated = $request->validate([ 'to' => 'required|string|max:100', 'content' => 'required|string|max:10000', - 'from' => 'required|string|max:100', + 'from' => 'nullable|string|max:100', 'subject' => 'nullable|string|max:255', ]); $workspaceId = $request->attributes->get('workspace_id'); + $apiKey = $request->attributes->get('agent_api_key'); + $from = $validated['from'] + ?? $apiKey?->name + ?? $request->header('X-Agent-Name', 'unknown'); $message = AgentMessage::create([ 'workspace_id' => $workspaceId, - 'from_agent' => $validated['from'], + 'from_agent' => $from, 'to_agent' => $validated['to'], 'content' => $validated['content'], 'subject' => $validated['subject'] ?? null, diff --git a/php/Controllers/Api/SubscriptionController.php b/php/Controllers/Api/SubscriptionController.php new file mode 100644 index 0000000..d420bc9 --- /dev/null +++ b/php/Controllers/Api/SubscriptionController.php @@ -0,0 +1,48 @@ +validate([ + 'api_keys' => 'nullable|array', + ]); + + $capabilities = DetectCapabilities::run($validated['api_keys'] ?? []); + + return response()->json(['data' => $capabilities]); + } + + public function budget(Request $request, string $agentId): JsonResponse + { + $budget = GetNodeBudget::run((int) $request->attributes->get('workspace_id'), $agentId); + + return response()->json(['data' => $budget]); + } + + public function updateBudget(Request $request, string $agentId): JsonResponse + { + $validated = $request->validate([ + 'limits' => 'required|array', + ]); + + $budget = UpdateBudget::run( + (int) $request->attributes->get('workspace_id'), + $agentId, + $validated['limits'], + ); + + return response()->json(['data' => $budget]); + } +} diff --git a/php/Controllers/Api/SyncController.php b/php/Controllers/Api/SyncController.php new file mode 100644 index 0000000..41b232b --- /dev/null +++ b/php/Controllers/Api/SyncController.php @@ -0,0 +1,64 @@ +validate([ + 'agent_id' => 'required|string|max:255', + 'dispatches' => 'nullable|array', + ]); + + $result = PushDispatchHistory::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['dispatches'] ?? [], + ); + + return response()->json(['data' => $result], 201); + } + + public function pull(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'since' => 'nullable|date', + ]); + + $context = PullFleetContext::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['since'] ?? null, + ); + + return response()->json([ + 'data' => $context, + 'total' => count($context), + ]); + } + + public function status(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + ]); + + $status = GetAgentSyncStatus::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + ); + + return response()->json(['data' => $status]); + } +} diff --git a/php/Middleware/AgentApiAuth.php b/php/Middleware/AgentApiAuth.php index fa55303..4c3386d 100644 --- a/php/Middleware/AgentApiAuth.php +++ b/php/Middleware/AgentApiAuth.php @@ -72,7 +72,9 @@ class AgentApiAuth // Store API key in request for downstream use $request->attributes->set('agent_api_key', $key); + $request->attributes->set('api_key', $key); $request->attributes->set('workspace_id', $key->workspace_id); + $request->attributes->set('workspace', $key->workspace); /** @var Response $response */ $response = $next($request); diff --git a/php/Migrations/2026_03_31_000001_create_agent_fleet_tables.php b/php/Migrations/2026_03_31_000001_create_agent_fleet_tables.php new file mode 100644 index 0000000..5204006 --- /dev/null +++ b/php/Migrations/2026_03_31_000001_create_agent_fleet_tables.php @@ -0,0 +1,101 @@ +id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('agent_id')->unique(); + $table->string('platform', 64)->default('unknown'); + $table->json('models')->nullable(); + $table->json('capabilities')->nullable(); + $table->string('status', 32)->default('offline'); + $table->json('compute_budget')->nullable(); + $table->unsignedBigInteger('current_task_id')->nullable(); + $table->timestamp('last_heartbeat_at')->nullable(); + $table->timestamp('registered_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + }); + } + + if (! Schema::hasTable('fleet_tasks')) { + Schema::create('fleet_tasks', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete(); + $table->string('repo'); + $table->string('branch')->nullable(); + $table->text('task'); + $table->string('template')->nullable(); + $table->string('agent_model')->nullable(); + $table->string('status', 32)->default('queued'); + $table->json('result')->nullable(); + $table->json('findings')->nullable(); + $table->json('changes')->nullable(); + $table->json('report')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + $table->index(['fleet_node_id', 'status']); + }); + } + + if (! Schema::hasTable('credit_entries')) { + Schema::create('credit_entries', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete(); + $table->string('task_type'); + $table->integer('amount'); + $table->integer('balance_after'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'fleet_node_id']); + }); + } + + if (! Schema::hasTable('sync_records')) { + Schema::create('sync_records', function (Blueprint $table) { + $table->id(); + $table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete(); + $table->string('direction', 16); + $table->unsignedInteger('payload_size')->default(0); + $table->unsignedInteger('items_count')->default(0); + $table->timestamp('synced_at')->nullable(); + $table->timestamps(); + + $table->index(['fleet_node_id', 'direction']); + }); + } + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + + Schema::dropIfExists('sync_records'); + Schema::dropIfExists('credit_entries'); + Schema::dropIfExists('fleet_tasks'); + Schema::dropIfExists('fleet_nodes'); + + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/php/Models/AgentApiKey.php b/php/Models/AgentApiKey.php index e6b41cc..74ad840 100644 --- a/php/Models/AgentApiKey.php +++ b/php/Models/AgentApiKey.php @@ -80,6 +80,40 @@ class AgentApiKey extends Model public const PERM_SESSIONS_WRITE = 'sessions.write'; + public const PERM_BRAIN_READ = 'brain.read'; + + public const PERM_BRAIN_WRITE = 'brain.write'; + + public const PERM_ISSUES_READ = 'issues.read'; + + public const PERM_ISSUES_WRITE = 'issues.write'; + + public const PERM_SPRINTS_READ = 'sprints.read'; + + public const PERM_SPRINTS_WRITE = 'sprints.write'; + + public const PERM_MESSAGES_READ = 'messages.read'; + + public const PERM_MESSAGES_WRITE = 'messages.write'; + + public const PERM_AUTH_WRITE = 'auth.write'; + + public const PERM_FLEET_READ = 'fleet.read'; + + public const PERM_FLEET_WRITE = 'fleet.write'; + + public const PERM_SYNC_READ = 'sync.read'; + + public const PERM_SYNC_WRITE = 'sync.write'; + + public const PERM_CREDITS_READ = 'credits.read'; + + public const PERM_CREDITS_WRITE = 'credits.write'; + + public const PERM_SUBSCRIPTION_READ = 'subscription.read'; + + public const PERM_SUBSCRIPTION_WRITE = 'subscription.write'; + public const PERM_TOOLS_READ = 'tools.read'; public const PERM_TEMPLATES_READ = 'templates.read'; @@ -104,6 +138,23 @@ class AgentApiKey extends Model self::PERM_PHASES_WRITE => 'Update phase status, add/complete tasks', self::PERM_SESSIONS_READ => 'List and view sessions', self::PERM_SESSIONS_WRITE => 'Start, update, complete sessions', + self::PERM_BRAIN_READ => 'Recall and list brain memories', + self::PERM_BRAIN_WRITE => 'Store and forget brain memories', + self::PERM_ISSUES_READ => 'List and view issues', + self::PERM_ISSUES_WRITE => 'Create, update, and archive issues', + self::PERM_SPRINTS_READ => 'List and view sprints', + self::PERM_SPRINTS_WRITE => 'Create, update, and archive sprints', + self::PERM_MESSAGES_READ => 'Read inbox and conversation threads', + self::PERM_MESSAGES_WRITE => 'Send and acknowledge messages', + self::PERM_AUTH_WRITE => 'Provision and revoke agent API keys', + self::PERM_FLEET_READ => 'View fleet nodes, tasks, and stats', + self::PERM_FLEET_WRITE => 'Register nodes and manage fleet tasks', + self::PERM_SYNC_READ => 'Pull shared fleet context and sync status', + self::PERM_SYNC_WRITE => 'Push dispatch history to the platform', + self::PERM_CREDITS_READ => 'View agent credit balances and history', + self::PERM_CREDITS_WRITE => 'Award agent credits', + self::PERM_SUBSCRIPTION_READ => 'View node budgets and capability detection', + self::PERM_SUBSCRIPTION_WRITE => 'Update node budgets', self::PERM_TOOLS_READ => 'View tool analytics', self::PERM_TEMPLATES_READ => 'List and view templates', self::PERM_TEMPLATES_INSTANTIATE => 'Create plans from templates', @@ -252,7 +303,15 @@ class AgentApiKey extends Model // Permission helpers public function hasPermission(string $permission): bool { - return in_array($permission, $this->permissions ?? []); + $wanted = $this->normalisePermission($permission); + + foreach ($this->permissions ?? [] as $granted) { + if ($this->normalisePermission((string) $granted) === $wanted) { + return true; + } + } + + return false; } public function hasAnyPermission(array $permissions): bool @@ -277,6 +336,11 @@ class AgentApiKey extends Model return true; } + protected function normalisePermission(string $permission): string + { + return str_replace(':', '.', trim($permission)); + } + // Actions public function revoke(): self { diff --git a/php/Models/CreditEntry.php b/php/Models/CreditEntry.php new file mode 100644 index 0000000..21bb1f3 --- /dev/null +++ b/php/Models/CreditEntry.php @@ -0,0 +1,39 @@ + 'integer', + 'balance_after' => 'integer', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function fleetNode(): BelongsTo + { + return $this->belongsTo(FleetNode::class); + } +} diff --git a/php/Models/FleetNode.php b/php/Models/FleetNode.php new file mode 100644 index 0000000..f1858eb --- /dev/null +++ b/php/Models/FleetNode.php @@ -0,0 +1,82 @@ + 'array', + 'capabilities' => 'array', + 'compute_budget' => 'array', + 'last_heartbeat_at' => 'datetime', + 'registered_at' => 'datetime', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function currentTask(): BelongsTo + { + return $this->belongsTo(FleetTask::class, 'current_task_id'); + } + + public function tasks(): HasMany + { + return $this->hasMany(FleetTask::class); + } + + public function creditEntries(): HasMany + { + return $this->hasMany(CreditEntry::class); + } + + public function syncRecords(): HasMany + { + return $this->hasMany(SyncRecord::class); + } + + public function scopeOnline(Builder $query): Builder + { + return $query->whereIn('status', [self::STATUS_ONLINE, self::STATUS_BUSY]); + } + + public function scopeIdle(Builder $query): Builder + { + return $query->where('status', self::STATUS_ONLINE) + ->whereNull('current_task_id'); + } +} diff --git a/php/Models/FleetTask.php b/php/Models/FleetTask.php new file mode 100644 index 0000000..e9609e3 --- /dev/null +++ b/php/Models/FleetTask.php @@ -0,0 +1,69 @@ + 'array', + 'findings' => 'array', + 'changes' => 'array', + 'report' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function fleetNode(): BelongsTo + { + return $this->belongsTo(FleetNode::class); + } + + public function scopePendingForNode(Builder $query, FleetNode $node): Builder + { + return $query->where('fleet_node_id', $node->id) + ->whereIn('status', [self::STATUS_ASSIGNED, self::STATUS_QUEUED]) + ->orderBy('created_at'); + } +} diff --git a/php/Models/SyncRecord.php b/php/Models/SyncRecord.php new file mode 100644 index 0000000..81f1fd3 --- /dev/null +++ b/php/Models/SyncRecord.php @@ -0,0 +1,30 @@ + 'integer', + 'items_count' => 'integer', + 'synced_at' => 'datetime', + ]; + + public function fleetNode(): BelongsTo + { + return $this->belongsTo(FleetNode::class); + } +} diff --git a/php/Routes/api.php b/php/Routes/api.php index 7ef5770..3ac92f4 100644 --- a/php/Routes/api.php +++ b/php/Routes/api.php @@ -3,8 +3,19 @@ declare(strict_types=1); use Core\Mod\Agentic\Controllers\AgentApiController; +use Core\Mod\Agentic\Controllers\Api\AuthController; +use Core\Mod\Agentic\Controllers\Api\BrainController; +use Core\Mod\Agentic\Controllers\Api\CreditsController; +use Core\Mod\Agentic\Controllers\Api\FleetController; use Core\Mod\Agentic\Controllers\Api\IssueController; +use Core\Mod\Agentic\Controllers\Api\MessageController; +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\SprintController; +use Core\Mod\Agentic\Controllers\Api\SubscriptionController; +use Core\Mod\Agentic\Controllers\Api\SyncController; +use Core\Mod\Agentic\Controllers\Api\TaskController; use Core\Mod\Agentic\Middleware\AgentApiAuth; use Illuminate\Support\Facades\Route; @@ -33,42 +44,46 @@ Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function () Route::get('v1/agent/checkin', [\Core\Mod\Agentic\Controllers\Api\CheckinController::class, 'checkin']); }); -// Authenticated agent endpoints +Route::middleware(AgentApiAuth::class.':brain.read')->group(function () { + Route::post('v1/brain/recall', [BrainController::class, 'recall']); + Route::get('v1/brain/list', [BrainController::class, 'list']); +}); + +Route::middleware(AgentApiAuth::class.':brain.write')->group(function () { + Route::post('v1/brain/remember', [BrainController::class, 'remember']); + Route::delete('v1/brain/forget/{id}', [BrainController::class, 'forget']); +}); + Route::middleware(AgentApiAuth::class.':plans.read')->group(function () { - // Plans (read) - Route::get('v1/plans', [AgentApiController::class, 'listPlans']); - Route::get('v1/plans/{slug}', [AgentApiController::class, 'getPlan']); - - // Phases (read) - Route::get('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'getPhase']); - - // Sessions (read) - Route::get('v1/sessions', [AgentApiController::class, 'listSessions']); - Route::get('v1/sessions/{sessionId}', [AgentApiController::class, 'getSession']); + Route::get('v1/plans', [PlanController::class, 'index']); + Route::get('v1/plans/{slug}', [PlanController::class, 'show']); + Route::get('v1/plans/{slug}/phases/{phase}', [PhaseController::class, 'show']); }); Route::middleware(AgentApiAuth::class.':plans.write')->group(function () { - // Plans (write) - Route::post('v1/plans', [AgentApiController::class, 'createPlan']); - Route::patch('v1/plans/{slug}', [AgentApiController::class, 'updatePlan']); - Route::delete('v1/plans/{slug}', [AgentApiController::class, 'archivePlan']); + Route::post('v1/plans', [PlanController::class, 'store']); + Route::patch('v1/plans/{slug}/status', [PlanController::class, 'update']); + Route::delete('v1/plans/{slug}', [PlanController::class, 'destroy']); }); Route::middleware(AgentApiAuth::class.':phases.write')->group(function () { - // Phases (write) - Route::patch('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'updatePhase']); - Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [AgentApiController::class, 'addCheckpoint']); - Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}', [AgentApiController::class, 'updateTask']) - ->whereNumber('taskIdx'); - Route::post('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle', [AgentApiController::class, 'toggleTask']) - ->whereNumber('taskIdx'); + Route::patch('v1/plans/{slug}/phases/{phase}', [PhaseController::class, 'update']); + Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [PhaseController::class, 'checkpoint']); + Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{index}', [TaskController::class, 'update']) + ->whereNumber('index'); + Route::post('v1/plans/{slug}/phases/{phase}/tasks/{index}/toggle', [TaskController::class, 'toggle']) + ->whereNumber('index'); +}); + +Route::middleware(AgentApiAuth::class.':sessions.read')->group(function () { + Route::get('v1/sessions', [SessionController::class, 'index']); + Route::get('v1/sessions/{id}', [SessionController::class, 'show']); }); Route::middleware(AgentApiAuth::class.':sessions.write')->group(function () { - // Sessions (write) - Route::post('v1/sessions', [AgentApiController::class, 'startSession']); - Route::post('v1/sessions/{sessionId}/end', [AgentApiController::class, 'endSession']); - Route::post('v1/sessions/{sessionId}/continue', [AgentApiController::class, 'continueSession']); + Route::post('v1/sessions', [SessionController::class, 'store']); + Route::post('v1/sessions/{id}/continue', [SessionController::class, 'continue']); + Route::post('v1/sessions/{id}/end', [SessionController::class, 'end']); }); // Issue tracker @@ -97,13 +112,62 @@ Route::middleware(AgentApiAuth::class.':sprints.write')->group(function () { Route::delete('v1/sprints/{slug}', [SprintController::class, 'destroy']); }); -// Agent messaging — uses auth.api (same as brain routes) so CORE_BRAIN_KEY works -Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function () { - Route::get('v1/messages/inbox', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'inbox']); - Route::get('v1/messages/conversation/{agent}', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'conversation']); +Route::middleware(AgentApiAuth::class.':messages.read')->group(function () { + Route::get('v1/messages/inbox', [MessageController::class, 'inbox']); + Route::get('v1/messages/conversation/{agent}', [MessageController::class, 'conversation']); }); -Route::middleware(['throttle:60,1', 'auth.api:brain:write'])->group(function () { - Route::post('v1/messages/send', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'send']); - Route::post('v1/messages/{id}/read', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'markRead']); +Route::middleware(AgentApiAuth::class.':messages.write')->group(function () { + Route::post('v1/messages/send', [MessageController::class, 'send']); + Route::post('v1/messages/{id}/read', [MessageController::class, 'markRead']); +}); + +Route::middleware('auth')->group(function () { + Route::post('v1/agent/auth/provision', [AuthController::class, 'provision']); +}); + +Route::middleware(AgentApiAuth::class.':auth.write')->group(function () { + Route::delete('v1/agent/auth/revoke/{keyId}', [AuthController::class, 'revoke']); +}); + +Route::middleware(AgentApiAuth::class.':fleet.write')->group(function () { + Route::post('v1/fleet/register', [FleetController::class, 'register']); + Route::post('v1/fleet/heartbeat', [FleetController::class, 'heartbeat']); + Route::post('v1/fleet/deregister', [FleetController::class, 'deregister']); + Route::post('v1/fleet/task/assign', [FleetController::class, 'assignTask']); + Route::post('v1/fleet/task/complete', [FleetController::class, 'completeTask']); +}); + +Route::middleware(AgentApiAuth::class.':fleet.read')->group(function () { + Route::get('v1/fleet/nodes', [FleetController::class, 'index']); + Route::get('v1/fleet/task/next', [FleetController::class, 'nextTask']); + Route::get('v1/fleet/events', [FleetController::class, 'events']); + Route::get('v1/fleet/stats', [FleetController::class, 'stats']); +}); + +Route::middleware(AgentApiAuth::class.':sync.write')->group(function () { + Route::post('v1/agent/sync', [SyncController::class, 'push']); +}); + +Route::middleware(AgentApiAuth::class.':sync.read')->group(function () { + Route::get('v1/agent/context', [SyncController::class, 'pull']); + Route::get('v1/agent/status', [SyncController::class, 'status']); +}); + +Route::middleware(AgentApiAuth::class.':credits.write')->group(function () { + Route::post('v1/credits/award', [CreditsController::class, 'award']); +}); + +Route::middleware(AgentApiAuth::class.':credits.read')->group(function () { + Route::get('v1/credits/balance/{agentId}', [CreditsController::class, 'balance']); + Route::get('v1/credits/history/{agentId}', [CreditsController::class, 'history']); +}); + +Route::middleware(AgentApiAuth::class.':subscription.write')->group(function () { + Route::post('v1/subscription/detect', [SubscriptionController::class, 'detect']); + Route::put('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'updateBudget']); +}); + +Route::middleware(AgentApiAuth::class.':subscription.read')->group(function () { + Route::get('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'budget']); }); diff --git a/pkg/agentic/auto_pr.go b/pkg/agentic/auto_pr.go index 93c9024..5faf715 100644 --- a/pkg/agentic/auto_pr.go +++ b/pkg/agentic/auto_pr.go @@ -6,6 +6,7 @@ import ( "context" "time" + "dappco.re/go/agent/pkg/messages" core "dappco.re/go/core" ) @@ -51,6 +52,13 @@ func (s *PrepSubsystem) autoCreatePR(workspaceDir string) { } return } + if s.ServiceRuntime != nil { + s.Core().ACTION(messages.WorkspacePushed{ + Repo: workspaceStatus.Repo, + Branch: workspaceStatus.Branch, + Org: org, + }) + } title := core.Sprintf("[agent/%s] %s", workspaceStatus.Agent, truncate(workspaceStatus.Task, 60)) body := s.buildAutoPRBody(workspaceStatus, commitCount) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 0e6fb99..cd0e986 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -111,6 +111,9 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { core.Option{Key: "token", Value: s.brainKey}, )) + c.Action("agent.sync.push", s.handleSyncPush).Description = "Push completed dispatch state to the platform API" + c.Action("agent.sync.pull", s.handleSyncPull).Description = "Pull fleet context from the platform API" + c.Action("agentic.dispatch", s.handleDispatch).Description = "Prep workspace and spawn a subagent" c.Action("agentic.prep", s.handlePrep).Description = "Clone repo and build agent prompt" c.Action("agentic.status", s.handleStatus).Description = "List workspace states (running/completed/blocked)" diff --git a/pkg/agentic/sync.go b/pkg/agentic/sync.go new file mode 100644 index 0000000..1835b1d --- /dev/null +++ b/pkg/agentic/sync.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" +) + +type SyncPushInput struct { + AgentID string `json:"agent_id,omitempty"` +} + +type SyncPushOutput struct { + Success bool `json:"success"` + Count int `json:"count"` +} + +type SyncPullInput struct { + AgentID string `json:"agent_id,omitempty"` +} + +type SyncPullOutput struct { + Success bool `json:"success"` + Count int `json:"count"` + Context []map[string]any `json:"context"` +} + +// result := c.Action("agent.sync.push").Run(ctx, core.NewOptions()) +func (s *PrepSubsystem) handleSyncPush(ctx context.Context, options core.Options) core.Result { + output, err := s.syncPush(ctx, options.String("agent_id")) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("agent.sync.pull").Run(ctx, core.NewOptions()) +func (s *PrepSubsystem) handleSyncPull(ctx context.Context, options core.Options) core.Result { + output, err := s.syncPull(ctx, options.String("agent_id")) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) syncPush(ctx context.Context, agentID string) (SyncPushOutput, error) { + if agentID == "" { + agentID = AgentName() + } + token := s.syncToken() + if token == "" { + return SyncPushOutput{}, core.E("agent.sync.push", "api token is required", nil) + } + + dispatches := collectSyncDispatches() + if len(dispatches) == 0 { + return SyncPushOutput{Success: true, Count: 0}, nil + } + + payload := map[string]any{ + "agent_id": agentID, + "dispatches": dispatches, + } + + result := HTTPPost(ctx, core.Concat(s.syncAPIURL(), "/v1/agent/sync"), core.JSONMarshalString(payload), token, "Bearer") + if !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("agent.sync.push", "sync push failed", nil) + } + return SyncPushOutput{}, err + } + + return SyncPushOutput{Success: true, Count: len(dispatches)}, nil +} + +func (s *PrepSubsystem) syncPull(ctx context.Context, agentID string) (SyncPullOutput, error) { + if agentID == "" { + agentID = AgentName() + } + token := s.syncToken() + if token == "" { + return SyncPullOutput{}, core.E("agent.sync.pull", "api token is required", nil) + } + + endpoint := core.Concat(s.syncAPIURL(), "/v1/agent/context?agent_id=", agentID) + result := HTTPGet(ctx, endpoint, token, "Bearer") + if !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("agent.sync.pull", "sync pull failed", nil) + } + return SyncPullOutput{}, err + } + + var response struct { + Data []map[string]any `json:"data"` + } + parseResult := core.JSONUnmarshalString(result.Value.(string), &response) + if !parseResult.OK { + err, _ := parseResult.Value.(error) + if err == nil { + err = core.E("agent.sync.pull", "failed to parse sync response", nil) + } + return SyncPullOutput{}, err + } + + return SyncPullOutput{ + Success: true, + Count: len(response.Data), + Context: response.Data, + }, nil +} + +func (s *PrepSubsystem) syncAPIURL() string { + if value := core.Env("CORE_API_URL"); value != "" { + return value + } + if s != nil && s.brainURL != "" { + return s.brainURL + } + return "https://api.lthn.sh" +} + +func (s *PrepSubsystem) syncToken() string { + if value := core.Env("CORE_AGENT_API_KEY"); value != "" { + return value + } + if value := core.Env("CORE_BRAIN_KEY"); value != "" { + return value + } + if s != nil && s.brainKey != "" { + return s.brainKey + } + return "" +} + +func collectSyncDispatches() []map[string]any { + var dispatches []map[string]any + for _, path := range WorkspaceStatusPaths() { + workspaceDir := core.PathDir(path) + statusResult := ReadStatusResult(workspaceDir) + workspaceStatus, ok := workspaceStatusValue(statusResult) + if !ok { + continue + } + if !shouldSyncStatus(workspaceStatus.Status) { + continue + } + dispatches = append(dispatches, map[string]any{ + "workspace": WorkspaceName(workspaceDir), + "repo": workspaceStatus.Repo, + "org": workspaceStatus.Org, + "task": workspaceStatus.Task, + "agent": workspaceStatus.Agent, + "branch": workspaceStatus.Branch, + "status": workspaceStatus.Status, + "pr_url": workspaceStatus.PRURL, + "started_at": workspaceStatus.StartedAt, + "updated_at": workspaceStatus.UpdatedAt, + }) + } + return dispatches +} + +func shouldSyncStatus(status string) bool { + switch status { + case "completed", "merged", "failed", "blocked": + return true + } + return false +} diff --git a/pkg/agentic/sync_example_test.go b/pkg/agentic/sync_example_test.go new file mode 100644 index 0000000..bd77ebc --- /dev/null +++ b/pkg/agentic/sync_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import "fmt" + +func Example_shouldSyncStatus() { + fmt.Println(shouldSyncStatus("completed")) + fmt.Println(shouldSyncStatus("running")) + // Output: + // true + // false +} diff --git a/pkg/agentic/sync_test.go b/pkg/agentic/sync_test.go new file mode 100644 index 0000000..1f76a55 --- /dev/null +++ b/pkg/agentic/sync_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSync_HandleSyncPush_Good(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + t.Setenv("CORE_AGENT_API_KEY", "secret-token") + + workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5") + fs.EnsureDir(workspaceDir) + writeStatusResult(workspaceDir, &WorkspaceStatus{ + Status: "completed", + Agent: "codex", + Repo: "go-io", + Org: "core", + Task: "Fix tests", + Branch: "agent/fix-tests", + StartedAt: time.Now(), + UpdatedAt: time.Now(), + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/agent/sync", r.URL.Path) + require.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, AgentName(), payload["agent_id"]) + dispatches, ok := payload["dispatches"].([]any) + require.True(t, ok) + require.Len(t, dispatches, 1) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"synced":1}}`)) + })) + defer server.Close() + + subsystem := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + brainURL: server.URL, + } + output, err := subsystem.syncPush(context.Background(), "") + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, 1, output.Count) +} + +func TestSync_HandleSyncPull_Bad(t *testing.T) { + t.Setenv("CORE_AGENT_API_KEY", "secret-token") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/agent/context", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + })) + defer server.Close() + + subsystem := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + brainURL: server.URL, + } + _, err := subsystem.syncPull(context.Background(), "codex") + require.Error(t, err) +} + +func TestSync_HandleSyncPull_Ugly(t *testing.T) { + t.Setenv("CORE_AGENT_API_KEY", "secret-token") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/agent/context", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{this is not json`)) + })) + defer server.Close() + + subsystem := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + brainURL: server.URL, + } + _, err := subsystem.syncPull(context.Background(), "codex") + require.Error(t, err) +} diff --git a/pkg/messages/messages.go b/pkg/messages/messages.go index 863431d..e87ffcd 100644 --- a/pkg/messages/messages.go +++ b/pkg/messages/messages.go @@ -41,6 +41,13 @@ type PRMerged struct { PRNum int } +// c.ACTION(messages.WorkspacePushed{Repo: "go-io", Branch: "agent/fix-tests", Org: "core"}) +type WorkspacePushed struct { + Repo string + Branch string + Org string +} + // c.ACTION(messages.PRNeedsReview{Repo: "go-io", PRNum: 12, Reason: "merge conflict"}) type PRNeedsReview struct { Repo string @@ -84,9 +91,8 @@ type HarvestRejected struct { Reason string } -// c.ACTION(messages.InboxMessage{From: "charon", Subject: "status", Content: "all green"}) +// c.ACTION(messages.InboxMessage{New: 2, Total: 5}) type InboxMessage struct { - From string - Subject string - Content string + New int + Total int } diff --git a/pkg/messages/messages_example_test.go b/pkg/messages/messages_example_test.go index 43d8865..1536d92 100644 --- a/pkg/messages/messages_example_test.go +++ b/pkg/messages/messages_example_test.go @@ -32,3 +32,9 @@ func ExampleQueueDrained() { fmt.Println(ev.Completed) // Output: 3 } + +func ExampleWorkspacePushed() { + ev := WorkspacePushed{Repo: "go-io", Branch: "agent/fix-tests", Org: "core"} + fmt.Println(ev.Repo, ev.Org) + // Output: go-io core +} diff --git a/pkg/messages/messages_test.go b/pkg/messages/messages_test.go index 693fe4c..f95525c 100644 --- a/pkg/messages/messages_test.go +++ b/pkg/messages/messages_test.go @@ -18,16 +18,17 @@ func TestMessages_AllSatisfyMessage_Good(t *testing.T) { QAResult{Workspace: "core/go-io/task-5", Repo: "go-io", Passed: true}, PRCreated{Repo: "go-io", Branch: "agent/fix", PRURL: "https://forge.lthn.ai/core/go-io/pulls/1", PRNum: 1}, PRMerged{Repo: "go-io", PRURL: "https://forge.lthn.ai/core/go-io/pulls/1", PRNum: 1}, + WorkspacePushed{Repo: "go-io", Branch: "agent/fix", Org: "core"}, PRNeedsReview{Repo: "go-io", PRNum: 1, Reason: "merge conflict"}, QueueDrained{Completed: 3}, PokeQueue{}, RateLimitDetected{Pool: "codex", Duration: "30m"}, HarvestComplete{Repo: "go-io", Branch: "agent/fix", Files: 5}, HarvestRejected{Repo: "go-io", Branch: "agent/fix", Reason: "binary detected"}, - InboxMessage{From: "charon", Subject: "test", Content: "hello"}, + InboxMessage{New: 1, Total: 3}, } - assert.Len(t, msgs, 12, "expected 12 message types") + assert.Len(t, msgs, 13, "expected 13 message types") for _, msg := range msgs { assert.NotNil(t, msg) } diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index 961a7c0..be0f752 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -447,6 +447,10 @@ func (m *Subsystem) checkInbox() string { } if m.ServiceRuntime != nil { + m.Core().ACTION(messages.InboxMessage{ + New: len(inboxMessages), + Total: unread, + }) if notifierResult := m.Core().Service("mcp"); notifierResult.OK { if notifier, ok := notifierResult.Value.(channelSender); ok { for _, inboxMessage := range inboxMessages {