feat(agentic): implement §11 Admin UI Livewire components (FleetOverview + BrainExplorer + CreditLedger) (#850)

Additive-only — no existing files modified.

- FleetOverview: node list + status badges + dispatch button + stats panel
- BrainExplorer: semantic-recall search with DB fallback + forget action
- CreditLedger: balance display + transaction list + deduct/refund actions

Flux Pro components (no vanilla Alpine). Uses existing
fleet/brain/credit actions+services in this package.

Pest Feature tests _Good/_Bad/_Ugly per AX-10 — load classes directly
since composer.json + Boot.php were left untouched per scope. Future
follow-up: wire PSR-4 + view registration in Boot.php.

pest skipped (vendor binaries missing in sandbox).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=850
This commit is contained in:
Snider 2026-04-25 05:16:50 +01:00
parent 09054fbdab
commit 40dccb2a14
9 changed files with 1648 additions and 0 deletions

View file

@ -0,0 +1,282 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Livewire;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Tenant\Models\Workspace;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Brain Explorer')]
#[Layout('hub::admin.layouts.app')]
class BrainExplorer extends Component
{
public int $workspaceId = 0;
public string $query = '';
public string $typeFilter = '';
public string $projectFilter = '';
public string $agentFilter = '';
public int $limit = 10;
/**
* @var array<int, array<string, mixed>>
*/
public array $results = [];
public bool $usedFallbackSearch = false;
public function mount(?int $workspaceId = null): void
{
$this->checkHadesAccess();
$this->workspaceId = $workspaceId ?? $this->resolveWorkspaceId();
$this->loadRecentMemories();
}
#[Computed]
public function memoryTypes(): array
{
return BrainMemory::VALID_TYPES;
}
#[Computed]
public function availableAgents(): array
{
if ($this->workspaceId <= 0) {
return [];
}
try {
return BrainMemory::query()
->forWorkspace($this->workspaceId)
->active()
->latestVersions()
->orderBy('agent_id')
->pluck('agent_id')
->filter(static fn (mixed $agentId): bool => is_string($agentId) && $agentId !== '')
->unique()
->values()
->all();
} catch (\Throwable) {
return [];
}
}
public function searchMemories(): void
{
$this->validate([
'workspaceId' => 'required|integer|min:1',
'query' => 'nullable|string|max:2000',
'typeFilter' => 'nullable|string|max:255',
'projectFilter' => 'nullable|string|max:255',
'agentFilter' => 'nullable|string|max:255',
'limit' => 'required|integer|min:1|max:20',
]);
if (trim($this->query) === '') {
$this->loadRecentMemories();
return;
}
try {
$result = RecallKnowledge::run(
trim($this->query),
$this->workspaceId,
$this->brainFilters(),
$this->limit,
);
$this->results = collect($result['memories'] ?? [])
->map(fn (mixed $memory): array => $this->normaliseMemory($memory))
->values()
->all();
$this->usedFallbackSearch = false;
} catch (\Throwable) {
$this->results = $this->fallbackSearch();
$this->usedFallbackSearch = true;
}
}
public function forgetMemory(string $memoryId): void
{
ForgetKnowledge::run(
$memoryId,
$this->workspaceId,
(string) (auth()->id() ?? 'hades-ui'),
$this->query !== '' ? "Forgotten from search: {$this->query}" : 'Forgotten from explorer',
);
$this->searchMemories();
$this->toast('Memory Forgotten', 'The memory was removed from the brain index.', 'warning');
}
public function refreshExplorer(): void
{
$this->searchMemories();
$this->dispatch('notify', message: 'Brain explorer refreshed');
}
public function typeBadgeVariant(string $type): string
{
return match ($type) {
'decision', 'architecture' => 'warning',
'fact', 'documentation', 'context' => 'success',
'bug' => 'danger',
default => 'zinc',
};
}
public function render(): View
{
return view()->file($this->viewPath());
}
private function loadRecentMemories(): void
{
if ($this->workspaceId <= 0) {
$this->results = [];
$this->usedFallbackSearch = false;
return;
}
try {
$result = ListKnowledge::run($this->workspaceId, $this->brainFilters() + [
'limit' => $this->limit,
]);
$this->results = collect($result['memories'] ?? [])
->map(fn (mixed $memory): array => $this->normaliseMemory($memory))
->values()
->all();
$this->usedFallbackSearch = false;
} catch (\Throwable) {
$this->results = [];
$this->usedFallbackSearch = false;
}
}
/**
* @return array<string, mixed>
*/
private function brainFilters(): array
{
return array_filter([
'type' => $this->typeFilter !== '' ? $this->typeFilter : null,
'project' => $this->projectFilter !== '' ? $this->projectFilter : null,
'agent_id' => $this->agentFilter !== '' ? $this->agentFilter : null,
], static fn (mixed $value): bool => $value !== null && $value !== '');
}
/**
* @return array<int, array<string, mixed>>
*/
private function fallbackSearch(): array
{
$query = BrainMemory::query()
->forWorkspace($this->workspaceId)
->active()
->latestVersions()
->forProject($this->projectFilter !== '' ? $this->projectFilter : null)
->byAgent($this->agentFilter !== '' ? $this->agentFilter : null);
if ($this->typeFilter !== '') {
$query->ofType($this->typeFilter);
}
if (trim($this->query) !== '') {
$like = '%'.trim($this->query).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('content', 'like', $like)
->orWhere('source', 'like', $like)
->orWhere('project', 'like', $like)
->orWhere('org', 'like', $like);
});
}
return $query->orderByDesc('created_at')
->limit($this->limit)
->get()
->map(fn (BrainMemory $memory): array => $this->normaliseMemory($memory->toMcpContext()))
->values()
->all();
}
/**
* @param array<string, mixed>|BrainMemory $memory
* @return array<string, mixed>
*/
private function normaliseMemory(array|BrainMemory $memory): array
{
if ($memory instanceof BrainMemory) {
$memory = $memory->toMcpContext();
}
return [
'id' => (string) ($memory['id'] ?? ''),
'agent_id' => (string) ($memory['agent_id'] ?? 'unknown'),
'type' => (string) ($memory['type'] ?? 'context'),
'content' => (string) ($memory['content'] ?? ''),
'tags' => array_values(array_filter($memory['tags'] ?? [], static fn (mixed $tag): bool => is_string($tag) && $tag !== '')),
'project' => $memory['project'] ?? null,
'org' => $memory['org'] ?? null,
'confidence' => isset($memory['confidence']) ? (float) $memory['confidence'] : 0.0,
'score' => isset($memory['score']) ? (float) $memory['score'] : null,
'source' => $memory['source'] ?? null,
'created_at' => $memory['created_at'] ?? null,
];
}
private function resolveWorkspaceId(): int
{
try {
return (int) (Workspace::query()->value('id') ?? 0);
} catch (\Throwable) {
return 0;
}
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades access required');
}
}
private function toast(string $heading, string $text, string $variant): void
{
if (class_exists(Flux::class)) {
Flux::toast(
heading: $heading,
text: $text,
variant: $variant,
);
return;
}
$this->dispatch('notify', message: $text, variant: $variant);
}
private function viewPath(): string
{
return __DIR__.'/../../resources/views/livewire/agentic/brain-explorer.blade.php';
}
}

