agent/php/Controllers/Api/Fleet/FleetController.php
Snider dffdad8418 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
2026-04-25 05:43:51 +01:00

179 lines
6 KiB
PHP

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