From 40dccb2a1436e84d3c760b00c98bbb2ec2cfff29 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 05:16:50 +0100 Subject: [PATCH] =?UTF-8?q?feat(agentic):=20implement=20=C2=A711=20Admin?= =?UTF-8?q?=20UI=20Livewire=20components=20(FleetOverview=20+=20BrainExplo?= =?UTF-8?q?rer=20+=20CreditLedger)=20(#850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes tasks.lthn.sh/view.php?id=850 --- php/Agentic/Livewire/BrainExplorer.php | 282 ++++++++++++++++++ php/Agentic/Livewire/CreditLedger.php | 239 +++++++++++++++ php/Agentic/Livewire/FleetOverview.php | 263 ++++++++++++++++ .../livewire/agentic/brain-explorer.blade.php | 125 ++++++++ .../livewire/agentic/credit-ledger.blade.php | 121 ++++++++ .../livewire/agentic/fleet-overview.blade.php | 184 ++++++++++++ .../Agentic/Livewire/BrainExplorerTest.php | 136 +++++++++ .../Agentic/Livewire/CreditLedgerTest.php | 146 +++++++++ .../Agentic/Livewire/FleetOverviewTest.php | 152 ++++++++++ 9 files changed, 1648 insertions(+) create mode 100644 php/Agentic/Livewire/BrainExplorer.php create mode 100644 php/Agentic/Livewire/CreditLedger.php create mode 100644 php/Agentic/Livewire/FleetOverview.php create mode 100644 php/resources/views/livewire/agentic/brain-explorer.blade.php create mode 100644 php/resources/views/livewire/agentic/credit-ledger.blade.php create mode 100644 php/resources/views/livewire/agentic/fleet-overview.blade.php create mode 100644 php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php create mode 100644 php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php create mode 100644 php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php diff --git a/php/Agentic/Livewire/BrainExplorer.php b/php/Agentic/Livewire/BrainExplorer.php new file mode 100644 index 0000000..d277194 --- /dev/null +++ b/php/Agentic/Livewire/BrainExplorer.php @@ -0,0 +1,282 @@ +> + */ + 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 + */ + 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> + */ + 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|BrainMemory $memory + * @return array + */ + 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'; + } +} diff --git a/php/Agentic/Livewire/CreditLedger.php b/php/Agentic/Livewire/CreditLedger.php new file mode 100644 index 0000000..1326417 --- /dev/null +++ b/php/Agentic/Livewire/CreditLedger.php @@ -0,0 +1,239 @@ +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'; + } +} diff --git a/php/Agentic/Livewire/FleetOverview.php b/php/Agentic/Livewire/FleetOverview.php new file mode 100644 index 0000000..af78fad --- /dev/null +++ b/php/Agentic/Livewire/FleetOverview.php @@ -0,0 +1,263 @@ +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 $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'; + } +} diff --git a/php/resources/views/livewire/agentic/brain-explorer.blade.php b/php/resources/views/livewire/agentic/brain-explorer.blade.php new file mode 100644 index 0000000..95e0ed2 --- /dev/null +++ b/php/resources/views/livewire/agentic/brain-explorer.blade.php @@ -0,0 +1,125 @@ +{{-- SPDX-License-Identifier: EUPL-1.2 --}} + +
+ +
+
+ Brain Explorer + Search the OpenBrain corpus, inspect recall results, and forget stale entries. +
+ + + Refresh + +
+ +
+
+
+ Search Query + +
+ +
+ Type + + + @foreach ($this->memoryTypes as $type) + + @endforeach + +
+ +
+ Project + +
+ +
+ Agent + + + @foreach ($this->availableAgents as $agent) + + @endforeach + +
+
+ +
+
+ + {{ $usedFallbackSearch ? 'DB fallback search' : 'Semantic recall' }} + + {{ count($results) }} result{{ count($results) === 1 ? '' : 's' }} +
+ + + Search Brain + +
+
+
+ +
+ @forelse ($results as $result) + +
+
+
+ + {{ strtoupper($result['type']) }} + + + @if (! is_null($result['score'])) + + Score {{ number_format((float) $result['score'], 3) }} + + @endif + + + {{ $result['agent_id'] }} + +
+ + + {{ $result['content'] }} + +
+ + + Forget + +
+ +
+ @if (! empty($result['project'])) + Project: {{ $result['project'] }} + @endif + + @if (! empty($result['org'])) + Organisation: {{ $result['org'] }} + @endif + + Confidence: {{ number_format((float) $result['confidence'], 2) }} + + @if (! empty($result['created_at'])) + Created: {{ $result['created_at'] }} + @endif +
+ + @if ($result['tags'] !== []) +
+ @foreach ($result['tags'] as $tag) + {{ $tag }} + @endforeach +
+ @endif +
+ @empty + + No memories found for the current search. + + @endforelse +
+
diff --git a/php/resources/views/livewire/agentic/credit-ledger.blade.php b/php/resources/views/livewire/agentic/credit-ledger.blade.php new file mode 100644 index 0000000..b7b5023 --- /dev/null +++ b/php/resources/views/livewire/agentic/credit-ledger.blade.php @@ -0,0 +1,121 @@ +{{-- SPDX-License-Identifier: EUPL-1.2 --}} + +
+ + Credit Ledger + Balance management, transaction history, and manual credit adjustments for fleet nodes. + + +
+ + Agent + + + @foreach ($this->agents as $agent) + + @endforeach + + + + + Balance + {{ number_format((int) ($this->balance['balance'] ?? 0)) }} + Current balance for {{ $selectedAgentId !== '' ? $selectedAgentId : 'no agent selected' }} + + + + Credits Awarded + {{ number_format($this->totals['credits_awarded']) }} + Visible positive entries + + + + Credits Deducted + {{ number_format($this->totals['credits_deducted']) }} + {{ number_format((int) ($this->balance['entries'] ?? 0)) }} total ledger entries + +
+ +
+ +
+ Manual Adjustment + Deduct or refund credit for the selected agent. +
+ +
+ Amount + + @error('adjustmentAmount')
{{ $message }}
@enderror +
+ +
+ Reason + + @error('adjustmentReason')
{{ $message }}
@enderror +
+ +
+ + Deduct Credits + + + + Refund Credits + + + + Refresh + +
+
+ + +
+
+ Transaction Ledger + Recent credit activity for the selected node. +
+ + + Showing {{ $this->totals['entries_visible'] }} entries + +
+ +
+ + + + + + + + + + + + @forelse ($this->transactions as $transaction) + + + + + + + + @empty + + + + @endforelse + +
TypeAmountBalance AfterDescriptionCreated
{{ $transaction['task_type'] }} + + {{ (int) $transaction['amount'] > 0 ? '+' : '' }}{{ number_format((int) $transaction['amount']) }} + + {{ number_format((int) $transaction['balance_after']) }}{{ $transaction['description'] ?: 'No description' }}{{ $transaction['created_at'] ?: 'Pending' }}
+ No credit transactions recorded for this agent. +
+
+
+
+
diff --git a/php/resources/views/livewire/agentic/fleet-overview.blade.php b/php/resources/views/livewire/agentic/fleet-overview.blade.php new file mode 100644 index 0000000..e86f6a1 --- /dev/null +++ b/php/resources/views/livewire/agentic/fleet-overview.blade.php @@ -0,0 +1,184 @@ +{{-- SPDX-License-Identifier: EUPL-1.2 --}} + +
+
+ + Fleet Nodes + {{ number_format($this->stats['nodes_total']) }} + {{ number_format($this->stats['nodes_online']) }} online, {{ number_format($this->stats['nodes_busy']) }} busy + + + + Dispatch Volume + {{ number_format($this->stats['tasks_today']) }} + {{ number_format($this->stats['tasks_week']) }} tasks in the last 7 days + + + + Repos Touched + {{ number_format($this->stats['repos_touched']) }} + {{ number_format($this->stats['findings_total']) }} findings captured + + + + Compute Hours + {{ number_format($this->stats['compute_hours']) }} + {{ number_format($this->stats['nodes_idle']) }} nodes currently idle + +
+ +
+ +
+
+ Fleet Overview + Node health, activity state, and dispatch readiness. +
+ + + Refresh + +
+ +
+
+ Status Filter + + + + + + + +
+ +
+ Platform Filter + + + @foreach ($this->platforms as $platform) + + @endforeach + +
+
+ +
+ + + + + + + + + + + + + @forelse ($this->nodes as $node) + + + + + + + + + @empty + + + + @endforelse + +
NodeStatusCurrent TaskBudgetHeartbeatAction
+
{{ $node['agent_id'] }}
+
{{ $node['platform'] }}
+ @if ($node['models'] !== []) +
+ @foreach ($node['models'] as $model) + {{ $model }} + @endforeach +
+ @endif +
+ + {{ strtoupper($node['status']) }} + + + {{ $node['current_task_label'] }} + + {{ $node['compute_budget_label'] }} + +
{{ $node['last_heartbeat_human'] }}
+ @if ($node['last_heartbeat_at']) +
{{ $node['last_heartbeat_at'] }}
+ @endif +
+ + Dispatch + +
+ No fleet nodes match the current filters. +
+
+
+ + +
+ Dispatch Task + Assign work to a specific node without leaving the fleet view. +
+ +
+
+ Agent + + + @foreach ($this->nodes as $node) + + @endforeach + + @error('dispatchAgentId')
{{ $message }}
@enderror +
+ +
+ Repository + + @error('dispatchRepo')
{{ $message }}
@enderror +
+ +
+
+ Branch + +
+ +
+ Template + +
+
+ +
+ Agent Model + +
+ +
+ Task + + @error('dispatchTask')
{{ $message }}
@enderror +
+ +
+ Selected node: {{ $dispatchAgentId !== '' ? $dispatchAgentId : 'none' }} + + + Dispatch Task + +
+
+
+
+
diff --git a/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php b/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php new file mode 100644 index 0000000..0cc376e --- /dev/null +++ b/php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php @@ -0,0 +1,136 @@ + '{{ $slot }}', + 'button.blade.php' => '', + 'card.blade.php' => '
{{ $slot }}
', + 'heading.blade.php' => '
{{ $slot }}
', + 'input.blade.php' => '', + 'select.blade.php' => '', + 'text.blade.php' => '
{{ $slot }}
', + 'textarea.blade.php' => '', + ]; + + 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('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(); +}); diff --git a/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php b/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php new file mode 100644 index 0000000..0e21469 --- /dev/null +++ b/php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php @@ -0,0 +1,146 @@ + '{{ $slot }}', + 'button.blade.php' => '', + 'card.blade.php' => '
{{ $slot }}
', + 'heading.blade.php' => '
{{ $slot }}
', + 'input.blade.php' => '', + 'select.blade.php' => '', + 'text.blade.php' => '
{{ $slot }}
', + 'textarea.blade.php' => '', + ]; + + 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('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); +}); diff --git a/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php b/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php new file mode 100644 index 0000000..9093930 --- /dev/null +++ b/php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php @@ -0,0 +1,152 @@ + '{{ $slot }}', + 'button.blade.php' => '', + 'card.blade.php' => '
{{ $slot }}
', + 'heading.blade.php' => '
{{ $slot }}
', + 'input.blade.php' => '', + 'select.blade.php' => '', + 'text.blade.php' => '
{{ $slot }}
', + 'textarea.blade.php' => '', + ]; + + 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('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, + ]); +});