View file

@ -0,0 +1,239 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Livewire;
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
use Core\Mod\Agentic\Actions\Credits\GetBalance;
use Core\Mod\Agentic\Actions\Credits\GetCreditHistory;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Tenant\Models\Workspace;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Credit Ledger')]
#[Layout('hub::admin.layouts.app')]
class CreditLedger extends Component
{
public int $workspaceId = 0;
public string $selectedAgentId = '';
public int $historyLimit = 25;
public int $adjustmentAmount = 1;
public string $adjustmentReason = '';
public function mount(?int $workspaceId = null): void
{
$this->checkHadesAccess();
$this->workspaceId = $workspaceId ?? $this->resolveWorkspaceId();
$this->syncSelectedAgentId();
}
#[Computed]
public function agents(): array
{
if ($this->workspaceId <= 0) {
return [];
}
try {
return FleetNode::query()
->where('workspace_id', $this->workspaceId)
->orderBy('agent_id')
->get()
->map(static fn (FleetNode $node): array => [
'id' => $node->id,
'agent_id' => $node->agent_id,
'platform' => $node->platform,
'status' => $node->status,
])
->values()
->all();
} catch (\Throwable) {
return [];
}
}
#[Computed]
public function balance(): array
{
if ($this->workspaceId <= 0 || $this->selectedAgentId === '') {
return [
'agent_id' => $this->selectedAgentId,
'balance' => 0,
'entries' => 0,
];
}
try {
return GetBalance::run($this->workspaceId, $this->selectedAgentId);
} catch (\Throwable) {
return [
'agent_id' => $this->selectedAgentId,
'balance' => 0,
'entries' => 0,
];
}
}
#[Computed]
public function transactions(): array
{
if ($this->workspaceId <= 0 || $this->selectedAgentId === '') {
return [];
}
try {
return GetCreditHistory::run($this->workspaceId, $this->selectedAgentId, $this->historyLimit)
->map(static fn (CreditEntry $entry): array => [
'id' => $entry->id,
'task_type' => $entry->task_type,
'amount' => $entry->amount,
'balance_after' => $entry->balance_after,
'description' => $entry->description,
'created_at' => $entry->created_at?->toDateTimeString(),
])
->values()
->all();
} catch (\Throwable) {
return [];
}
}
#[Computed]
public function totals(): array
{
$transactions = collect($this->transactions);
return [
'credits_awarded' => (int) $transactions->where('amount', '>', 0)->sum('amount'),
'credits_deducted' => (int) abs($transactions->where('amount', '<', 0)->sum('amount')),
'entries_visible' => $transactions->count(),
];
}
public function refundCredits(): void
{
$this->validateAdjustment();
AwardCredits::run(
$this->workspaceId,
$this->selectedAgentId,
'manual-refund',
abs($this->adjustmentAmount),
null,
$this->adjustmentReason !== '' ? $this->adjustmentReason : 'Manual refund via admin ledger',
);
$this->resetAdjustment();
$this->refreshLedger();
$this->toast('Credits Refunded', "Added credit to {$this->selectedAgentId}.", 'success');
}
public function deductCredits(): void
{
$this->validateAdjustment();
AwardCredits::run(
$this->workspaceId,
$this->selectedAgentId,
'manual-deduction',
-abs($this->adjustmentAmount),
null,
$this->adjustmentReason !== '' ? $this->adjustmentReason : 'Manual deduction via admin ledger',
);
$this->resetAdjustment();
$this->refreshLedger();
$this->toast('Credits Deducted', "Deducted credit from {$this->selectedAgentId}.", 'warning');
}
public function refreshLedger(): void
{
unset($this->agents, $this->balance, $this->transactions, $this->totals);
$this->syncSelectedAgentId();
$this->dispatch('notify', message: 'Credit ledger refreshed');
}
public function amountBadgeVariant(int $amount): string
{
return $amount >= 0 ? 'success' : 'danger';
}
public function render(): View
{
return view()->file($this->viewPath());
}
private function validateAdjustment(): void
{
$this->validate([
'workspaceId' => 'required|integer|min:1',
'selectedAgentId' => 'required|string|max:255',
'adjustmentAmount' => 'required|integer|min:1|max:100000',
'adjustmentReason' => 'nullable|string|max:1000',
]);
}
private function resetAdjustment(): void
{
$this->adjustmentAmount = 1;
$this->adjustmentReason = '';
}
private function syncSelectedAgentId(): void
{
if ($this->selectedAgentId !== '' && collect($this->agents)->contains('agent_id', $this->selectedAgentId)) {
return;
}
$this->selectedAgentId = (string) (collect($this->agents)->first()['agent_id'] ?? '');
}
private function resolveWorkspaceId(): int
{
try {
return (int) (Workspace::query()->value('id') ?? 0);
} catch (\Throwable) {
return 0;
}
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades access required');
}
}
private function toast(string $heading, string $text, string $variant): void
{
if (class_exists(Flux::class)) {
Flux::toast(
heading: $heading,
text: $text,
variant: $variant,
);
return;
}
$this->dispatch('notify', message: $text, variant: $variant);
}
private function viewPath(): string
{
return __DIR__.'/../../resources/views/livewire/agentic/credit-ledger.blade.php';
}
}

