feat(api): implement §3 fleet+credits+subscription+sync+agent-auth routes (#848)
Additive-only — appended to php/Routes/api.php (existing routes
preserved). Existing /v1/fleet/{nodes,heartbeat,stats} +
/v1/agent/auth/provision left untouched.
New routes:
- /v1/agent/auth/register
- /v1/fleet/dispatch + /v1/fleet/stream
- /v1/credits/{balance,deduct,refund,ledger}
- /v1/subscription/{status,upgrade,cancel}
- /v1/agent/sync/{push,pull}
New controllers under php/Controllers/Api/{Fleet,Credits,Subscription,
Sync,AgentAuth}/. Reference FleetService/CreditService/SessionService
when available with fallbacks to current action/model layer (pre #849).
Pest Feature coverage under php/tests/Feature/Api/. pest skipped
(vendor binaries missing in sandbox).
Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=848
This commit is contained in:
parent
470ce0de99
commit
dffdad8418
11 changed files with 1341 additions and 0 deletions
156
php/Controllers/Api/AgentAuth/AgentAuthController.php
Normal file
156
php/Controllers/Api/AgentAuth/AgentAuthController.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api\AgentAuth;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\Services\SessionService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AgentAuthController extends Controller
|
||||
{
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string, mixed> $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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
176
php/Controllers/Api/Credits/CreditsController.php
Normal file
176
php/Controllers/Api/Credits/CreditsController.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api\Credits;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Mod\Agentic\Services\CreditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreditsController extends Controller
|
||||
{
|
||||
public function balance(Request $request): JsonResponse
|
||||
{
|
||||
$workspaceId = (int) $request->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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
179
php/Controllers/Api/Fleet/FleetController.php
Normal file
179
php/Controllers/Api/Fleet/FleetController.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api\Fleet;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Fleet\AssignTask;
|
||||
use Core\Mod\Agentic\Actions\Fleet\GetNextTask;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
use Core\Mod\Agentic\Services\FleetService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FleetController extends Controller
|
||||
{
|
||||
public function dispatch(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string, mixed> $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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
171
php/Controllers/Api/Subscription/SubscriptionController.php
Normal file
171
php/Controllers/Api/Subscription/SubscriptionController.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api\Subscription;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Subscription\DetectCapabilities;
|
||||
use Core\Mod\Agentic\Actions\Subscription\GetNodeBudget;
|
||||
use Core\Mod\Agentic\Actions\Subscription\UpdateBudget;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Mod\Agentic\Services\CreditService;
|
||||
use Core\Mod\Agentic\Services\SessionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
98
php/Controllers/Api/Sync/SyncController.php
Normal file
98
php/Controllers/Api/Sync/SyncController.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api\Sync;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Sync\PullFleetContext;
|
||||
use Core\Mod\Agentic\Actions\Sync\PushDispatchHistory;
|
||||
use Core\Mod\Agentic\Services\SessionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SyncController extends Controller
|
||||
{
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
64
php/tests/Feature/Api/AgentAuth/RoutesTest.php
Normal file
64
php/tests/Feature/Api/AgentAuth/RoutesTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
beforeEach(function (): void {
|
||||
require __DIR__.'/../../../../Routes/api.php';
|
||||
});
|
||||
|
||||
function agentAuthRouteKey(
|
||||
Workspace $workspace,
|
||||
array $permissions = [AgentApiKey::PERM_AUTH_WRITE, AgentApiKey::PERM_SESSIONS_WRITE]
|
||||
): AgentApiKey {
|
||||
return createApiKey($workspace, 'Agent Auth Key', $permissions);
|
||||
}
|
||||
|
||||
test('agent auth register route creates an authenticated session record', function (): void {
|
||||
$workspace = createWorkspace();
|
||||
$key = agentAuthRouteKey($workspace);
|
||||
|
||||
$response = $this
|
||||
->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_');
|
||||
});
|
||||
119
php/tests/Feature/Api/Credits/RoutesTest.php
Normal file
119
php/tests/Feature/Api/Credits/RoutesTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
beforeEach(function (): void {
|
||||
require __DIR__.'/../../../../Routes/api.php';
|
||||
});
|
||||
|
||||
function creditsRouteKey(
|
||||
Workspace $workspace,
|
||||
array $permissions = [AgentApiKey::PERM_CREDITS_READ, AgentApiKey::PERM_CREDITS_WRITE]
|
||||
): AgentApiKey {
|
||||
return createApiKey($workspace, 'Credits Route Key', $permissions);
|
||||
}
|
||||
|
||||
test('credits balance route returns workspace totals', 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' => 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');
|
||||
});
|
||||
168
php/tests/Feature/Api/Fleet/RoutesTest.php
Normal file
168
php/tests/Feature/Api/Fleet/RoutesTest.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Controllers\Api\Fleet\FleetController;
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
beforeEach(function (): void {
|
||||
require __DIR__.'/../../../../Routes/api.php';
|
||||
});
|
||||
|
||||
function fleetRouteKey(
|
||||
Workspace $workspace,
|
||||
array $permissions = [AgentApiKey::PERM_FLEET_READ, AgentApiKey::PERM_FLEET_WRITE]
|
||||
): AgentApiKey {
|
||||
return createApiKey($workspace, 'Fleet Route Key', $permissions);
|
||||
}
|
||||
|
||||
test('fleet heartbeat route updates the node status', function (): void {
|
||||
$workspace = createWorkspace();
|
||||
$key = fleetRouteKey($workspace);
|
||||
|
||||
FleetNode::create([
|
||||
'workspace_id' => $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);
|
||||
});
|
||||
95
php/tests/Feature/Api/Subscription/RoutesTest.php
Normal file
95
php/tests/Feature/Api/Subscription/RoutesTest.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
beforeEach(function (): void {
|
||||
require __DIR__.'/../../../../Routes/api.php';
|
||||
});
|
||||
|
||||
function subscriptionRouteKey(
|
||||
Workspace $workspace,
|
||||
array $permissions = [AgentApiKey::PERM_SUBSCRIPTION_READ, AgentApiKey::PERM_SUBSCRIPTION_WRITE]
|
||||
): AgentApiKey {
|
||||
return createApiKey($workspace, 'Subscription Route Key', $permissions);
|
||||
}
|
||||
|
||||
test('subscription status route reports capability and credit status', function (): void {
|
||||
$workspace = createWorkspace();
|
||||
$key = subscriptionRouteKey($workspace, [AgentApiKey::PERM_SUBSCRIPTION_READ]);
|
||||
|
||||
CreditEntry::create([
|
||||
'workspace_id' => $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);
|
||||
});
|
||||
76
php/tests/Feature/Api/Sync/RoutesTest.php
Normal file
76
php/tests/Feature/Api/Sync/RoutesTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Models\BrainMemory;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
beforeEach(function (): void {
|
||||
require __DIR__.'/../../../../Routes/api.php';
|
||||
});
|
||||
|
||||
function syncRouteKey(
|
||||
Workspace $workspace,
|
||||
array $permissions = [AgentApiKey::PERM_SYNC_READ, AgentApiKey::PERM_SYNC_WRITE]
|
||||
): AgentApiKey {
|
||||
return createApiKey($workspace, 'Sync Route Key', $permissions);
|
||||
}
|
||||
|
||||
test('agent sync push route stores dispatch history', function (): void {
|
||||
$workspace = createWorkspace();
|
||||
$key = syncRouteKey($workspace, [AgentApiKey::PERM_SYNC_WRITE]);
|
||||
|
||||
$response = $this
|
||||
->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.');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue