agent/php/tests/Feature/Agentic/Services/FleetServiceTest.php
Snider 470ce0de99 feat(agentic): implement §9 Services (FleetService + CreditService + SessionService) (#849)
Additive-only — no existing files modified.

- FleetService: wraps fleet actions+models, register/heartbeat/dispatch
  (direct or queued), node health snapshots, typed fleet stats
- CreditService: workspace-level balance/refund/deduct/ledger over
  credit_entries, returns typed CreditTransaction DTOs
- SessionService: RFC-§7 lifecycle session creation + guarded state
  transitions + SSE-style emission via Laravel events

DTOs: FleetStats, CreditTransaction (readonly).
Pest Feature tests _Good/_Bad/_Ugly per AX-10. pest skipped (vendor missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=849
2026-04-25 05:28:49 +01:00

116 lines
3.6 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Data\FleetStats;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
use Core\Mod\Agentic\Services\FleetService;
use function Pest\Laravel\assertDatabaseHas;
if (! function_exists('loadAgenticPhpClass')) {
function loadAgenticPhpClass(string $relativePath): void
{
$phpRoot = dirname(__DIR__, 4);
require_once $phpRoot.'/'.$relativePath;
}
}
beforeEach(function (): void {
loadAgenticPhpClass('Agentic/Data/FleetStats.php');
loadAgenticPhpClass('Agentic/Services/FleetService.php');
});
test('FleetService_register_Good_registers_heartbeats_and_reports_workspace_stats', function (): void {
$workspace = createWorkspace();
$service = new FleetService();
$registered = $service->register([
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'platform' => 'darwin',
'models' => ['gpt-5.5'],
'capabilities' => ['dispatch' => true],
]);
$heartbeat = $service->heartbeat([
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'status' => FleetNode::STATUS_BUSY,
'compute_budget' => ['max_daily_hours' => 4],
]);
$task = $service->dispatch($workspace->id, [
'agent_id' => 'alpha',
'repo' => 'dAppCore/core-agent',
'branch' => 'dev',
'task' => 'Review the next queue item and prepare an assignment.',
]);
$stats = $service->stats($workspace->id);
expect($registered->status)->toBe(FleetNode::STATUS_ONLINE)
->and($heartbeat->status)->toBe(FleetNode::STATUS_BUSY)
->and($task->status)->toBe(FleetTask::STATUS_ASSIGNED)
->and($stats)->toBeInstanceOf(FleetStats::class)
->and($stats->nodesOnline)->toBe(1)
->and($stats->tasksToday)->toBe(1);
assertDatabaseHas('fleet_nodes', [
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'status' => FleetNode::STATUS_BUSY,
]);
assertDatabaseHas('fleet_tasks', [
'workspace_id' => $workspace->id,
'repo' => 'dAppCore/core-agent',
'status' => FleetTask::STATUS_ASSIGNED,
]);
});
test('FleetService_dispatch_Bad_rejects_missing_repo_or_task', function (): void {
$workspace = createWorkspace();
$service = new FleetService();
expect(fn () => $service->dispatch($workspace->id, [
'repo' => '',
'task' => ' ',
]))->toThrow(InvalidArgumentException::class, 'repo and task are required');
});
test('FleetService_dispatch_Ugly_queues_unassigned_work_and_marks_stale_nodes_unhealthy', function (): void {
$workspace = createWorkspace();
$service = new FleetService();
$node = FleetNode::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'beta',
'platform' => 'linux',
'status' => FleetNode::STATUS_ONLINE,
'last_heartbeat_at' => now()->subMinutes(10),
'registered_at' => now()->subMinutes(10),
]);
$queued = $service->dispatch($workspace->id, [
'repo' => 'dAppCore/core-agent',
'task' => 'Pick this up when capacity is available.',
'report' => ['priority' => 'P1'],
]);
$health = $service->health($node);
expect($queued->status)->toBe(FleetTask::STATUS_QUEUED)
->and($health['agent_id'])->toBe('beta')
->and($health['is_stale'])->toBeTrue()
->and($health['is_online'])->toBeTrue();
assertDatabaseHas('fleet_tasks', [
'workspace_id' => $workspace->id,
'repo' => 'dAppCore/core-agent',
'status' => FleetTask::STATUS_QUEUED,
]);
});