View file

@ -0,0 +1,263 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Livewire;
use Core\Mod\Agentic\Actions\Fleet\AssignTask;
use Core\Mod\Agentic\Actions\Fleet\GetFleetStats;
use Core\Mod\Agentic\Actions\Fleet\ListNodes;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Tenant\Models\Workspace;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Fleet Overview')]
#[Layout('hub::admin.layouts.app')]
class FleetOverview extends Component
{
public int $workspaceId = 0;
public string $statusFilter = '';
public string $platformFilter = '';
public string $dispatchAgentId = '';
public string $dispatchRepo = '';
public string $dispatchTask = '';
public string $dispatchBranch = 'dev';
public string $dispatchTemplate = '';
public string $dispatchModel = '';
public function mount(?int $workspaceId = null): void
{
$this->checkHadesAccess();
$this->workspaceId = $workspaceId ?? $this->resolveWorkspaceId();
$this->syncDispatchAgentId();
}
#[Computed]
public function nodes(): array
{
if ($this->workspaceId <= 0) {
return [];
}
try {
return ListNodes::run($this->workspaceId, $this->statusFilter, $this->platformFilter)
->load('currentTask')
->map(function (FleetNode $node): array {
$currentTask = $node->currentTask;
return [
'id' => $node->id,
'agent_id' => $node->agent_id,
'platform' => $node->platform,
'models' => $node->models ?? [],
'capabilities' => $node->capabilities ?? [],
'status' => $node->status,
'current_task_id' => $node->current_task_id,
'current_task_label' => $currentTask?->repo ?? ($node->current_task_id ? 'Task #'.$node->current_task_id : 'Idle'),
'compute_budget' => $node->compute_budget ?? [],
'compute_budget_label' => $this->summariseBudget($node->compute_budget ?? []),
'last_heartbeat_at' => $node->last_heartbeat_at?->toDateTimeString(),
'last_heartbeat_human' => $node->last_heartbeat_at?->diffForHumans() ?? 'Never',
];
})
->values()
->all();
} catch (\Throwable) {
return [];
}
}
#[Computed]
public function stats(): array
{
if ($this->workspaceId <= 0) {
return [
'nodes_online' => 0,
'tasks_today' => 0,
'tasks_week' => 0,
'repos_touched' => 0,
'findings_total' => 0,
'compute_hours' => 0,
'nodes_total' => 0,
'nodes_busy' => 0,
'nodes_idle' => 0,
];
}
try {
$stats = GetFleetStats::run($this->workspaceId);
} catch (\Throwable) {
$stats = [
'nodes_online' => 0,
'tasks_today' => 0,
'tasks_week' => 0,
'repos_touched' => 0,
'findings_total' => 0,
'compute_hours' => 0,
];
}
$nodes = collect($this->nodes);
return $stats + [
'nodes_total' => $nodes->count(),
'nodes_busy' => $nodes->where('status', FleetNode::STATUS_BUSY)->count(),
'nodes_idle' => $nodes->where('status', FleetNode::STATUS_ONLINE)->count(),
];
}
#[Computed]
public function platforms(): array
{
if ($this->workspaceId <= 0) {
return [];
}
try {
return FleetNode::query()
->where('workspace_id', $this->workspaceId)
->orderBy('platform')
->pluck('platform')
->filter(static fn (mixed $platform): bool => is_string($platform) && $platform !== '')
->unique()
->values()
->all();
} catch (\Throwable) {
return [];
}
}
public function stageDispatch(string $agentId): void
{
$this->dispatchAgentId = $agentId;
$this->toast('Dispatch Ready', "Prepared dispatch for {$agentId}.", 'info');
}
public function dispatchTask(): void
{
$this->validate([
'workspaceId' => 'required|integer|min:1',
'dispatchAgentId' => 'required|string|max:255',
'dispatchRepo' => 'required|string|max:255',
'dispatchTask' => 'required|string|max:10000',
'dispatchBranch' => 'nullable|string|max:255',
'dispatchTemplate' => 'nullable|string|max:255',
'dispatchModel' => 'nullable|string|max:255',
]);
AssignTask::run(
$this->workspaceId,
$this->dispatchAgentId,
$this->dispatchTask,
$this->dispatchRepo,
$this->dispatchTemplate !== '' ? $this->dispatchTemplate : null,
$this->dispatchBranch !== '' ? $this->dispatchBranch : null,
$this->dispatchModel !== '' ? $this->dispatchModel : null,
);
$agentId = $this->dispatchAgentId;
$this->dispatchTask = '';
$this->dispatchTemplate = '';
$this->dispatchModel = '';
$this->refreshOverview();
$this->toast('Task Dispatched', "Queued work for {$agentId}.", 'success');
}
public function refreshOverview(): void
{
unset($this->nodes, $this->stats, $this->platforms);
$this->syncDispatchAgentId();
$this->dispatch('notify', message: 'Fleet overview refreshed');
}
public function statusBadgeVariant(string $status): string
{
return match ($status) {
FleetNode::STATUS_BUSY => 'warning',
FleetNode::STATUS_ONLINE => 'success',
FleetNode::STATUS_PAUSED => 'zinc',
default => 'danger',
};
}
public function render(): View
{
return view()->file($this->viewPath());
}
private function resolveWorkspaceId(): int
{
try {
return (int) (Workspace::query()->value('id') ?? 0);
} catch (\Throwable) {
return 0;
}
}
private function syncDispatchAgentId(): void
{
if ($this->dispatchAgentId !== '' && collect($this->nodes)->contains('agent_id', $this->dispatchAgentId)) {
return;
}
$this->dispatchAgentId = (string) (collect($this->nodes)->first()['agent_id'] ?? '');
}
/**
* @param array<string, mixed> $budget
*/
private function summariseBudget(array $budget): string
{
if ($budget === []) {
return 'Not set';
}
return collect($budget)
->map(static fn (mixed $value, string|int $key): string => sprintf('%s: %s', (string) $key, is_scalar($value) ? (string) $value : json_encode($value)))
->implode(', ');
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades access required');
}
}
private function toast(string $heading, string $text, string $variant): void
{
if (class_exists(Flux::class)) {
Flux::toast(
heading: $heading,
text: $text,
variant: $variant,
);
return;
}
$this->dispatch('notify', message: $text, variant: $variant);
}
private function viewPath(): string
{
return __DIR__.'/../../resources/views/livewire/agentic/fleet-overview.blade.php';
}
}

