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:
parent
09054fbdab
commit
40dccb2a14
9 changed files with 1648 additions and 0 deletions
282
php/Agentic/Livewire/BrainExplorer.php
Normal file
282
php/Agentic/Livewire/BrainExplorer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
239
php/Agentic/Livewire/CreditLedger.php
Normal file
239
php/Agentic/Livewire/CreditLedger.php
Normal 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';
|
||||
}
|
||||
}
|
||||
263
php/Agentic/Livewire/FleetOverview.php
Normal file
263
php/Agentic/Livewire/FleetOverview.php
Normal 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';
|
||||
}
|
||||
}
|
||||
125
php/resources/views/livewire/agentic/brain-explorer.blade.php
Normal file
125
php/resources/views/livewire/agentic/brain-explorer.blade.php
Normal 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>
|
||||
121
php/resources/views/livewire/agentic/credit-ledger.blade.php
Normal file
121
php/resources/views/livewire/agentic/credit-ledger.blade.php
Normal 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>
|
||||
184
php/resources/views/livewire/agentic/fleet-overview.blade.php
Normal file
184
php/resources/views/livewire/agentic/fleet-overview.blade.php
Normal 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>
|
||||
136
php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php
Normal file
136
php/tests/Feature/Agentic/Livewire/BrainExplorerTest.php
Normal 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();
|
||||
});
|
||||
146
php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php
Normal file
146
php/tests/Feature/Agentic/Livewire/CreditLedgerTest.php
Normal 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);
|
||||
});
|
||||
152
php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php
Normal file
152
php/tests/Feature/Agentic/Livewire/FleetOverviewTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue