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:
Snider 2026-04-25 05:43:50 +01:00
parent 470ce0de99
commit dffdad8418
11 changed files with 1341 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View file

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

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

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

View 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);
});

View 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);
});

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