View file

@ -0,0 +1,125 @@
{{-- SPDX-License-Identifier: EUPL-1.2 --}}
<div class="space-y-6">
<flux:card class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<flux:heading size="lg">Brain Explorer</flux:heading>
<flux:text>Search the OpenBrain corpus, inspect recall results, and forget stale entries.</flux:text>
</div>
<flux:button type="button" variant="ghost" wire:click="refreshExplorer">
Refresh
</flux:button>
</div>
<form class="space-y-4" wire:submit="searchMemories">
<div class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_repeat(3,minmax(0,1fr))]">
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Search Query</flux:text>
<flux:input wire:model="query" placeholder="How does dispatch history work?" />
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Type</flux:text>
<flux:select wire:model="typeFilter">
<option value="">All types</option>
@foreach ($this->memoryTypes as $type)
<option value="{{ $type }}">{{ ucfirst($type) }}</option>
@endforeach
</flux:select>
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Project</flux:text>
<flux:input wire:model="projectFilter" placeholder="core-agent" />
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Agent</flux:text>
<flux:select wire:model="agentFilter">
<option value="">All agents</option>
@foreach ($this->availableAgents as $agent)
<option value="{{ $agent }}">{{ $agent }}</option>
@endforeach
</flux:select>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<flux:badge color="{{ $usedFallbackSearch ? 'warning' : 'success' }}">
{{ $usedFallbackSearch ? 'DB fallback search' : 'Semantic recall' }}
</flux:badge>
<flux:text>{{ count($results) }} result{{ count($results) === 1 ? '' : 's' }}</flux:text>
</div>
<flux:button type="submit" variant="primary">
Search Brain
</flux:button>
</div>
</form>
</flux:card>
<div class="space-y-4">
@forelse ($results as $result)
<flux:card class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<flux:badge color="{{ $this->typeBadgeVariant($result['type']) }}">
{{ strtoupper($result['type']) }}
</flux:badge>
@if (! is_null($result['score']))
<flux:badge color="zinc">
Score {{ number_format((float) $result['score'], 3) }}
</flux:badge>
@endif
<flux:badge color="zinc">
{{ $result['agent_id'] }}
</flux:badge>
</div>
<flux:text class="leading-7 text-zinc-800">
{{ $result['content'] }}
</flux:text>
</div>
<flux:button type="button" variant="danger" wire:click="forgetMemory('{{ $result['id'] }}')">
Forget
</flux:button>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
@if (! empty($result['project']))
<span>Project: {{ $result['project'] }}</span>
@endif
@if (! empty($result['org']))
<span>Organisation: {{ $result['org'] }}</span>
@endif
<span>Confidence: {{ number_format((float) $result['confidence'], 2) }}</span>
@if (! empty($result['created_at']))
<span>Created: {{ $result['created_at'] }}</span>
@endif
</div>
@if ($result['tags'] !== [])
<div class="flex flex-wrap gap-2">
@foreach ($result['tags'] as $tag)
<flux:badge color="zinc">{{ $tag }}</flux:badge>
@endforeach
</div>
@endif
</flux:card>
@empty
<flux:card>
<flux:text>No memories found for the current search.</flux:text>
</flux:card>
@endforelse
</div>
</div>

View file

