agent/php/Agentic/Livewire/CreditLedger.php
Snider 83df8ad71a fix(agent): address CodeRabbit + SonarCloud findings on PR #6
20+ CHANGES_REQUESTED dispositions across PHP MCP services, Go pkg/agentic,
hermes_runner_mcp Python server, plugin shell scripts.

Highlights:
- DatabaseSchema.php: identifier quoting
- AwardCredits.php: task row locking order
- CreditTransaction.php: fail-fast row decoding
- OpenApiGenerator.php: YAML parse handling + uri query params
- CaptureDispatchResultJob.php: AgentProfile namespace fix
- CreditsController.php: missing workspace_id fail-closed
- QueryAuditService.php: prose query false positives + unbounded aggregation
- McpHealthService.php: proc_close after timeout + env var resolution
- CreditLedger.php + FleetOverview.php: workspace agent + dispatch target validation
- McpAgentServerCommand.php: quota burn on failed tool calls
- McpMetricsService.php: N-day window consistency
- hermes_runner_mcp: API key off command line + invalid method+id + run_id encoding
- CircuitBreaker.php: extracted CircuitOpenException class with autoload-correct placement
- pkg/agentic + brain + flow: SonarCloud sendMessage/fetchLoopRepoRefs/commitWorkspace/Connect annotations
- shell scripts: removed [[ usage for portability

43 files modified, 1 new (CircuitOpenException.php).

Verification: gofmt -w + php -l + python3 -m py_compile + bash -n all clean.
Touched-package go test passes (pkg/lib/flow, pkg/lib).
Full go test ./... blocked by pre-existing dappco.re module graph drift, out of scope.

Parked for separate work:
- Mantis #1062: go.mod local replace removal (cross-repo architectural)
- Mantis #1063: Sonar residual line-length / duplication quality-gate cluster

Closes findings on https://github.com/dAppCore/agent/pull/6

Co-authored-by: Codex <noreply@openai.com>
2026-04-27 13:39:24 +01:00

247 lines
7 KiB
PHP

<?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 Illuminate\Validation\Rule;
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',
Rule::exists(FleetNode::class, 'agent_id')->where(
fn ($query) => $query->where('workspace_id', $this->workspaceId),
),
],
'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';
}
}