diff --git a/php/Controllers/Api/AgentAuth/AgentAuthController.php b/php/Controllers/Api/AgentAuth/AgentAuthController.php new file mode 100644 index 0000000..2a2edc2 --- /dev/null +++ b/php/Controllers/Api/AgentAuth/AgentAuthController.php @@ -0,0 +1,156 @@ +validate([ + 'plan_id' => 'nullable|integer', + 'plan_slug' => 'nullable|string|max:255', + 'agent_type' => 'nullable|string|max:255', + 'context_summary' => 'nullable|array', + 'context' => 'nullable|array', + 'work_log' => 'nullable|array', + 'artifacts' => 'nullable|array', + 'handoff_notes' => 'nullable|array', + ]); + + $session = $this->createSession( + (int) $request->attributes->get('workspace_id'), + $validated, + ); + + return response()->json(['data' => $this->formatSession($session)], 201); + } + + /** + * @param array $payload + */ + private function createSession(int $workspaceId, array $payload): AgentSession + { + $service = $this->resolveSessionService(); + + if ($service !== null && method_exists($service, 'create')) { + $session = $service->create($workspaceId, $payload); + + if ($session instanceof AgentSession) { + return $session; + } + } + + $workspace = Workspace::query()->find($workspaceId); + $agentType = trim((string) ($payload['agent_type'] ?? '')); + $session = AgentSession::start( + $this->resolvePlan($workspaceId, $payload), + $agentType !== '' ? $agentType : null, + $workspace instanceof Workspace ? $workspace : null, + ); + + $attributes = []; + + if (isset($payload['context_summary']) && is_array($payload['context_summary'])) { + $attributes['context_summary'] = $payload['context_summary']; + } elseif (isset($payload['context']) && is_array($payload['context'])) { + $attributes['context_summary'] = $payload['context']; + } + + if (isset($payload['work_log']) && is_array($payload['work_log'])) { + $attributes['work_log'] = array_values($payload['work_log']); + } + + if (isset($payload['artifacts']) && is_array($payload['artifacts'])) { + $attributes['artifacts'] = array_values($payload['artifacts']); + } + + if (isset($payload['handoff_notes']) && is_array($payload['handoff_notes'])) { + $attributes['handoff_notes'] = $payload['handoff_notes']; + } + + if ($attributes !== []) { + $session->update($attributes); + } + + return $session->fresh() ?? $session; + } + + /** + * @param array $payload + */ + private function resolvePlan(int $workspaceId, array $payload): ?AgentPlan + { + if (isset($payload['plan_id'])) { + $plan = AgentPlan::query() + ->where('workspace_id', $workspaceId) + ->find((int) $payload['plan_id']); + + if (! $plan instanceof AgentPlan) { + throw new \InvalidArgumentException('Plan not found'); + } + + return $plan; + } + + if (isset($payload['plan_slug'])) { + $plan = AgentPlan::query() + ->where('workspace_id', $workspaceId) + ->where('slug', (string) $payload['plan_slug']) + ->first(); + + if (! $plan instanceof AgentPlan) { + throw new \InvalidArgumentException('Plan not found'); + } + + return $plan; + } + + return null; + } + + /** + * @return array + */ + private function formatSession(AgentSession $session): array + { + return [ + 'id' => $session->id, + 'session_id' => $session->session_id, + 'workspace_id' => $session->workspace_id, + 'agent_plan_id' => $session->agent_plan_id, + 'agent_type' => $session->agent_type, + 'status' => $session->status, + 'context_summary' => $session->context_summary ?? [], + 'work_log' => $session->work_log ?? [], + 'artifacts' => $session->artifacts ?? [], + 'handoff_notes' => $session->handoff_notes ?? [], + 'final_summary' => $session->final_summary, + 'started_at' => $session->started_at?->toIso8601String(), + 'last_active_at' => $session->last_active_at?->toIso8601String(), + 'ended_at' => $session->ended_at?->toIso8601String(), + ]; + } + + private function resolveSessionService(): ?object + { + if (! class_exists(SessionService::class)) { + return null; + } + + $service = app(SessionService::class); + + return is_object($service) ? $service : null; + } +} diff --git a/php/Controllers/Api/Credits/CreditsController.php b/php/Controllers/Api/Credits/CreditsController.php new file mode 100644 index 0000000..228f5c1 --- /dev/null +++ b/php/Controllers/Api/Credits/CreditsController.php @@ -0,0 +1,176 @@ +attributes->get('workspace_id'); + $service = $this->resolveCreditService(); + + $payload = $service !== null && method_exists($service, 'balance') + ? (array) $service->balance($workspaceId) + : $this->fallbackBalance($workspaceId); + + return response()->json(['data' => $payload]); + } + + public function deduct(Request $request): JsonResponse + { + $validated = $request->validate([ + 'amount' => 'required|integer|min:1', + 'reason' => 'required|string|max:1000', + ]); + + $workspaceId = (int) $request->attributes->get('workspace_id'); + $service = $this->resolveCreditService(); + + $entry = $service !== null && method_exists($service, 'deduct') + ? $service->deduct($workspaceId, (int) $validated['amount'], $validated['reason']) + : $this->recordTransaction($workspaceId, -abs((int) $validated['amount']), 'manual-deduction', $validated['reason']); + + return response()->json(['data' => $this->formatEntry($entry)], 201); + } + + public function refund(Request $request): JsonResponse + { + $validated = $request->validate([ + 'amount' => 'required|integer|min:1', + 'reason' => 'required|string|max:1000', + ]); + + $workspaceId = (int) $request->attributes->get('workspace_id'); + $service = $this->resolveCreditService(); + + $entry = $service !== null && method_exists($service, 'refund') + ? $service->refund($workspaceId, (int) $validated['amount'], $validated['reason']) + : $this->recordTransaction($workspaceId, abs((int) $validated['amount']), 'manual-refund', $validated['reason']); + + return response()->json(['data' => $this->formatEntry($entry)], 201); + } + + public function ledger(Request $request): JsonResponse + { + $validated = $request->validate([ + 'limit' => 'nullable|integer|min:1|max:500', + ]); + + $workspaceId = (int) $request->attributes->get('workspace_id'); + $limit = (int) ($validated['limit'] ?? 50); + $service = $this->resolveCreditService(); + + $entries = []; + + if ($service !== null && method_exists($service, 'ledger')) { + foreach ($service->ledger($workspaceId) as $entry) { + $entries[] = $this->formatEntry($entry); + + if (count($entries) >= $limit) { + break; + } + } + } else { + foreach (CreditEntry::query()->where('workspace_id', $workspaceId)->latest('id')->limit($limit)->get() as $entry) { + $entries[] = $this->formatEntry($entry); + } + } + + return response()->json([ + 'data' => $entries, + 'total' => count($entries), + ]); + } + + /** + * @return array + */ + private function fallbackBalance(int $workspaceId): array + { + $entries = CreditEntry::query()->where('workspace_id', $workspaceId); + + return [ + 'workspace_id' => $workspaceId, + 'balance' => (int) (clone $entries)->sum('amount'), + 'total_earned' => (int) (clone $entries)->where('amount', '>', 0)->sum('amount'), + 'total_spent' => (int) abs((int) (clone $entries)->where('amount', '<', 0)->sum('amount')), + 'entries' => (int) (clone $entries)->count(), + ]; + } + + private function recordTransaction(int $workspaceId, int $amount, string $taskType, string $reason): CreditEntry + { + return DB::transaction(function () use ($workspaceId, $amount, $taskType, $reason): CreditEntry { + $previousBalance = (int) CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->lockForUpdate() + ->latest('id') + ->value('balance_after'); + + return CreditEntry::query()->create([ + 'workspace_id' => $workspaceId, + 'fleet_node_id' => null, + 'task_type' => $taskType, + 'amount' => $amount, + 'balance_after' => $previousBalance + $amount, + 'description' => $reason, + ]); + }); + } + + /** + * @return array + */ + private function formatEntry(object|array $entry): array + { + if (is_array($entry)) { + return [ + 'id' => isset($entry['id']) ? (int) $entry['id'] : null, + 'workspace_id' => isset($entry['workspace_id']) ? (int) $entry['workspace_id'] : null, + 'fleet_node_id' => isset($entry['fleet_node_id']) ? (int) $entry['fleet_node_id'] : null, + 'task_type' => (string) ($entry['task_type'] ?? ''), + 'amount' => (int) ($entry['amount'] ?? 0), + 'balance_after' => (int) ($entry['balance_after'] ?? 0), + 'description' => isset($entry['description']) ? (string) $entry['description'] : null, + 'created_at' => isset($entry['created_at']) ? (string) $entry['created_at'] : null, + ]; + } + + $createdAt = $entry->created_at ?? null; + + return [ + 'id' => isset($entry->id) ? (int) $entry->id : null, + 'workspace_id' => isset($entry->workspace_id) ? (int) $entry->workspace_id : null, + 'fleet_node_id' => isset($entry->fleet_node_id) ? (int) $entry->fleet_node_id : null, + 'task_type' => (string) ($entry->task_type ?? ''), + 'amount' => (int) ($entry->amount ?? 0), + 'balance_after' => (int) ($entry->balance_after ?? 0), + 'description' => isset($entry->description) ? (string) $entry->description : null, + 'created_at' => is_object($createdAt) && method_exists($createdAt, 'toIso8601String') + ? $createdAt->toIso8601String() + : ($createdAt !== null ? (string) $createdAt : null), + ]; + } + + private function resolveCreditService(): ?object + { + if (! class_exists(CreditService::class)) { + return null; + } + + $service = app(CreditService::class); + + return is_object($service) ? $service : null; + } +} diff --git a/php/Controllers/Api/Fleet/FleetController.php b/php/Controllers/Api/Fleet/FleetController.php new file mode 100644 index 0000000..deecbb9 --- /dev/null +++ b/php/Controllers/Api/Fleet/FleetController.php @@ -0,0 +1,179 @@ +validate([ + 'agent_id' => 'nullable|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', + 'report' => 'nullable|array', + ]); + + $fleetTask = $this->dispatchTask( + (int) $request->attributes->get('workspace_id'), + $validated, + ); + + return response()->json(['data' => $this->formatTask($fleetTask)], 201); + } + + public function stream(Request $request): StreamedResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'capabilities' => 'nullable|array', + 'capabilities.*' => 'string', + 'limit' => 'nullable|integer|min:1', + 'poll_interval_ms' => 'nullable|integer|min:100|max:5000', + ]); + + $workspaceId = (int) $request->attributes->get('workspace_id'); + $agentId = $validated['agent_id']; + $capabilities = $validated['capabilities'] ?? []; + $limit = (int) ($validated['limit'] ?? 0); + $pollIntervalMs = (int) ($validated['poll_interval_ms'] ?? 1000); + + return response()->stream(function () use ($workspaceId, $agentId, $capabilities, $limit, $pollIntervalMs): void { + $emitted = 0; + + ignore_user_abort(true); + set_time_limit(0); + + $this->streamEvent('ready', ['agent_id' => $agentId]); + + while (! connection_aborted()) { + $fleetTask = GetNextTask::run($workspaceId, $agentId, $capabilities); + + if ($fleetTask instanceof FleetTask) { + $this->streamEvent('task.assigned', $this->formatTask($fleetTask)); + $emitted++; + + if ($limit > 0 && $emitted >= $limit) { + break; + } + + continue; + } + + usleep($pollIntervalMs * 1000); + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]); + } + + /** + * @param array $payload + */ + private function dispatchTask(int $workspaceId, array $payload): FleetTask + { + $service = $this->resolveFleetService(); + + if ($service !== null && method_exists($service, 'dispatch')) { + $fleetTask = $service->dispatch($workspaceId, $payload); + + if ($fleetTask instanceof FleetTask) { + return $fleetTask; + } + } + + $agentId = trim((string) ($payload['agent_id'] ?? '')); + if ($agentId !== '') { + return AssignTask::run( + $workspaceId, + $agentId, + (string) $payload['task'], + (string) $payload['repo'], + isset($payload['template']) ? (string) $payload['template'] : null, + isset($payload['branch']) ? (string) $payload['branch'] : null, + isset($payload['agent_model']) ? (string) $payload['agent_model'] : null, + ); + } + + $fleetTask = FleetTask::query()->create([ + 'workspace_id' => $workspaceId, + 'fleet_node_id' => null, + 'repo' => (string) $payload['repo'], + 'branch' => isset($payload['branch']) ? (string) $payload['branch'] : null, + 'task' => (string) $payload['task'], + 'template' => isset($payload['template']) ? (string) $payload['template'] : null, + 'agent_model' => isset($payload['agent_model']) ? (string) $payload['agent_model'] : null, + 'status' => FleetTask::STATUS_QUEUED, + 'report' => isset($payload['report']) && is_array($payload['report']) ? $payload['report'] : null, + ])->fresh(); + + if (! $fleetTask instanceof FleetTask) { + throw new \RuntimeException('Failed to create fleet task'); + } + + return $fleetTask; + } + + /** + * @param array $data + */ + private function streamEvent(string $event, array $data): void + { + echo "event: {$event}\n"; + echo 'data: '.json_encode($data)."\n\n"; + + @ob_flush(); + flush(); + } + + /** + * @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(), + ]; + } + + private function resolveFleetService(): ?object + { + if (! class_exists(FleetService::class)) { + return null; + } + + $service = app(FleetService::class); + + return is_object($service) ? $service : null; + } +} diff --git a/php/Controllers/Api/Subscription/SubscriptionController.php b/php/Controllers/Api/Subscription/SubscriptionController.php new file mode 100644 index 0000000..6ca4f48 --- /dev/null +++ b/php/Controllers/Api/Subscription/SubscriptionController.php @@ -0,0 +1,171 @@ +validate([ + 'agent_id' => 'nullable|string|max:255', + 'api_keys' => 'nullable|array', + 'api_keys.*' => 'string', + ]); + + $workspaceId = (int) $request->attributes->get('workspace_id'); + $capabilities = DetectCapabilities::run($validated['api_keys'] ?? []); + $credits = $this->workspaceBalance($workspaceId); + $agentId = trim((string) ($validated['agent_id'] ?? '')); + + return response()->json([ + 'data' => [ + 'workspace_id' => $workspaceId, + 'status' => ! empty($capabilities['available']) || (($credits['balance'] ?? 0) > 0) ? 'active' : 'inactive', + 'providers' => $capabilities['providers'] ?? [], + 'available' => $capabilities['available'] ?? [], + 'credits' => $credits, + 'budget' => $agentId !== '' ? GetNodeBudget::run($workspaceId, $agentId) : null, + ], + ]); + } + + public function upgrade(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'limits' => 'required|array', + 'session_id' => 'nullable|string|max:255', + ]); + + $budget = UpdateBudget::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['limits'], + ); + + $this->emitSessionEvent( + $validated['session_id'] ?? null, + 'subscription.upgraded', + ['agent_id' => $validated['agent_id'], 'limits' => $validated['limits']], + ); + + return response()->json([ + 'data' => [ + 'agent_id' => $validated['agent_id'], + 'status' => 'upgraded', + 'budget' => $budget, + ], + ]); + } + + public function cancel(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'session_id' => 'nullable|string|max:255', + ]); + + $budget = UpdateBudget::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + [ + 'cancelled' => true, + 'cancelled_at' => now()->toIso8601String(), + 'max_daily_hours' => 0, + ], + ); + + $this->emitSessionEvent( + $validated['session_id'] ?? null, + 'subscription.cancelled', + ['agent_id' => $validated['agent_id']], + ); + + return response()->json([ + 'data' => [ + 'agent_id' => $validated['agent_id'], + 'status' => 'cancelled', + 'budget' => $budget, + ], + ]); + } + + /** + * @return array + */ + private function workspaceBalance(int $workspaceId): array + { + $service = $this->resolveCreditService(); + + if ($service !== null && method_exists($service, 'balance')) { + return (array) $service->balance($workspaceId); + } + + $entries = CreditEntry::query()->where('workspace_id', $workspaceId); + + return [ + 'workspace_id' => $workspaceId, + 'balance' => (int) (clone $entries)->sum('amount'), + 'total_earned' => (int) (clone $entries)->where('amount', '>', 0)->sum('amount'), + 'total_spent' => (int) abs((int) (clone $entries)->where('amount', '<', 0)->sum('amount')), + 'entries' => (int) (clone $entries)->count(), + ]; + } + + /** + * @param array $data + */ + private function emitSessionEvent(?string $sessionId, string $event, array $data): void + { + if ($sessionId === null || trim($sessionId) === '') { + return; + } + + $service = $this->resolveSessionService(); + + if ($service === null || ! method_exists($service, 'emit')) { + return; + } + + $service->emit($sessionId, [ + 'event' => $event, + 'data' => $data, + ]); + } + + private function resolveCreditService(): ?object + { + if (! class_exists(CreditService::class)) { + return null; + } + + $service = app(CreditService::class); + + return is_object($service) ? $service : null; + } + + private function resolveSessionService(): ?object + { + if (! class_exists(SessionService::class)) { + return null; + } + + $service = app(SessionService::class); + + return is_object($service) ? $service : null; + } +} diff --git a/php/Controllers/Api/Sync/SyncController.php b/php/Controllers/Api/Sync/SyncController.php new file mode 100644 index 0000000..ea3c5aa --- /dev/null +++ b/php/Controllers/Api/Sync/SyncController.php @@ -0,0 +1,98 @@ +validate([ + 'agent_id' => 'required|string|max:255', + 'dispatches' => 'nullable|array', + 'session_id' => 'nullable|string|max:255', + ]); + + $result = PushDispatchHistory::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['dispatches'] ?? [], + ); + + $this->emitSessionEvent( + $validated['session_id'] ?? null, + 'sync.push', + ['agent_id' => $validated['agent_id'], 'synced' => $result['synced'] ?? 0], + ); + + return response()->json(['data' => $result], 201); + } + + public function pull(Request $request): JsonResponse + { + $validated = $request->validate([ + 'agent_id' => 'required|string|max:255', + 'since' => 'nullable|date', + 'session_id' => 'nullable|string|max:255', + ]); + + $context = PullFleetContext::run( + (int) $request->attributes->get('workspace_id'), + $validated['agent_id'], + $validated['since'] ?? null, + ); + + $this->emitSessionEvent( + $validated['session_id'] ?? null, + 'sync.pull', + ['agent_id' => $validated['agent_id'], 'total' => count($context)], + ); + + return response()->json([ + 'data' => $context, + 'total' => count($context), + ]); + } + + /** + * @param array $data + */ + private function emitSessionEvent(?string $sessionId, string $event, array $data): void + { + if ($sessionId === null || trim($sessionId) === '') { + return; + } + + $service = $this->resolveSessionService(); + + if ($service === null || ! method_exists($service, 'emit')) { + return; + } + + $service->emit($sessionId, [ + 'event' => $event, + 'data' => $data, + ]); + } + + private function resolveSessionService(): ?object + { + if (! class_exists(SessionService::class)) { + return null; + } + + $service = app(SessionService::class); + + return is_object($service) ? $service : null; + } +} diff --git a/php/Routes/api.php b/php/Routes/api.php index 2bf5b7c..cdea33f 100644 --- a/php/Routes/api.php +++ b/php/Routes/api.php @@ -176,3 +176,42 @@ Route::middleware(AgentApiAuth::class.':subscription.write')->group(function () Route::middleware(AgentApiAuth::class.':subscription.read')->group(function () { Route::get('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'budget']); }); + +Route::middleware(AgentApiAuth::class.':auth.write,sessions.write')->group(function () { + Route::post('v1/agent/auth/register', [\Core\Mod\Agentic\Controllers\Api\AgentAuth\AgentAuthController::class, 'register']); +}); + +Route::middleware(AgentApiAuth::class.':fleet.write')->group(function () { + Route::post('v1/fleet/dispatch', [\Core\Mod\Agentic\Controllers\Api\Fleet\FleetController::class, 'dispatch']); +}); + +Route::middleware(AgentApiAuth::class.':fleet.read')->group(function () { + Route::get('v1/fleet/stream', [\Core\Mod\Agentic\Controllers\Api\Fleet\FleetController::class, 'stream']); +}); + +Route::middleware(AgentApiAuth::class.':credits.write')->group(function () { + Route::post('v1/credits/deduct', [\Core\Mod\Agentic\Controllers\Api\Credits\CreditsController::class, 'deduct']); + Route::post('v1/credits/refund', [\Core\Mod\Agentic\Controllers\Api\Credits\CreditsController::class, 'refund']); +}); + +Route::middleware(AgentApiAuth::class.':credits.read')->group(function () { + Route::get('v1/credits/balance', [\Core\Mod\Agentic\Controllers\Api\Credits\CreditsController::class, 'balance']); + Route::get('v1/credits/ledger', [\Core\Mod\Agentic\Controllers\Api\Credits\CreditsController::class, 'ledger']); +}); + +Route::middleware(AgentApiAuth::class.':subscription.write')->group(function () { + Route::post('v1/subscription/upgrade', [\Core\Mod\Agentic\Controllers\Api\Subscription\SubscriptionController::class, 'upgrade']); + Route::post('v1/subscription/cancel', [\Core\Mod\Agentic\Controllers\Api\Subscription\SubscriptionController::class, 'cancel']); +}); + +Route::middleware(AgentApiAuth::class.':subscription.read')->group(function () { + Route::get('v1/subscription/status', [\Core\Mod\Agentic\Controllers\Api\Subscription\SubscriptionController::class, 'status']); +}); + +Route::middleware(AgentApiAuth::class.':sync.write')->group(function () { + Route::post('v1/agent/sync/push', [\Core\Mod\Agentic\Controllers\Api\Sync\SyncController::class, 'push']); +}); + +Route::middleware(AgentApiAuth::class.':sync.read')->group(function () { + Route::get('v1/agent/sync/pull', [\Core\Mod\Agentic\Controllers\Api\Sync\SyncController::class, 'pull']); +}); diff --git a/php/tests/Feature/Api/AgentAuth/RoutesTest.php b/php/tests/Feature/Api/AgentAuth/RoutesTest.php new file mode 100644 index 0000000..e185b59 --- /dev/null +++ b/php/tests/Feature/Api/AgentAuth/RoutesTest.php @@ -0,0 +1,64 @@ +withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/agent/auth/register', [ + 'agent_type' => 'codex', + 'context' => ['repo' => 'core/agent'], + 'work_log' => [ + ['message' => 'Registered via API'], + ], + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.status', AgentSession::STATUS_ACTIVE) + ->assertJsonPath('data.agent_type', 'codex') + ->assertJsonPath('data.context_summary.repo', 'core/agent') + ->assertJsonPath('data.work_log.0.message', 'Registered via API'); + + expect(AgentSession::query()->where('workspace_id', $workspace->id)->count())->toBe(1); +}); + +test('agent auth provision route returns a new plain text key', function (): void { + $workspace = createWorkspace(); + + $this->withoutMiddleware(); + + $response = $this->postJson('/v1/agent/auth/provision', [ + 'workspace_id' => $workspace->id, + 'oauth_user_id' => 'user-42', + 'permissions' => [AgentApiKey::PERM_FLEET_READ], + 'rate_limit' => 120, + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.rate_limit', 120) + ->assertJsonPath('data.permissions.0', AgentApiKey::PERM_FLEET_READ); + + expect((string) $response->json('data.plain_text_key'))->toStartWith('ak_'); +}); diff --git a/php/tests/Feature/Api/Credits/RoutesTest.php b/php/tests/Feature/Api/Credits/RoutesTest.php new file mode 100644 index 0000000..29e8063 --- /dev/null +++ b/php/tests/Feature/Api/Credits/RoutesTest.php @@ -0,0 +1,119 @@ + $workspace->id, + 'fleet_node_id' => null, + 'task_type' => 'manual-refund', + 'amount' => 30, + 'balance_after' => 30, + ]); + CreditEntry::create([ + 'workspace_id' => $workspace->id, + 'fleet_node_id' => null, + 'task_type' => 'manual-deduction', + 'amount' => -10, + 'balance_after' => 20, + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/credits/balance'); + + $response + ->assertOk() + ->assertJsonPath('data.workspace_id', $workspace->id) + ->assertJsonPath('data.balance', 20) + ->assertJsonPath('data.total_earned', 30) + ->assertJsonPath('data.total_spent', 10) + ->assertJsonPath('data.entries', 2); +}); + +test('credits deduct route records a negative ledger entry', function (): void { + $workspace = createWorkspace(); + $key = creditsRouteKey($workspace, [AgentApiKey::PERM_CREDITS_WRITE]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/credits/deduct', [ + 'amount' => 15, + 'reason' => 'Manual moderation charge', + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.amount', -15) + ->assertJsonPath('data.balance_after', -15) + ->assertJsonPath('data.task_type', 'manual-deduction'); +}); + +test('credits refund route records a positive ledger entry', function (): void { + $workspace = createWorkspace(); + $key = creditsRouteKey($workspace, [AgentApiKey::PERM_CREDITS_WRITE]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/credits/refund', [ + 'amount' => 25, + 'reason' => 'Manual goodwill refund', + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.amount', 25) + ->assertJsonPath('data.balance_after', 25) + ->assertJsonPath('data.task_type', 'manual-refund'); +}); + +test('credits ledger route returns the newest workspace entries first', function (): void { + $workspace = createWorkspace(); + $key = creditsRouteKey($workspace, [AgentApiKey::PERM_CREDITS_READ]); + + CreditEntry::create([ + 'workspace_id' => $workspace->id, + 'fleet_node_id' => null, + 'task_type' => 'manual-refund', + 'amount' => 10, + 'balance_after' => 10, + ]); + CreditEntry::create([ + 'workspace_id' => $workspace->id, + 'fleet_node_id' => null, + 'task_type' => 'manual-deduction', + 'amount' => -3, + 'balance_after' => 7, + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/credits/ledger'); + + $response + ->assertOk() + ->assertJsonPath('total', 2) + ->assertJsonPath('data.0.task_type', 'manual-deduction') + ->assertJsonPath('data.0.balance_after', 7) + ->assertJsonPath('data.1.task_type', 'manual-refund'); +}); diff --git a/php/tests/Feature/Api/Fleet/RoutesTest.php b/php/tests/Feature/Api/Fleet/RoutesTest.php new file mode 100644 index 0000000..5dc0ac6 --- /dev/null +++ b/php/tests/Feature/Api/Fleet/RoutesTest.php @@ -0,0 +1,168 @@ + $workspace->id, + 'agent_id' => 'charon', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_OFFLINE, + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/fleet/heartbeat', [ + 'agent_id' => 'charon', + 'status' => FleetNode::STATUS_ONLINE, + 'compute_budget' => ['max_daily_hours' => 6], + ]); + + $response + ->assertOk() + ->assertJsonPath('data.agent_id', 'charon') + ->assertJsonPath('data.status', FleetNode::STATUS_ONLINE) + ->assertJsonPath('data.compute_budget.max_daily_hours', 6); +}); + +test('fleet nodes route lists nodes for the workspace', function (): void { + $workspace = createWorkspace(); + $key = fleetRouteKey($workspace, [AgentApiKey::PERM_FLEET_READ]); + + FleetNode::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'clotho', + 'platform' => 'darwin', + 'status' => FleetNode::STATUS_ONLINE, + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/fleet/nodes'); + + $response + ->assertOk() + ->assertJsonPath('total', 1) + ->assertJsonPath('data.0.agent_id', 'clotho') + ->assertJsonPath('data.0.platform', 'darwin'); +}); + +test('fleet dispatch route queues an unassigned task', function (): void { + $workspace = createWorkspace(); + $key = fleetRouteKey($workspace, [AgentApiKey::PERM_FLEET_WRITE]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/fleet/dispatch', [ + 'repo' => 'dappco.re/go/agent', + 'task' => 'Implement the dispatch alias route', + 'branch' => 'dev', + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.repo', 'dappco.re/go/agent') + ->assertJsonPath('data.status', FleetTask::STATUS_QUEUED); + + expect(FleetTask::query()->where('workspace_id', $workspace->id)->count())->toBe(1); +}); + +test('fleet stats route returns aggregate counters', function (): void { + $workspace = createWorkspace(); + $key = fleetRouteKey($workspace, [AgentApiKey::PERM_FLEET_READ]); + $node = FleetNode::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_ONLINE, + ]); + + FleetTask::create([ + 'workspace_id' => $workspace->id, + 'fleet_node_id' => $node->id, + 'repo' => 'core/agent', + 'task' => 'Summarise fleet throughput', + 'status' => FleetTask::STATUS_COMPLETED, + 'findings' => [['severity' => 'high'], ['severity' => 'low']], + 'started_at' => now()->subHour(), + 'completed_at' => now(), + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/fleet/stats'); + + $response + ->assertOk() + ->assertJsonPath('data.nodes_online', 1) + ->assertJsonPath('data.tasks_today', 1) + ->assertJsonPath('data.repos_touched', 1) + ->assertJsonPath('data.findings_total', 2); +}); + +test('fleet stream route emits sse frames for assigned tasks', function (): void { + $workspace = createWorkspace(); + $node = FleetNode::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'charon', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_ONLINE, + ]); + + $task = FleetTask::create([ + 'workspace_id' => $workspace->id, + 'fleet_node_id' => $node->id, + 'repo' => 'core/app', + 'task' => 'Ship the stream alias', + 'status' => FleetTask::STATUS_ASSIGNED, + ]); + + $request = Request::create('/v1/fleet/stream', 'GET', [ + 'agent_id' => 'charon', + 'limit' => 1, + 'poll_interval_ms' => 100, + ]); + $request->attributes->set('workspace_id', $workspace->id); + + $response = app(FleetController::class)->stream($request); + + ob_start(); + $response->sendContent(); + $output = ob_get_clean(); + + expect($output)->toContain('event: ready') + ->and($output)->toContain('"agent_id":"charon"') + ->and($output)->toContain('event: task.assigned') + ->and($output)->toContain('"repo":"core/app"') + ->and($output)->toContain('"task":"Ship the stream alias"'); + + $task->refresh(); + $node->refresh(); + + expect($task->status)->toBe(FleetTask::STATUS_IN_PROGRESS) + ->and($node->status)->toBe(FleetNode::STATUS_BUSY) + ->and($node->current_task_id)->toBe($task->id); +}); diff --git a/php/tests/Feature/Api/Subscription/RoutesTest.php b/php/tests/Feature/Api/Subscription/RoutesTest.php new file mode 100644 index 0000000..9797054 --- /dev/null +++ b/php/tests/Feature/Api/Subscription/RoutesTest.php @@ -0,0 +1,95 @@ + $workspace->id, + 'fleet_node_id' => null, + 'task_type' => 'manual-refund', + 'amount' => 5, + 'balance_after' => 5, + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/subscription/status?api_keys[openai]=test-key'); + + $response + ->assertOk() + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.providers.openai', true) + ->assertJsonPath('data.credits.balance', 5); +}); + +test('subscription upgrade route updates the node budget', function (): void { + $workspace = createWorkspace(); + $key = subscriptionRouteKey($workspace, [AgentApiKey::PERM_SUBSCRIPTION_WRITE]); + + FleetNode::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'charon', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_ONLINE, + 'compute_budget' => ['max_daily_hours' => 1], + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/subscription/upgrade', [ + 'agent_id' => 'charon', + 'limits' => ['max_daily_hours' => 4, 'prefer_models' => ['codex:gpt-5.4-mini']], + ]); + + $response + ->assertOk() + ->assertJsonPath('data.status', 'upgraded') + ->assertJsonPath('data.budget.max_daily_hours', 4) + ->assertJsonPath('data.budget.prefer_models.0', 'codex:gpt-5.4-mini'); +}); + +test('subscription cancel route marks the node budget as cancelled', function (): void { + $workspace = createWorkspace(); + $key = subscriptionRouteKey($workspace, [AgentApiKey::PERM_SUBSCRIPTION_WRITE]); + + FleetNode::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_ONLINE, + 'compute_budget' => ['max_daily_hours' => 8], + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/subscription/cancel', [ + 'agent_id' => 'virgil', + ]); + + $response + ->assertOk() + ->assertJsonPath('data.status', 'cancelled') + ->assertJsonPath('data.budget.cancelled', true) + ->assertJsonPath('data.budget.max_daily_hours', 0); +}); diff --git a/php/tests/Feature/Api/Sync/RoutesTest.php b/php/tests/Feature/Api/Sync/RoutesTest.php new file mode 100644 index 0000000..2da5267 --- /dev/null +++ b/php/tests/Feature/Api/Sync/RoutesTest.php @@ -0,0 +1,76 @@ +withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->postJson('/v1/agent/sync/push', [ + 'agent_id' => 'charon', + 'dispatches' => [[ + 'repo' => 'dappco.re/go/agent', + 'workspace' => 'core-agent', + 'task' => 'Record the sync alias route', + 'status' => 'completed', + ]], + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.synced', 1); + + expect(FleetNode::query()->where('agent_id', 'charon')->exists())->toBeTrue(); +}); + +test('agent sync pull route returns shared context', function (): void { + $workspace = createWorkspace(); + $key = syncRouteKey($workspace, [AgentApiKey::PERM_SYNC_READ]); + + FleetNode::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'charon', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_ONLINE, + ]); + + BrainMemory::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'charon', + 'type' => 'observation', + 'content' => 'Shared context for the new pull route.', + 'tags' => ['sync'], + 'confidence' => 0.8, + 'source' => 'test', + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/agent/sync/pull?agent_id=charon'); + + $response + ->assertOk() + ->assertJsonPath('total', 1) + ->assertJsonPath('data.0.agent_id', 'charon') + ->assertJsonPath('data.0.content', 'Shared context for the new pull route.'); +});