@ -0,0 +1,121 @@
{{-- SPDX-License-Identifier: EUPL-1.2 --}}
<div class="space-y-6">
<flux:card class="space-y-2">
<flux:heading size="lg">Credit Ledger</flux:heading>
<flux:text>Balance management, transaction history, and manual credit adjustments for fleet nodes.</flux:text>
</flux:card>
<div class="grid gap-4 lg:grid-cols-[minmax(0,20rem)_repeat(3,minmax(0,1fr))]">
<flux:card class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Agent</flux:text>
<flux:select wire:model.live="selectedAgentId">
<option value="">Select an agent</option>
@foreach ($this->agents as $agent)
<option value="{{ $agent['agent_id'] }}">{{ $agent['agent_id'] }} · {{ $agent['platform'] }}</option>
@endforeach
</flux:select>
</flux:card>
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Balance</flux:text>
<flux:heading size="xl">{{ number_format((int) ($this->balance['balance'] ?? 0)) }}</flux:heading>
<flux:text>Current balance for {{ $selectedAgentId !== '' ? $selectedAgentId : 'no agent selected' }}</flux:text>
</flux:card>
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Credits Awarded</flux:text>
<flux:heading size="xl">{{ number_format($this->totals['credits_awarded']) }}</flux:heading>
<flux:text>Visible positive entries</flux:text>
</flux:card>
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Credits Deducted</flux:text>
<flux:heading size="xl">{{ number_format($this->totals['credits_deducted']) }}</flux:heading>
<flux:text>{{ number_format((int) ($this->balance['entries'] ?? 0)) }} total ledger entries</flux:text>
</flux:card>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(22rem,26rem)_minmax(0,1fr)]">
<flux:card class="space-y-4">
<div>
<flux:heading size="lg">Manual Adjustment</flux:heading>
<flux:text>Deduct or refund credit for the selected agent.</flux:text>
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Amount</flux:text>
<flux:input type="number" min="1" wire:model="adjustmentAmount" />
@error('adjustmentAmount') <div class="text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Reason</flux:text>
<flux:textarea wire:model="adjustmentReason" rows="6" placeholder="Explain why the ledger is being adjusted." />
@error('adjustmentReason') <div class="text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="flex flex-wrap items-center gap-3">
<flux:button type="button" variant="danger" wire:click="deductCredits">
Deduct Credits
</flux:button>
<flux:button type="button" variant="primary" wire:click="refundCredits">
Refund Credits
</flux:button>
<flux:button type="button" variant="ghost" wire:click="refreshLedger">
Refresh
</flux:button>
</div>
</flux:card>
<flux:card class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<flux:heading size="lg">Transaction Ledger</flux:heading>
<flux:text>Recent credit activity for the selected node.</flux:text>
</div>
<flux:badge color="zinc">
Showing {{ $this->totals['entries_visible'] }} entries
</flux:badge>
</div>
<div class="overflow-x-auto rounded-2xl border border-zinc-200 bg-white">
<table class="min-w-full divide-y divide-zinc-200 text-left text-sm">
<thead class="bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500">
<tr>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Amount</th>
<th class="px-4 py-3">Balance After</th>
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100">
@forelse ($this->transactions as $transaction)
<tr>
<td class="px-4 py-3 font-medium text-zinc-900">{{ $transaction['task_type'] }}</td>
<td class="px-4 py-3">
<flux:badge color="{{ $this->amountBadgeVariant((int) $transaction['amount']) }}">
{{ (int) $transaction['amount'] > 0 ? '+' : '' }}{{ number_format((int) $transaction['amount']) }}
</flux:badge>
</td>
<td class="px-4 py-3 text-zinc-700">{{ number_format((int) $transaction['balance_after']) }}</td>
<td class="px-4 py-3 text-zinc-700">{{ $transaction['description'] ?: 'No description' }}</td>
<td class="px-4 py-3 text-zinc-500">{{ $transaction['created_at'] ?: 'Pending' }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-zinc-500">
No credit transactions recorded for this agent.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</flux:card>
</div>
</div>

View file

@ -0,0 +1,184 @@
{{-- SPDX-License-Identifier: EUPL-1.2 --}}
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Fleet Nodes</flux:text>
<flux:heading size="xl">{{ number_format($this->stats['nodes_total']) }}</flux:heading>
<flux:text>{{ number_format($this->stats['nodes_online']) }} online, {{ number_format($this->stats['nodes_busy']) }} busy</flux:text>
</flux:card>
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Dispatch Volume</flux:text>
<flux:heading size="xl">{{ number_format($this->stats['tasks_today']) }}</flux:heading>
<flux:text>{{ number_format($this->stats['tasks_week']) }} tasks in the last 7 days</flux:text>
</flux:card>
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Repos Touched</flux:text>
<flux:heading size="xl">{{ number_format($this->stats['repos_touched']) }}</flux:heading>
<flux:text>{{ number_format($this->stats['findings_total']) }} findings captured</flux:text>
</flux:card>
<flux:card class="space-y-1">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Compute Hours</flux:text>
<flux:heading size="xl">{{ number_format($this->stats['compute_hours']) }}</flux:heading>
<flux:text>{{ number_format($this->stats['nodes_idle']) }} nodes currently idle</flux:text>
</flux:card>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(24rem,1fr)]">
<flux:card class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<flux:heading size="lg">Fleet Overview</flux:heading>
<flux:text>Node health, activity state, and dispatch readiness.</flux:text>
</div>
<flux:button type="button" variant="ghost" wire:click="refreshOverview">
Refresh
</flux:button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Status Filter</flux:text>
<flux:select wire:model.live="statusFilter">
<option value="">All statuses</option>
<option value="online">Online</option>
<option value="busy">Busy</option>
<option value="paused">Paused</option>
<option value="offline">Offline</option>
</flux:select>
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Platform Filter</flux:text>
<flux:select wire:model.live="platformFilter">
<option value="">All platforms</option>
@foreach ($this->platforms as $platform)
<option value="{{ $platform }}">{{ $platform }}</option>
@endforeach
</flux:select>
</div>
</div>
<div class="overflow-x-auto rounded-2xl border border-zinc-200 bg-white">
<table class="min-w-full divide-y divide-zinc-200 text-left text-sm">
<thead class="bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500">
<tr>
<th class="px-4 py-3">Node</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Current Task</th>
<th class="px-4 py-3">Budget</th>
<th class="px-4 py-3">Heartbeat</th>
<th class="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100">
@forelse ($this->nodes as $node)
<tr class="align-top">
<td class="px-4 py-3">
<div class="font-medium text-zinc-900">{{ $node['agent_id'] }}</div>
<div class="mt-1 text-xs text-zinc-500">{{ $node['platform'] }}</div>
@if ($node['models'] !== [])
<div class="mt-2 flex flex-wrap gap-1">
@foreach ($node['models'] as $model)
<flux:badge color="zinc">{{ $model }}</flux:badge>
@endforeach
</div>
@endif
</td>
<td class="px-4 py-3">
<flux:badge color="{{ $this->statusBadgeVariant($node['status']) }}">
{{ strtoupper($node['status']) }}
</flux:badge>
</td>
<td class="px-4 py-3 text-zinc-700">
{{ $node['current_task_label'] }}
</td>
<td class="px-4 py-3 text-zinc-700">
{{ $node['compute_budget_label'] }}
</td>
<td class="px-4 py-3">
<div class="text-zinc-900">{{ $node['last_heartbeat_human'] }}</div>
@if ($node['last_heartbeat_at'])
<div class="mt-1 text-xs text-zinc-500">{{ $node['last_heartbeat_at'] }}</div>
@endif
</td>
<td class="px-4 py-3 text-right">
<flux:button type="button" variant="primary" wire:click="stageDispatch('{{ $node['agent_id'] }}')">
Dispatch
</flux:button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-sm text-zinc-500">
No fleet nodes match the current filters.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</flux:card>
<flux:card class="space-y-4">
<div>
<flux:heading size="lg">Dispatch Task</flux:heading>
<flux:text>Assign work to a specific node without leaving the fleet view.</flux:text>
</div>
<form class="space-y-4" wire:submit="dispatchTask">
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Agent</flux:text>
<flux:select wire:model="dispatchAgentId">
<option value="">Select a node</option>
@foreach ($this->nodes as $node)
<option value="{{ $node['agent_id'] }}">{{ $node['agent_id'] }} · {{ $node['platform'] }}</option>
@endforeach
</flux:select>
@error('dispatchAgentId') <div class="text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Repository</flux:text>
<flux:input wire:model="dispatchRepo" placeholder="dAppCore/core-agent" />
@error('dispatchRepo') <div class="text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Branch</flux:text>
<flux:input wire:model="dispatchBranch" placeholder="dev" />
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Template</flux:text>
<flux:input wire:model="dispatchTemplate" placeholder="bugfix" />
</div>
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Agent Model</flux:text>
<flux:input wire:model="dispatchModel" placeholder="gpt-5.5" />
</div>
<div class="space-y-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500">Task</flux:text>
<flux:textarea wire:model="dispatchTask" rows="8" placeholder="Describe the work item, constraints, and expected output." />
@error('dispatchTask') <div class="text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="flex items-center justify-between gap-3">
<flux:text>Selected node: {{ $dispatchAgentId !== '' ? $dispatchAgentId : 'none' }}</flux:text>
<flux:button type="submit" variant="primary">
Dispatch Task
</flux:button>
</div>
</form>
</flux:card>
</div>
</div>

