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>
185 lines
6.8 KiB
PHP
185 lines
6.8 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Agentic\Controllers\Api\Credits;
|
|
|
|
use Core\Front\Controller;
|
|
use Core\Mod\Agentic\Models\CreditEntry;
|
|
use Core\Mod\Agentic\Services\CreditService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class CreditsController extends Controller
|
|
{
|
|
public function balance(Request $request): JsonResponse
|
|
{
|
|
$workspaceId = $this->workspaceIdFrom($request);
|
|
$service = $this->resolveCreditService();
|
|
|
|
$payload = $service !== null && method_exists($service, 'balance')
|
|
? (array) $service->balance($workspaceId)
|
|
: $this->fallbackBalance($workspaceId);
|
|
|
|
return response()->json(['data' => $payload]);
|
|
}
|
|
|
|
public function deduct(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'amount' => 'required|integer|min:1',
|
|
'reason' => 'required|string|max:1000',
|
|
]);
|
|
|
|
$workspaceId = $this->workspaceIdFrom($request);
|
|
$service = $this->resolveCreditService();
|
|
|
|
$entry = $service !== null && method_exists($service, 'deduct')
|
|
? $service->deduct($workspaceId, (int) $validated['amount'], $validated['reason'])
|
|
: $this->recordTransaction($workspaceId, -abs((int) $validated['amount']), 'manual-deduction', $validated['reason']);
|
|
|
|
return response()->json(['data' => $this->formatEntry($entry)], 201);
|
|
}
|
|
|
|
public function refund(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'amount' => 'required|integer|min:1',
|
|
'reason' => 'required|string|max:1000',
|
|
]);
|
|
|
|
$workspaceId = $this->workspaceIdFrom($request);
|
|
$service = $this->resolveCreditService();
|
|
|
|
$entry = $service !== null && method_exists($service, 'refund')
|
|
? $service->refund($workspaceId, (int) $validated['amount'], $validated['reason'])
|
|
: $this->recordTransaction($workspaceId, abs((int) $validated['amount']), 'manual-refund', $validated['reason']);
|
|
|
|
return response()->json(['data' => $this->formatEntry($entry)], 201);
|
|
}
|
|
|
|
public function ledger(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'limit' => 'nullable|integer|min:1|max:500',
|
|
]);
|
|
|
|
$workspaceId = $this->workspaceIdFrom($request);
|
|
$limit = (int) ($validated['limit'] ?? 50);
|
|
$service = $this->resolveCreditService();
|
|
|
|
$entries = [];
|
|
|
|
if ($service !== null && method_exists($service, 'ledger')) {
|
|
foreach ($service->ledger($workspaceId) as $entry) {
|
|
$entries[] = $this->formatEntry($entry);
|
|
|
|
if (count($entries) >= $limit) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
foreach (CreditEntry::query()->where('workspace_id', $workspaceId)->latest('id')->limit($limit)->get() as $entry) {
|
|
$entries[] = $this->formatEntry($entry);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'data' => $entries,
|
|
'total' => count($entries),
|
|
]);
|
|
}
|
|
|
|
private function workspaceIdFrom(Request $request): int
|
|
{
|
|
$workspaceId = filter_var($request->attributes->get('workspace_id'), FILTER_VALIDATE_INT);
|
|
|
|
abort_if($workspaceId === false || $workspaceId < 1, 400, 'workspace_id attribute is required.');
|
|
|
|
return (int) $workspaceId;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function fallbackBalance(int $workspaceId): array
|
|
{
|
|
$entries = CreditEntry::query()->where('workspace_id', $workspaceId);
|
|
|
|
return [
|
|
'workspace_id' => $workspaceId,
|
|
'balance' => (int) (clone $entries)->sum('amount'),
|
|
'total_earned' => (int) (clone $entries)->where('amount', '>', 0)->sum('amount'),
|
|
'total_spent' => (int) abs((int) (clone $entries)->where('amount', '<', 0)->sum('amount')),
|
|
'entries' => (int) (clone $entries)->count(),
|
|
];
|
|
}
|
|
|
|
private function recordTransaction(int $workspaceId, int $amount, string $taskType, string $reason): CreditEntry
|
|
{
|
|
return DB::transaction(function () use ($workspaceId, $amount, $taskType, $reason): CreditEntry {
|
|
$previousBalance = (int) CreditEntry::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->lockForUpdate()
|
|
->latest('id')
|
|
->value('balance_after');
|
|
|
|
return CreditEntry::query()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'fleet_node_id' => null,
|
|
'task_type' => $taskType,
|
|
'amount' => $amount,
|
|
'balance_after' => $previousBalance + $amount,
|
|
'description' => $reason,
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function formatEntry(object|array $entry): array
|
|
{
|
|
if (is_array($entry)) {
|
|
return [
|
|
'id' => isset($entry['id']) ? (int) $entry['id'] : null,
|
|
'workspace_id' => isset($entry['workspace_id']) ? (int) $entry['workspace_id'] : null,
|
|
'fleet_node_id' => isset($entry['fleet_node_id']) ? (int) $entry['fleet_node_id'] : null,
|
|
'task_type' => (string) ($entry['task_type'] ?? ''),
|
|
'amount' => (int) ($entry['amount'] ?? 0),
|
|
'balance_after' => (int) ($entry['balance_after'] ?? 0),
|
|
'description' => isset($entry['description']) ? (string) $entry['description'] : null,
|
|
'created_at' => isset($entry['created_at']) ? (string) $entry['created_at'] : null,
|
|
];
|
|
}
|
|
|
|
$createdAt = $entry->created_at ?? null;
|
|
|
|
return [
|
|
'id' => isset($entry->id) ? (int) $entry->id : null,
|
|
'workspace_id' => isset($entry->workspace_id) ? (int) $entry->workspace_id : null,
|
|
'fleet_node_id' => isset($entry->fleet_node_id) ? (int) $entry->fleet_node_id : null,
|
|
'task_type' => (string) ($entry->task_type ?? ''),
|
|
'amount' => (int) ($entry->amount ?? 0),
|
|
'balance_after' => (int) ($entry->balance_after ?? 0),
|
|
'description' => isset($entry->description) ? (string) $entry->description : null,
|
|
'created_at' => is_object($createdAt) && method_exists($createdAt, 'toIso8601String')
|
|
? $createdAt->toIso8601String()
|
|
: ($createdAt !== null ? (string) $createdAt : null),
|
|
];
|
|
}
|
|
|
|
private function resolveCreditService(): ?object
|
|
{
|
|
if (! class_exists(CreditService::class)) {
|
|
return null;
|
|
}
|
|
|
|
$service = app(CreditService::class);
|
|
|
|
return is_object($service) ? $service : null;
|
|
}
|
|
}
|