View file

@ -0,0 +1,136 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Blade;
use Livewire\Livewire;
uses(\Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase::class);
if (! function_exists('prepareAgenticLivewireHarness')) {
function prepareAgenticLivewireHarness(): void
{
$base = sys_get_temp_dir().'/agentic-livewire-stubs';
$componentPath = $base.'/components';
$hubPath = $base.'/hub/admin/layouts';
if (! is_dir($componentPath)) {
mkdir($componentPath, 0777, true);
}
if (! is_dir($hubPath)) {
mkdir($hubPath, 0777, true);
}
file_put_contents($hubPath.'/app.blade.php', '{{ $slot }}');
$stubs = [
'badge.blade.php' => '<span {{ $attributes }}>{{ $slot }}</span>',
'button.blade.php' => '<button {{ $attributes }}>{{ $slot }}</button>',
'card.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'heading.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'input.blade.php' => '<input {{ $attributes }} />',
'select.blade.php' => '<select {{ $attributes }}>{{ $slot }}</select>',
'text.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'textarea.blade.php' => '<textarea {{ $attributes }}>{{ $slot }}</textarea>',
];
foreach ($stubs as $file => $contents) {
file_put_contents($componentPath.'/'.$file, $contents);
}
Blade::anonymousComponentPath($componentPath, 'flux');
app('view')->addNamespace('hub', $base.'/hub');
}
}
if (! function_exists('loadAgenticLivewireComponent')) {
function loadAgenticLivewireComponent(string $component): string
{
$phpRoot = dirname(__DIR__, 4);
require_once $phpRoot."/Agentic/Livewire/{$component}.php";
return "Core\\Mod\\Agentic\\Livewire\\{$component}";
}
}
beforeEach(function (): void {
prepareAgenticLivewireHarness();
$this->actingAsHades();
});
it('wires brain actions and flux blade controls', function (): void {
$phpRoot = dirname(__DIR__, 4);
$componentSource = file_get_contents($phpRoot.'/Agentic/Livewire/BrainExplorer.php');
$bladeSource = file_get_contents($phpRoot.'/resources/views/livewire/agentic/brain-explorer.blade.php');
expect($componentSource)
->toContain('ForgetKnowledge')
->toContain('ListKnowledge')
->toContain('RecallKnowledge');
expect($bladeSource)
->toContain('<flux:card')
->toContain('wire:submit="searchMemories"')
->toContain('wire:click="forgetMemory');
});
it('renders recent memories when no query is provided', function (): void {
$component = loadAgenticLivewireComponent('BrainExplorer');
$workspace = createWorkspace();
BrainMemory::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'decision',
'content' => 'Dispatch decisions are stored in the queue log.',
'confidence' => 0.9,
'tags' => ['dispatch', 'queue'],
]);
Livewire::test($component, ['workspaceId' => $workspace->id])
->assertSee('Brain Explorer')
->assertSee('Dispatch decisions are stored in the queue log.')
->assertSee('virgil');
});
it('falls back to database search and forgets memories', function (): void {
$component = loadAgenticLivewireComponent('BrainExplorer');
$workspace = createWorkspace();
$memory = BrainMemory::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'context',
'content' => 'Dispatch queue memory for local fallback search.',
'confidence' => 0.7,
'tags' => ['dispatch'],
]);
app()->instance(BrainService::class, new class extends BrainService
{
public function recall(
string $query,
int $topK,
array $filter,
int $workspaceId,
array $keywords = [],
array $boostKeywords = [],
): array {
throw new RuntimeException('Brain backend offline');
}
});
Livewire::test($component, ['workspaceId' => $workspace->id])
->set('query', 'dispatch queue')
->call('searchMemories')
->assertSee('Dispatch queue memory for local fallback search.')
->call('forgetMemory', $memory->id)
->assertDontSee('Dispatch queue memory for local fallback search.');
expect(BrainMemory::withTrashed()->find($memory->id)?->deleted_at)->not->toBeNull();
});

View file

@ -0,0 +1,146 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
use Core\Mod\Agentic\Actions\Credits\GetBalance;
use Core\Mod\Agentic\Models\FleetNode;
use Illuminate\Support\Facades\Blade;
use Livewire\Livewire;
use function Pest\Laravel\assertDatabaseHas;
uses(\Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase::class);
if (! function_exists('prepareAgenticLivewireHarness')) {
function prepareAgenticLivewireHarness(): void
{
$base = sys_get_temp_dir().'/agentic-livewire-stubs';
$componentPath = $base.'/components';
$hubPath = $base.'/hub/admin/layouts';
if (! is_dir($componentPath)) {
mkdir($componentPath, 0777, true);
}
if (! is_dir($hubPath)) {
mkdir($hubPath, 0777, true);
}
file_put_contents($hubPath.'/app.blade.php', '{{ $slot }}');
$stubs = [
'badge.blade.php' => '<span {{ $attributes }}>{{ $slot }}</span>',
'button.blade.php' => '<button {{ $attributes }}>{{ $slot }}</button>',
'card.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'heading.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'input.blade.php' => '<input {{ $attributes }} />',
'select.blade.php' => '<select {{ $attributes }}>{{ $slot }}</select>',
'text.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'textarea.blade.php' => '<textarea {{ $attributes }}>{{ $slot }}</textarea>',
];
foreach ($stubs as $file => $contents) {
file_put_contents($componentPath.'/'.$file, $contents);
}
Blade::anonymousComponentPath($componentPath, 'flux');
app('view')->addNamespace('hub', $base.'/hub');
}
}
if (! function_exists('loadAgenticLivewireComponent')) {
function loadAgenticLivewireComponent(string $component): string
{
$phpRoot = dirname(__DIR__, 4);
require_once $phpRoot."/Agentic/Livewire/{$component}.php";
return "Core\\Mod\\Agentic\\Livewire\\{$component}";
}
}
beforeEach(function (): void {
prepareAgenticLivewireHarness();
$this->actingAsHades();
});
it('wires credit actions and flux blade controls', function (): void {
$phpRoot = dirname(__DIR__, 4);
$componentSource = file_get_contents($phpRoot.'/Agentic/Livewire/CreditLedger.php');
$bladeSource = file_get_contents($phpRoot.'/resources/views/livewire/agentic/credit-ledger.blade.php');
expect($componentSource)
->toContain('AwardCredits')
->toContain('GetBalance')
->toContain('GetCreditHistory');
expect($bladeSource)
->toContain('<flux:card')
->toContain('wire:click="deductCredits"')
->toContain('wire:click="refundCredits"');
});
it('renders balance and transaction history for the selected agent', function (): void {
$component = loadAgenticLivewireComponent('CreditLedger');
$workspace = createWorkspace();
FleetNode::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'platform' => 'darwin',
'status' => FleetNode::STATUS_ONLINE,
'registered_at' => now(),
'last_heartbeat_at' => now(),
]);
AwardCredits::run($workspace->id, 'alpha', 'manual-refund', 5, null, 'Initial award');
Livewire::test($component, ['workspaceId' => $workspace->id])
->assertSee('Credit Ledger')
->assertSee('alpha')
->assertSee('Initial award')
->assertSee('5');
});
it('refunds and deducts credits through the ledger actions', function (): void {
$component = loadAgenticLivewireComponent('CreditLedger');
$workspace = createWorkspace();
FleetNode::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'platform' => 'darwin',
'status' => FleetNode::STATUS_ONLINE,
'registered_at' => now(),
'last_heartbeat_at' => now(),
]);
Livewire::test($component, ['workspaceId' => $workspace->id])
->set('selectedAgentId', 'alpha')
->set('adjustmentAmount', 3)
->set('adjustmentReason', 'Manual refund')
->call('refundCredits')
->assertHasNoErrors()
->set('adjustmentAmount', 2)
->set('adjustmentReason', 'Manual deduction')
->call('deductCredits')
->assertHasNoErrors();
assertDatabaseHas('credit_entries', [
'workspace_id' => $workspace->id,
'task_type' => 'manual-refund',
'amount' => 3,
'description' => 'Manual refund',
]);
assertDatabaseHas('credit_entries', [
'workspace_id' => $workspace->id,
'task_type' => 'manual-deduction',
'amount' => -2,
'description' => 'Manual deduction',
]);
expect(GetBalance::run($workspace->id, 'alpha')['balance'])->toBe(1);
});

View file

@ -0,0 +1,152 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Models\FleetNode;
use Illuminate\Support\Facades\Blade;
use Livewire\Livewire;
use function Pest\Laravel\assertDatabaseHas;
uses(\Core\Mod\Agentic\Tests\Feature\Livewire\LivewireTestCase::class);
if (! function_exists('prepareAgenticLivewireHarness')) {
function prepareAgenticLivewireHarness(): void
{
$base = sys_get_temp_dir().'/agentic-livewire-stubs';
$componentPath = $base.'/components';
$hubPath = $base.'/hub/admin/layouts';
if (! is_dir($componentPath)) {
mkdir($componentPath, 0777, true);
}
if (! is_dir($hubPath)) {
mkdir($hubPath, 0777, true);
}
file_put_contents($hubPath.'/app.blade.php', '{{ $slot }}');
$stubs = [
'badge.blade.php' => '<span {{ $attributes }}>{{ $slot }}</span>',
'button.blade.php' => '<button {{ $attributes }}>{{ $slot }}</button>',
'card.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'heading.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'input.blade.php' => '<input {{ $attributes }} />',
'select.blade.php' => '<select {{ $attributes }}>{{ $slot }}</select>',
'text.blade.php' => '<div {{ $attributes }}>{{ $slot }}</div>',
'textarea.blade.php' => '<textarea {{ $attributes }}>{{ $slot }}</textarea>',
];
foreach ($stubs as $file => $contents) {
file_put_contents($componentPath.'/'.$file, $contents);
}
Blade::anonymousComponentPath($componentPath, 'flux');
app('view')->addNamespace('hub', $base.'/hub');
}
}
if (! function_exists('loadAgenticLivewireComponent')) {
function loadAgenticLivewireComponent(string $component): string
{
$phpRoot = dirname(__DIR__, 4);
require_once $phpRoot."/Agentic/Livewire/{$component}.php";
return "Core\\Mod\\Agentic\\Livewire\\{$component}";
}
}
beforeEach(function (): void {
prepareAgenticLivewireHarness();
$this->actingAsHades();
});
it('wires fleet actions and flux blade controls', function (): void {
$phpRoot = dirname(__DIR__, 4);
$componentSource = file_get_contents($phpRoot.'/Agentic/Livewire/FleetOverview.php');
$bladeSource = file_get_contents($phpRoot.'/resources/views/livewire/agentic/fleet-overview.blade.php');
expect($componentSource)
->toContain('AssignTask')
->toContain('GetFleetStats')
->toContain('ListNodes');
expect($bladeSource)
->toContain('<flux:card')
->toContain('wire:click="stageDispatch')
->toContain('wire:submit="dispatchTask"');
});
it('renders node list and stats for hades users', function (): void {
$component = loadAgenticLivewireComponent('FleetOverview');
$workspace = createWorkspace();
FleetNode::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'platform' => 'darwin',
'models' => ['gpt-5.5'],
'status' => FleetNode::STATUS_ONLINE,
'registered_at' => now(),
'last_heartbeat_at' => now(),
]);
FleetNode::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'beta',
'platform' => 'linux',
'models' => ['gpt-5.4-mini'],
'status' => FleetNode::STATUS_BUSY,
'registered_at' => now(),
'last_heartbeat_at' => now(),
]);
Livewire::test($component, ['workspaceId' => $workspace->id])
->assertSee('Fleet Overview')
->assertSee('Dispatch Task')
->assertSee('alpha')
->assertSee('beta');
});
it('dispatches a task to the selected node', function (): void {
$component = loadAgenticLivewireComponent('FleetOverview');
$workspace = createWorkspace();
FleetNode::query()->create([
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'platform' => 'darwin',
'models' => ['gpt-5.5'],
'status' => FleetNode::STATUS_ONLINE,
'registered_at' => now(),
'last_heartbeat_at' => now(),
]);
Livewire::test($component, ['workspaceId' => $workspace->id])
->set('dispatchAgentId', 'alpha')
->set('dispatchRepo', 'dAppCore/core-agent')
->set('dispatchBranch', 'dev')
->set('dispatchTemplate', 'triage')
->set('dispatchModel', 'gpt-5.5')
->set('dispatchTask', 'Review the dispatch backlog and prepare the next assignment.')
->call('dispatchTask')
->assertHasNoErrors();
assertDatabaseHas('fleet_tasks', [
'workspace_id' => $workspace->id,
'repo' => 'dAppCore/core-agent',
'branch' => 'dev',
'template' => 'triage',
'agent_model' => 'gpt-5.5',
'status' => 'assigned',
]);
assertDatabaseHas('fleet_nodes', [
'workspace_id' => $workspace->id,
'agent_id' => 'alpha',
'status' => FleetNode::STATUS_BUSY,
]);
});