feat(agent): implement fleet and sync RFC surfaces
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
e531462bda
commit
6c69005aff
44 changed files with 2196 additions and 57 deletions
47
php/Actions/Auth/ProvisionAgentKey.php
Normal file
47
php/Actions/Auth/ProvisionAgentKey.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Auth;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Services\AgentApiKeyService;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ProvisionAgentKey
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string> $permissions
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(
|
||||
int $workspaceId,
|
||||
string $oauthUserId,
|
||||
?string $name = null,
|
||||
array $permissions = [],
|
||||
int $rateLimit = 100,
|
||||
?string $expiresAt = null
|
||||
): AgentApiKey {
|
||||
if ($workspaceId <= 0) {
|
||||
throw new \InvalidArgumentException('workspace_id is required');
|
||||
}
|
||||
|
||||
if ($oauthUserId === '') {
|
||||
throw new \InvalidArgumentException('oauth_user_id is required');
|
||||
}
|
||||
|
||||
$service = app(AgentApiKeyService::class);
|
||||
|
||||
return $service->create(
|
||||
$workspaceId,
|
||||
$name ?: 'agent-'.$oauthUserId,
|
||||
$permissions,
|
||||
$rateLimit,
|
||||
$expiresAt ? Carbon::parse($expiresAt) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
32
php/Actions/Auth/RevokeAgentKey.php
Normal file
32
php/Actions/Auth/RevokeAgentKey.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Auth;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Services\AgentApiKeyService;
|
||||
|
||||
class RevokeAgentKey
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, int $keyId): bool
|
||||
{
|
||||
$key = AgentApiKey::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->find($keyId);
|
||||
|
||||
if (! $key) {
|
||||
throw new \InvalidArgumentException('API key not found');
|
||||
}
|
||||
|
||||
app(AgentApiKeyService::class)->revoke($key);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
53
php/Actions/Credits/AwardCredits.php
Normal file
53
php/Actions/Credits/AwardCredits.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Credits;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class AwardCredits
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(
|
||||
int $workspaceId,
|
||||
string $agentId,
|
||||
string $taskType,
|
||||
int $amount,
|
||||
?int $fleetNodeId = null,
|
||||
?string $description = null
|
||||
): CreditEntry {
|
||||
if ($agentId === '' || $taskType === '' || $amount === 0) {
|
||||
throw new \InvalidArgumentException('agent_id, task_type, and non-zero amount are required');
|
||||
}
|
||||
|
||||
$node = $fleetNodeId !== null
|
||||
? FleetNode::query()->where('workspace_id', $workspaceId)->find($fleetNodeId)
|
||||
: FleetNode::query()->where('workspace_id', $workspaceId)->where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$previousBalance = (int) CreditEntry::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('fleet_node_id', $node->id)
|
||||
->latest('id')
|
||||
->value('balance_after');
|
||||
|
||||
return CreditEntry::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'fleet_node_id' => $node->id,
|
||||
'task_type' => $taskType,
|
||||
'amount' => $amount,
|
||||
'balance_after' => $previousBalance + $amount,
|
||||
'description' => $description,
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
php/Actions/Credits/GetBalance.php
Normal file
46
php/Actions/Credits/GetBalance.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Credits;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class GetBalance
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId): array
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$balance = (int) CreditEntry::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('fleet_node_id', $node->id)
|
||||
->latest('id')
|
||||
->value('balance_after');
|
||||
|
||||
return [
|
||||
'agent_id' => $agentId,
|
||||
'balance' => $balance,
|
||||
'entries' => CreditEntry::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('fleet_node_id', $node->id)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
php/Actions/Credits/GetCreditHistory.php
Normal file
37
php/Actions/Credits/GetCreditHistory.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Credits;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\CreditEntry;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class GetCreditHistory
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, int $limit = 50): Collection
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
return CreditEntry::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('fleet_node_id', $node->id)
|
||||
->latest()
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
58
php/Actions/Fleet/AssignTask.php
Normal file
58
php/Actions/Fleet/AssignTask.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
|
||||
class AssignTask
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(
|
||||
int $workspaceId,
|
||||
string $agentId,
|
||||
string $task,
|
||||
string $repo,
|
||||
?string $template = null,
|
||||
?string $branch = null,
|
||||
?string $agentModel = null
|
||||
): FleetTask {
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
if ($task === '' || $repo === '') {
|
||||
throw new \InvalidArgumentException('repo and task are required');
|
||||
}
|
||||
|
||||
$fleetTask = FleetTask::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'fleet_node_id' => $node->id,
|
||||
'repo' => $repo,
|
||||
'branch' => $branch,
|
||||
'task' => $task,
|
||||
'template' => $template,
|
||||
'agent_model' => $agentModel,
|
||||
'status' => FleetTask::STATUS_ASSIGNED,
|
||||
]);
|
||||
|
||||
$node->update([
|
||||
'status' => FleetNode::STATUS_BUSY,
|
||||
'current_task_id' => $fleetTask->id,
|
||||
]);
|
||||
|
||||
return $fleetTask->fresh();
|
||||
}
|
||||
}
|
||||
70
php/Actions/Fleet/CompleteTask.php
Normal file
70
php/Actions/Fleet/CompleteTask.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
|
||||
class CompleteTask
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @param array<int, mixed> $findings
|
||||
* @param array<string, mixed> $changes
|
||||
* @param array<string, mixed> $report
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(
|
||||
int $workspaceId,
|
||||
string $agentId,
|
||||
int $taskId,
|
||||
array $result = [],
|
||||
array $findings = [],
|
||||
array $changes = [],
|
||||
array $report = []
|
||||
): FleetTask {
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
$fleetTask = FleetTask::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->find($taskId);
|
||||
|
||||
if (! $node || ! $fleetTask) {
|
||||
throw new \InvalidArgumentException('Fleet task not found');
|
||||
}
|
||||
|
||||
$status = ($result['status'] ?? '') === 'failed'
|
||||
? FleetTask::STATUS_FAILED
|
||||
: FleetTask::STATUS_COMPLETED;
|
||||
|
||||
$fleetTask->update([
|
||||
'status' => $status,
|
||||
'result' => $result,
|
||||
'findings' => $findings,
|
||||
'changes' => $changes,
|
||||
'report' => $report,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$node->update([
|
||||
'status' => FleetNode::STATUS_ONLINE,
|
||||
'current_task_id' => null,
|
||||
'last_heartbeat_at' => now(),
|
||||
]);
|
||||
|
||||
$creditAmount = max(1, count($findings) + 1);
|
||||
AwardCredits::run($workspaceId, $agentId, 'fleet-task', $creditAmount, $node->id, 'Fleet task completed');
|
||||
|
||||
return $fleetTask->fresh();
|
||||
}
|
||||
}
|
||||
36
php/Actions/Fleet/DeregisterNode.php
Normal file
36
php/Actions/Fleet/DeregisterNode.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class DeregisterNode
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId): bool
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$node->update([
|
||||
'status' => FleetNode::STATUS_OFFLINE,
|
||||
'current_task_id' => null,
|
||||
'last_heartbeat_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
32
php/Actions/Fleet/GetFleetStats.php
Normal file
32
php/Actions/Fleet/GetFleetStats.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
|
||||
class GetFleetStats
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function handle(int $workspaceId): array
|
||||
{
|
||||
$nodes = FleetNode::query()->where('workspace_id', $workspaceId);
|
||||
$tasks = FleetTask::query()->where('workspace_id', $workspaceId);
|
||||
|
||||
return [
|
||||
'nodes_online' => (clone $nodes)->online()->count(),
|
||||
'tasks_today' => (clone $tasks)->whereDate('created_at', today())->count(),
|
||||
'tasks_week' => (clone $tasks)->where('created_at', '>=', now()->subDays(7))->count(),
|
||||
'repos_touched' => (clone $tasks)->distinct('repo')->count('repo'),
|
||||
'findings_total' => (clone $tasks)->get()->sum(static fn (FleetTask $task) => count($task->findings ?? [])),
|
||||
'compute_hours' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
49
php/Actions/Fleet/GetNextTask.php
Normal file
49
php/Actions/Fleet/GetNextTask.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
|
||||
class GetNextTask
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $capabilities
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, array $capabilities = []): ?FleetTask
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$task = FleetTask::pendingForNode($node)->first();
|
||||
|
||||
if (! $task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$task->update([
|
||||
'status' => FleetTask::STATUS_IN_PROGRESS,
|
||||
'started_at' => $task->started_at ?? now(),
|
||||
]);
|
||||
|
||||
$node->update([
|
||||
'status' => FleetNode::STATUS_BUSY,
|
||||
'current_task_id' => $task->id,
|
||||
]);
|
||||
|
||||
return $task->fresh();
|
||||
}
|
||||
}
|
||||
29
php/Actions/Fleet/ListNodes.php
Normal file
29
php/Actions/Fleet/ListNodes.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ListNodes
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function handle(int $workspaceId, ?string $status = null, ?string $platform = null): Collection
|
||||
{
|
||||
$query = FleetNode::query()->where('workspace_id', $workspaceId);
|
||||
|
||||
if ($status !== null && $status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($platform !== null && $platform !== '') {
|
||||
$query->where('platform', $platform);
|
||||
}
|
||||
|
||||
return $query->orderBy('agent_id')->get();
|
||||
}
|
||||
}
|
||||
38
php/Actions/Fleet/NodeHeartbeat.php
Normal file
38
php/Actions/Fleet/NodeHeartbeat.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class NodeHeartbeat
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $computeBudget
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, string $status, array $computeBudget = []): FleetNode
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$node->update([
|
||||
'status' => $status !== '' ? $status : $node->status,
|
||||
'compute_budget' => $computeBudget !== [] ? $computeBudget : $node->compute_budget,
|
||||
'last_heartbeat_at' => now(),
|
||||
]);
|
||||
|
||||
return $node->fresh();
|
||||
}
|
||||
}
|
||||
43
php/Actions/Fleet/RegisterNode.php
Normal file
43
php/Actions/Fleet/RegisterNode.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Fleet;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class RegisterNode
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string> $models
|
||||
* @param array<string, mixed> $capabilities
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, string $platform, array $models = [], array $capabilities = []): FleetNode
|
||||
{
|
||||
if ($workspaceId <= 0) {
|
||||
throw new \InvalidArgumentException('workspace_id is required');
|
||||
}
|
||||
|
||||
if ($agentId === '') {
|
||||
throw new \InvalidArgumentException('agent_id is required');
|
||||
}
|
||||
|
||||
return FleetNode::updateOrCreate(
|
||||
['agent_id' => $agentId],
|
||||
[
|
||||
'workspace_id' => $workspaceId,
|
||||
'platform' => $platform !== '' ? $platform : 'unknown',
|
||||
'models' => $models,
|
||||
'capabilities' => $capabilities,
|
||||
'status' => FleetNode::STATUS_ONLINE,
|
||||
'registered_at' => now(),
|
||||
'last_heartbeat_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
30
php/Actions/Subscription/DetectCapabilities.php
Normal file
30
php/Actions/Subscription/DetectCapabilities.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Subscription;
|
||||
|
||||
use Core\Actions\Action;
|
||||
|
||||
class DetectCapabilities
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $apiKeys
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(array $apiKeys = []): array
|
||||
{
|
||||
$resolved = [
|
||||
'claude' => ($apiKeys['claude'] ?? '') !== '' || (string) config('agentic.claude.api_key', '') !== '',
|
||||
'gemini' => ($apiKeys['gemini'] ?? '') !== '' || (string) config('agentic.gemini.api_key', '') !== '',
|
||||
'openai' => ($apiKeys['openai'] ?? '') !== '' || (string) config('agentic.openai.api_key', '') !== '',
|
||||
];
|
||||
|
||||
return [
|
||||
'providers' => $resolved,
|
||||
'available' => array_keys(array_filter($resolved)),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
php/Actions/Subscription/GetNodeBudget.php
Normal file
32
php/Actions/Subscription/GetNodeBudget.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Subscription;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class GetNodeBudget
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId): array
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
return $node->compute_budget ?? [];
|
||||
}
|
||||
}
|
||||
38
php/Actions/Subscription/UpdateBudget.php
Normal file
38
php/Actions/Subscription/UpdateBudget.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Subscription;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
|
||||
class UpdateBudget
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $limits
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, array $limits): array
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$node->update([
|
||||
'compute_budget' => array_merge($node->compute_budget ?? [], $limits),
|
||||
'last_heartbeat_at' => now(),
|
||||
]);
|
||||
|
||||
return $node->fresh()->compute_budget ?? [];
|
||||
}
|
||||
}
|
||||
50
php/Actions/Sync/GetAgentSyncStatus.php
Normal file
50
php/Actions/Sync/GetAgentSyncStatus.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sync;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\SyncRecord;
|
||||
|
||||
class GetAgentSyncStatus
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId): array
|
||||
{
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if (! $node) {
|
||||
throw new \InvalidArgumentException('Fleet node not found');
|
||||
}
|
||||
|
||||
$lastPush = SyncRecord::query()
|
||||
->where('fleet_node_id', $node->id)
|
||||
->where('direction', 'push')
|
||||
->latest('synced_at')
|
||||
->first();
|
||||
|
||||
$lastPull = SyncRecord::query()
|
||||
->where('fleet_node_id', $node->id)
|
||||
->where('direction', 'pull')
|
||||
->latest('synced_at')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'agent_id' => $node->agent_id,
|
||||
'status' => $node->status,
|
||||
'last_push_at' => $lastPush?->synced_at?->toIso8601String(),
|
||||
'last_pull_at' => $lastPull?->synced_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
56
php/Actions/Sync/PullFleetContext.php
Normal file
56
php/Actions/Sync/PullFleetContext.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sync;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\BrainMemory;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\SyncRecord;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PullFleetContext
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, ?string $since = null): array
|
||||
{
|
||||
if ($agentId === '') {
|
||||
throw new \InvalidArgumentException('agent_id is required');
|
||||
}
|
||||
|
||||
$query = BrainMemory::query()
|
||||
->forWorkspace($workspaceId)
|
||||
->active()
|
||||
->latest();
|
||||
|
||||
if ($since !== null && $since !== '') {
|
||||
$query->where('created_at', '>=', Carbon::parse($since));
|
||||
}
|
||||
|
||||
$items = $query->limit(25)->get();
|
||||
|
||||
$node = FleetNode::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('agent_id', $agentId)
|
||||
->first();
|
||||
|
||||
if ($node) {
|
||||
SyncRecord::create([
|
||||
'fleet_node_id' => $node->id,
|
||||
'direction' => 'pull',
|
||||
'payload_size' => strlen((string) json_encode($items->toArray())),
|
||||
'items_count' => $items->count(),
|
||||
'synced_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $items->map(fn (BrainMemory $memory) => $memory->toMcpContext())->values()->all();
|
||||
}
|
||||
}
|
||||
79
php/Actions/Sync/PushDispatchHistory.php
Normal file
79
php/Actions/Sync/PushDispatchHistory.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sync;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\BrainMemory;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\SyncRecord;
|
||||
|
||||
class PushDispatchHistory
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $dispatches
|
||||
* @return array{synced: int}
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(int $workspaceId, string $agentId, array $dispatches): array
|
||||
{
|
||||
if ($agentId === '') {
|
||||
throw new \InvalidArgumentException('agent_id is required');
|
||||
}
|
||||
|
||||
$node = FleetNode::firstOrCreate(
|
||||
['agent_id' => $agentId],
|
||||
[
|
||||
'workspace_id' => $workspaceId,
|
||||
'platform' => 'remote',
|
||||
'status' => FleetNode::STATUS_ONLINE,
|
||||
'registered_at' => now(),
|
||||
'last_heartbeat_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$synced = 0;
|
||||
|
||||
foreach ($dispatches as $dispatch) {
|
||||
$repo = (string) ($dispatch['repo'] ?? '');
|
||||
$status = (string) ($dispatch['status'] ?? 'completed');
|
||||
$workspace = (string) ($dispatch['workspace'] ?? '');
|
||||
$task = (string) ($dispatch['task'] ?? '');
|
||||
|
||||
if ($repo === '' && $workspace === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
BrainMemory::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'agent_id' => $agentId,
|
||||
'type' => 'observation',
|
||||
'content' => trim("Repo: {$repo}\nWorkspace: {$workspace}\nStatus: {$status}\nTask: {$task}"),
|
||||
'tags' => array_values(array_filter([
|
||||
'sync',
|
||||
$repo !== '' ? $repo : null,
|
||||
$status,
|
||||
])),
|
||||
'project' => $repo !== '' ? $repo : null,
|
||||
'confidence' => 0.7,
|
||||
'source' => 'sync.push',
|
||||
]);
|
||||
|
||||
$synced++;
|
||||
}
|
||||
|
||||
SyncRecord::create([
|
||||
'fleet_node_id' => $node->id,
|
||||
'direction' => 'push',
|
||||
'payload_size' => strlen((string) json_encode($dispatches)),
|
||||
'items_count' => count($dispatches),
|
||||
'synced_at' => now(),
|
||||
]);
|
||||
|
||||
return ['synced' => $synced];
|
||||
}
|
||||
}
|
||||
56
php/Boot.php
56
php/Boot.php
|
|
@ -198,14 +198,52 @@ class Boot extends ServiceProvider
|
|||
{
|
||||
$registry = $this->app->make(Services\AgentToolRegistry::class);
|
||||
|
||||
$registry->registerMany([
|
||||
new Mcp\Tools\Agent\Brain\BrainRemember(),
|
||||
new Mcp\Tools\Agent\Brain\BrainRecall(),
|
||||
new Mcp\Tools\Agent\Brain\BrainForget(),
|
||||
new Mcp\Tools\Agent\Brain\BrainList(),
|
||||
new Mcp\Tools\Agent\Messaging\AgentSend(),
|
||||
new Mcp\Tools\Agent\Messaging\AgentInbox(),
|
||||
new Mcp\Tools\Agent\Messaging\AgentConversation(),
|
||||
]);
|
||||
$toolClasses = [
|
||||
Mcp\Tools\Agent\Brain\BrainRemember::class,
|
||||
Mcp\Tools\Agent\Brain\BrainRecall::class,
|
||||
Mcp\Tools\Agent\Brain\BrainForget::class,
|
||||
Mcp\Tools\Agent\Brain\BrainList::class,
|
||||
Mcp\Tools\Agent\Messaging\AgentSend::class,
|
||||
Mcp\Tools\Agent\Messaging\AgentInbox::class,
|
||||
Mcp\Tools\Agent\Messaging\AgentConversation::class,
|
||||
Mcp\Tools\Agent\Plan\PlanCreate::class,
|
||||
Mcp\Tools\Agent\Plan\PlanGet::class,
|
||||
Mcp\Tools\Agent\Plan\PlanList::class,
|
||||
Mcp\Tools\Agent\Plan\PlanUpdateStatus::class,
|
||||
Mcp\Tools\Agent\Plan\PlanArchive::class,
|
||||
Mcp\Tools\Agent\Phase\PhaseGet::class,
|
||||
Mcp\Tools\Agent\Phase\PhaseUpdateStatus::class,
|
||||
Mcp\Tools\Agent\Phase\PhaseAddCheckpoint::class,
|
||||
Mcp\Tools\Agent\Session\SessionStart::class,
|
||||
Mcp\Tools\Agent\Session\SessionEnd::class,
|
||||
Mcp\Tools\Agent\Session\SessionLog::class,
|
||||
Mcp\Tools\Agent\Session\SessionHandoff::class,
|
||||
Mcp\Tools\Agent\Session\SessionResume::class,
|
||||
Mcp\Tools\Agent\Session\SessionReplay::class,
|
||||
Mcp\Tools\Agent\Session\SessionContinue::class,
|
||||
Mcp\Tools\Agent\Session\SessionArtifact::class,
|
||||
Mcp\Tools\Agent\Session\SessionList::class,
|
||||
Mcp\Tools\Agent\State\StateSet::class,
|
||||
Mcp\Tools\Agent\State\StateGet::class,
|
||||
Mcp\Tools\Agent\State\StateList::class,
|
||||
Mcp\Tools\Agent\Task\TaskUpdate::class,
|
||||
Mcp\Tools\Agent\Task\TaskToggle::class,
|
||||
Mcp\Tools\Agent\Template\TemplateList::class,
|
||||
Mcp\Tools\Agent\Template\TemplatePreview::class,
|
||||
Mcp\Tools\Agent\Template\TemplateCreatePlan::class,
|
||||
Mcp\Tools\Agent\Content\ContentGenerate::class,
|
||||
Mcp\Tools\Agent\Content\ContentBatchGenerate::class,
|
||||
Mcp\Tools\Agent\Content\ContentBriefCreate::class,
|
||||
Mcp\Tools\Agent\Content\ContentBriefGet::class,
|
||||
Mcp\Tools\Agent\Content\ContentBriefList::class,
|
||||
Mcp\Tools\Agent\Content\ContentStatus::class,
|
||||
Mcp\Tools\Agent\Content\ContentUsageStats::class,
|
||||
Mcp\Tools\Agent\Content\ContentFromPlan::class,
|
||||
];
|
||||
|
||||
$registry->registerMany(array_map(
|
||||
static fn (string $toolClass) => new $toolClass(),
|
||||
$toolClasses,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
php/Controllers/Api/AuthController.php
Normal file
58
php/Controllers/Api/AuthController.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Auth\ProvisionAgentKey;
|
||||
use Core\Mod\Agentic\Actions\Auth\RevokeAgentKey;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function provision(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'workspace_id' => 'required|integer',
|
||||
'oauth_user_id' => 'required|string|max:255',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string',
|
||||
'rate_limit' => 'nullable|integer|min:1',
|
||||
'expires_at' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$key = ProvisionAgentKey::run(
|
||||
(int) $validated['workspace_id'],
|
||||
$validated['oauth_user_id'],
|
||||
$validated['name'] ?? null,
|
||||
$validated['permissions'] ?? [],
|
||||
(int) ($validated['rate_limit'] ?? 100),
|
||||
$validated['expires_at'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $key->id,
|
||||
'name' => $key->name,
|
||||
'plain_text_key' => $key->plainTextKey,
|
||||
'permissions' => $key->permissions ?? [],
|
||||
'rate_limit' => $key->rate_limit,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function revoke(Request $request, int $keyId): JsonResponse
|
||||
{
|
||||
RevokeAgentKey::run((int) $request->attributes->get('workspace_id'), $keyId);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'key_id' => $keyId,
|
||||
'revoked' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,11 +33,12 @@ class BrainController extends Controller
|
|||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
$apiKey = $request->attributes->get('api_key');
|
||||
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
|
||||
$apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key');
|
||||
$agentId = $apiKey?->name ?? 'api';
|
||||
|
||||
try {
|
||||
$memory = RememberKnowledge::run($validated, $workspace->id, $agentId);
|
||||
$memory = RememberKnowledge::run($validated, $workspaceId, $agentId);
|
||||
|
||||
return response()->json([
|
||||
'data' => $memory->toMcpContext(),
|
||||
|
|
@ -73,11 +74,12 @@ class BrainController extends Controller
|
|||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
|
||||
|
||||
try {
|
||||
$result = RecallKnowledge::run(
|
||||
$validated['query'],
|
||||
$workspace->id,
|
||||
$workspaceId,
|
||||
$validated['filter'] ?? [],
|
||||
$validated['top_k'] ?? 5,
|
||||
);
|
||||
|
|
@ -110,11 +112,12 @@ class BrainController extends Controller
|
|||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
$apiKey = $request->attributes->get('api_key');
|
||||
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
|
||||
$apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key');
|
||||
$agentId = $apiKey?->name ?? 'api';
|
||||
|
||||
try {
|
||||
$result = ForgetKnowledge::run($id, $workspace->id, $agentId, $request->input('reason'));
|
||||
$result = ForgetKnowledge::run($id, $workspaceId, $agentId, $request->input('reason'));
|
||||
|
||||
return response()->json([
|
||||
'data' => $result,
|
||||
|
|
@ -147,9 +150,10 @@ class BrainController extends Controller
|
|||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
|
||||
|
||||
try {
|
||||
$result = ListKnowledge::run($workspace->id, $validated);
|
||||
$result = ListKnowledge::run($workspaceId, $validated);
|
||||
|
||||
return response()->json([
|
||||
'data' => $result,
|
||||
|
|
|
|||
71
php/Controllers/Api/CreditsController.php
Normal file
71
php/Controllers/Api/CreditsController.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
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 Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CreditsController extends Controller
|
||||
{
|
||||
public function award(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'task_type' => 'required|string|max:255',
|
||||
'amount' => 'required|integer|not_in:0',
|
||||
'fleet_node_id' => 'nullable|integer',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$entry = AwardCredits::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['task_type'],
|
||||
(int) $validated['amount'],
|
||||
isset($validated['fleet_node_id']) ? (int) $validated['fleet_node_id'] : null,
|
||||
$validated['description'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json(['data' => $this->formatEntry($entry)], 201);
|
||||
}
|
||||
|
||||
public function balance(Request $request, string $agentId): JsonResponse
|
||||
{
|
||||
$balance = GetBalance::run((int) $request->attributes->get('workspace_id'), $agentId);
|
||||
|
||||
return response()->json(['data' => $balance]);
|
||||
}
|
||||
|
||||
public function history(Request $request, string $agentId): JsonResponse
|
||||
{
|
||||
$limit = (int) $request->query('limit', 50);
|
||||
$entries = GetCreditHistory::run((int) $request->attributes->get('workspace_id'), $agentId, $limit);
|
||||
|
||||
return response()->json([
|
||||
'data' => $entries->map(fn (CreditEntry $entry) => $this->formatEntry($entry))->values()->all(),
|
||||
'total' => $entries->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatEntry(CreditEntry $entry): array
|
||||
{
|
||||
return [
|
||||
'id' => $entry->id,
|
||||
'task_type' => $entry->task_type,
|
||||
'amount' => $entry->amount,
|
||||
'balance_after' => $entry->balance_after,
|
||||
'description' => $entry->description,
|
||||
'created_at' => $entry->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
233
php/Controllers/Api/FleetController.php
Normal file
233
php/Controllers/Api/FleetController.php
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Fleet\AssignTask;
|
||||
use Core\Mod\Agentic\Actions\Fleet\CompleteTask;
|
||||
use Core\Mod\Agentic\Actions\Fleet\DeregisterNode;
|
||||
use Core\Mod\Agentic\Actions\Fleet\GetFleetStats;
|
||||
use Core\Mod\Agentic\Actions\Fleet\GetNextTask;
|
||||
use Core\Mod\Agentic\Actions\Fleet\ListNodes;
|
||||
use Core\Mod\Agentic\Actions\Fleet\NodeHeartbeat;
|
||||
use Core\Mod\Agentic\Actions\Fleet\RegisterNode;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\FleetTask;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FleetController extends Controller
|
||||
{
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'platform' => 'required|string|max:64',
|
||||
'models' => 'nullable|array',
|
||||
'models.*' => 'string',
|
||||
'capabilities' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$node = RegisterNode::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['platform'],
|
||||
$validated['models'] ?? [],
|
||||
$validated['capabilities'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $this->formatNode($node)], 201);
|
||||
}
|
||||
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'status' => 'required|string|max:32',
|
||||
'compute_budget' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$node = NodeHeartbeat::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['status'],
|
||||
$validated['compute_budget'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $this->formatNode($node)]);
|
||||
}
|
||||
|
||||
public function deregister(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
DeregisterNode::run((int) $request->attributes->get('workspace_id'), $validated['agent_id']);
|
||||
|
||||
return response()->json(['data' => ['agent_id' => $validated['agent_id'], 'deregistered' => true]]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'nullable|string|max:32',
|
||||
'platform' => 'nullable|string|max:64',
|
||||
]);
|
||||
|
||||
$nodes = ListNodes::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['status'] ?? null,
|
||||
$validated['platform'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $nodes->map(fn (FleetNode $node) => $this->formatNode($node))->values()->all(),
|
||||
'total' => $nodes->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function assignTask(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'repo' => 'required|string|max:255',
|
||||
'branch' => 'nullable|string|max:255',
|
||||
'task' => 'required|string|max:10000',
|
||||
'template' => 'nullable|string|max:255',
|
||||
'agent_model' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$fleetTask = AssignTask::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['task'],
|
||||
$validated['repo'],
|
||||
$validated['template'] ?? null,
|
||||
$validated['branch'] ?? null,
|
||||
$validated['agent_model'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json(['data' => $this->formatTask($fleetTask)], 201);
|
||||
}
|
||||
|
||||
public function completeTask(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'task_id' => 'required|integer',
|
||||
'result' => 'nullable|array',
|
||||
'findings' => 'nullable|array',
|
||||
'changes' => 'nullable|array',
|
||||
'report' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$fleetTask = CompleteTask::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
(int) $validated['task_id'],
|
||||
$validated['result'] ?? [],
|
||||
$validated['findings'] ?? [],
|
||||
$validated['changes'] ?? [],
|
||||
$validated['report'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $this->formatTask($fleetTask)]);
|
||||
}
|
||||
|
||||
public function nextTask(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'capabilities' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$fleetTask = GetNextTask::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['capabilities'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $fleetTask ? $this->formatTask($fleetTask) : null]);
|
||||
}
|
||||
|
||||
public function events(Request $request): StreamedResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$workspaceId = (int) $request->attributes->get('workspace_id');
|
||||
$agentId = $validated['agent_id'];
|
||||
|
||||
return response()->stream(function () use ($workspaceId, $agentId): void {
|
||||
echo "event: ready\n";
|
||||
echo 'data: '.json_encode(['agent_id' => $agentId])."\n\n";
|
||||
|
||||
$fleetTask = GetNextTask::run($workspaceId, $agentId, []);
|
||||
if ($fleetTask instanceof FleetTask) {
|
||||
echo "event: task.assigned\n";
|
||||
echo 'data: '.json_encode($this->formatTask($fleetTask))."\n\n";
|
||||
}
|
||||
|
||||
@ob_flush();
|
||||
flush();
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Connection' => 'keep-alive',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
]);
|
||||
}
|
||||
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
$stats = GetFleetStats::run((int) $request->attributes->get('workspace_id'));
|
||||
|
||||
return response()->json(['data' => $stats]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatNode(FleetNode $node): array
|
||||
{
|
||||
return [
|
||||
'id' => $node->id,
|
||||
'agent_id' => $node->agent_id,
|
||||
'platform' => $node->platform,
|
||||
'models' => $node->models ?? [],
|
||||
'capabilities' => $node->capabilities ?? [],
|
||||
'status' => $node->status,
|
||||
'compute_budget' => $node->compute_budget ?? [],
|
||||
'current_task_id' => $node->current_task_id,
|
||||
'last_heartbeat_at' => $node->last_heartbeat_at?->toIso8601String(),
|
||||
'registered_at' => $node->registered_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatTask(FleetTask $fleetTask): array
|
||||
{
|
||||
return [
|
||||
'id' => $fleetTask->id,
|
||||
'repo' => $fleetTask->repo,
|
||||
'branch' => $fleetTask->branch,
|
||||
'task' => $fleetTask->task,
|
||||
'template' => $fleetTask->template,
|
||||
'agent_model' => $fleetTask->agent_model,
|
||||
'status' => $fleetTask->status,
|
||||
'result' => $fleetTask->result ?? [],
|
||||
'findings' => $fleetTask->findings ?? [],
|
||||
'changes' => $fleetTask->changes ?? [],
|
||||
'report' => $fleetTask->report ?? [],
|
||||
'started_at' => $fleetTask->started_at?->toIso8601String(),
|
||||
'completed_at' => $fleetTask->completed_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -69,15 +69,19 @@ class MessageController extends Controller
|
|||
$validated = $request->validate([
|
||||
'to' => 'required|string|max:100',
|
||||
'content' => 'required|string|max:10000',
|
||||
'from' => 'required|string|max:100',
|
||||
'from' => 'nullable|string|max:100',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$workspaceId = $request->attributes->get('workspace_id');
|
||||
$apiKey = $request->attributes->get('agent_api_key');
|
||||
$from = $validated['from']
|
||||
?? $apiKey?->name
|
||||
?? $request->header('X-Agent-Name', 'unknown');
|
||||
|
||||
$message = AgentMessage::create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'from_agent' => $validated['from'],
|
||||
'from_agent' => $from,
|
||||
'to_agent' => $validated['to'],
|
||||
'content' => $validated['content'],
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
|
|
|
|||
48
php/Controllers/Api/SubscriptionController.php
Normal file
48
php/Controllers/Api/SubscriptionController.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Subscription\DetectCapabilities;
|
||||
use Core\Mod\Agentic\Actions\Subscription\GetNodeBudget;
|
||||
use Core\Mod\Agentic\Actions\Subscription\UpdateBudget;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function detect(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'api_keys' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$capabilities = DetectCapabilities::run($validated['api_keys'] ?? []);
|
||||
|
||||
return response()->json(['data' => $capabilities]);
|
||||
}
|
||||
|
||||
public function budget(Request $request, string $agentId): JsonResponse
|
||||
{
|
||||
$budget = GetNodeBudget::run((int) $request->attributes->get('workspace_id'), $agentId);
|
||||
|
||||
return response()->json(['data' => $budget]);
|
||||
}
|
||||
|
||||
public function updateBudget(Request $request, string $agentId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'limits' => 'required|array',
|
||||
]);
|
||||
|
||||
$budget = UpdateBudget::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$agentId,
|
||||
$validated['limits'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $budget]);
|
||||
}
|
||||
}
|
||||
64
php/Controllers/Api/SyncController.php
Normal file
64
php/Controllers/Api/SyncController.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Sync\GetAgentSyncStatus;
|
||||
use Core\Mod\Agentic\Actions\Sync\PullFleetContext;
|
||||
use Core\Mod\Agentic\Actions\Sync\PushDispatchHistory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SyncController extends Controller
|
||||
{
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'dispatches' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$result = PushDispatchHistory::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['dispatches'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $result], 201);
|
||||
}
|
||||
|
||||
public function pull(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
'since' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$context = PullFleetContext::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
$validated['since'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $context,
|
||||
'total' => count($context),
|
||||
]);
|
||||
}
|
||||
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$status = GetAgentSyncStatus::run(
|
||||
(int) $request->attributes->get('workspace_id'),
|
||||
$validated['agent_id'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $status]);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,9 @@ class AgentApiAuth
|
|||
|
||||
// Store API key in request for downstream use
|
||||
$request->attributes->set('agent_api_key', $key);
|
||||
$request->attributes->set('api_key', $key);
|
||||
$request->attributes->set('workspace_id', $key->workspace_id);
|
||||
$request->attributes->set('workspace', $key->workspace);
|
||||
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
|
|
|
|||
101
php/Migrations/2026_03_31_000001_create_agent_fleet_tables.php
Normal file
101
php/Migrations/2026_03_31_000001_create_agent_fleet_tables.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
if (! Schema::hasTable('fleet_nodes')) {
|
||||
Schema::create('fleet_nodes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('agent_id')->unique();
|
||||
$table->string('platform', 64)->default('unknown');
|
||||
$table->json('models')->nullable();
|
||||
$table->json('capabilities')->nullable();
|
||||
$table->string('status', 32)->default('offline');
|
||||
$table->json('compute_budget')->nullable();
|
||||
$table->unsignedBigInteger('current_task_id')->nullable();
|
||||
$table->timestamp('last_heartbeat_at')->nullable();
|
||||
$table->timestamp('registered_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('fleet_tasks')) {
|
||||
Schema::create('fleet_tasks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete();
|
||||
$table->string('repo');
|
||||
$table->string('branch')->nullable();
|
||||
$table->text('task');
|
||||
$table->string('template')->nullable();
|
||||
$table->string('agent_model')->nullable();
|
||||
$table->string('status', 32)->default('queued');
|
||||
$table->json('result')->nullable();
|
||||
$table->json('findings')->nullable();
|
||||
$table->json('changes')->nullable();
|
||||
$table->json('report')->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'status']);
|
||||
$table->index(['fleet_node_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('credit_entries')) {
|
||||
Schema::create('credit_entries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete();
|
||||
$table->string('task_type');
|
||||
$table->integer('amount');
|
||||
$table->integer('balance_after');
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'fleet_node_id']);
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('sync_records')) {
|
||||
Schema::create('sync_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete();
|
||||
$table->string('direction', 16);
|
||||
$table->unsignedInteger('payload_size')->default(0);
|
||||
$table->unsignedInteger('items_count')->default(0);
|
||||
$table->timestamp('synced_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['fleet_node_id', 'direction']);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
Schema::dropIfExists('sync_records');
|
||||
Schema::dropIfExists('credit_entries');
|
||||
Schema::dropIfExists('fleet_tasks');
|
||||
Schema::dropIfExists('fleet_nodes');
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
|
@ -80,6 +80,40 @@ class AgentApiKey extends Model
|
|||
|
||||
public const PERM_SESSIONS_WRITE = 'sessions.write';
|
||||
|
||||
public const PERM_BRAIN_READ = 'brain.read';
|
||||
|
||||
public const PERM_BRAIN_WRITE = 'brain.write';
|
||||
|
||||
public const PERM_ISSUES_READ = 'issues.read';
|
||||
|
||||
public const PERM_ISSUES_WRITE = 'issues.write';
|
||||
|
||||
public const PERM_SPRINTS_READ = 'sprints.read';
|
||||
|
||||
public const PERM_SPRINTS_WRITE = 'sprints.write';
|
||||
|
||||
public const PERM_MESSAGES_READ = 'messages.read';
|
||||
|
||||
public const PERM_MESSAGES_WRITE = 'messages.write';
|
||||
|
||||
public const PERM_AUTH_WRITE = 'auth.write';
|
||||
|
||||
public const PERM_FLEET_READ = 'fleet.read';
|
||||
|
||||
public const PERM_FLEET_WRITE = 'fleet.write';
|
||||
|
||||
public const PERM_SYNC_READ = 'sync.read';
|
||||
|
||||
public const PERM_SYNC_WRITE = 'sync.write';
|
||||
|
||||
public const PERM_CREDITS_READ = 'credits.read';
|
||||
|
||||
public const PERM_CREDITS_WRITE = 'credits.write';
|
||||
|
||||
public const PERM_SUBSCRIPTION_READ = 'subscription.read';
|
||||
|
||||
public const PERM_SUBSCRIPTION_WRITE = 'subscription.write';
|
||||
|
||||
public const PERM_TOOLS_READ = 'tools.read';
|
||||
|
||||
public const PERM_TEMPLATES_READ = 'templates.read';
|
||||
|
|
@ -104,6 +138,23 @@ class AgentApiKey extends Model
|
|||
self::PERM_PHASES_WRITE => 'Update phase status, add/complete tasks',
|
||||
self::PERM_SESSIONS_READ => 'List and view sessions',
|
||||
self::PERM_SESSIONS_WRITE => 'Start, update, complete sessions',
|
||||
self::PERM_BRAIN_READ => 'Recall and list brain memories',
|
||||
self::PERM_BRAIN_WRITE => 'Store and forget brain memories',
|
||||
self::PERM_ISSUES_READ => 'List and view issues',
|
||||
self::PERM_ISSUES_WRITE => 'Create, update, and archive issues',
|
||||
self::PERM_SPRINTS_READ => 'List and view sprints',
|
||||
self::PERM_SPRINTS_WRITE => 'Create, update, and archive sprints',
|
||||
self::PERM_MESSAGES_READ => 'Read inbox and conversation threads',
|
||||
self::PERM_MESSAGES_WRITE => 'Send and acknowledge messages',
|
||||
self::PERM_AUTH_WRITE => 'Provision and revoke agent API keys',
|
||||
self::PERM_FLEET_READ => 'View fleet nodes, tasks, and stats',
|
||||
self::PERM_FLEET_WRITE => 'Register nodes and manage fleet tasks',
|
||||
self::PERM_SYNC_READ => 'Pull shared fleet context and sync status',
|
||||
self::PERM_SYNC_WRITE => 'Push dispatch history to the platform',
|
||||
self::PERM_CREDITS_READ => 'View agent credit balances and history',
|
||||
self::PERM_CREDITS_WRITE => 'Award agent credits',
|
||||
self::PERM_SUBSCRIPTION_READ => 'View node budgets and capability detection',
|
||||
self::PERM_SUBSCRIPTION_WRITE => 'Update node budgets',
|
||||
self::PERM_TOOLS_READ => 'View tool analytics',
|
||||
self::PERM_TEMPLATES_READ => 'List and view templates',
|
||||
self::PERM_TEMPLATES_INSTANTIATE => 'Create plans from templates',
|
||||
|
|
@ -252,7 +303,15 @@ class AgentApiKey extends Model
|
|||
// Permission helpers
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
return in_array($permission, $this->permissions ?? []);
|
||||
$wanted = $this->normalisePermission($permission);
|
||||
|
||||
foreach ($this->permissions ?? [] as $granted) {
|
||||
if ($this->normalisePermission((string) $granted) === $wanted) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasAnyPermission(array $permissions): bool
|
||||
|
|
@ -277,6 +336,11 @@ class AgentApiKey extends Model
|
|||
return true;
|
||||
}
|
||||
|
||||
protected function normalisePermission(string $permission): string
|
||||
{
|
||||
return str_replace(':', '.', trim($permission));
|
||||
}
|
||||
|
||||
// Actions
|
||||
public function revoke(): self
|
||||
{
|
||||
|
|
|
|||
39
php/Models/CreditEntry.php
Normal file
39
php/Models/CreditEntry.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CreditEntry extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'fleet_node_id',
|
||||
'task_type',
|
||||
'amount',
|
||||
'balance_after',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function fleetNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FleetNode::class);
|
||||
}
|
||||
}
|
||||
82
php/Models/FleetNode.php
Normal file
82
php/Models/FleetNode.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class FleetNode extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
|
||||
public const STATUS_OFFLINE = 'offline';
|
||||
|
||||
public const STATUS_ONLINE = 'online';
|
||||
|
||||
public const STATUS_BUSY = 'busy';
|
||||
|
||||
public const STATUS_PAUSED = 'paused';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'agent_id',
|
||||
'platform',
|
||||
'models',
|
||||
'capabilities',
|
||||
'status',
|
||||
'compute_budget',
|
||||
'current_task_id',
|
||||
'last_heartbeat_at',
|
||||
'registered_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'models' => 'array',
|
||||
'capabilities' => 'array',
|
||||
'compute_budget' => 'array',
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'registered_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function currentTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FleetTask::class, 'current_task_id');
|
||||
}
|
||||
|
||||
public function tasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(FleetTask::class);
|
||||
}
|
||||
|
||||
public function creditEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(CreditEntry::class);
|
||||
}
|
||||
|
||||
public function syncRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(SyncRecord::class);
|
||||
}
|
||||
|
||||
public function scopeOnline(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_ONLINE, self::STATUS_BUSY]);
|
||||
}
|
||||
|
||||
public function scopeIdle(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_ONLINE)
|
||||
->whereNull('current_task_id');
|
||||
}
|
||||
}
|
||||
69
php/Models/FleetTask.php
Normal file
69
php/Models/FleetTask.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class FleetTask extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_ASSIGNED = 'assigned';
|
||||
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'fleet_node_id',
|
||||
'repo',
|
||||
'branch',
|
||||
'task',
|
||||
'template',
|
||||
'agent_model',
|
||||
'status',
|
||||
'result',
|
||||
'findings',
|
||||
'changes',
|
||||
'report',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'result' => 'array',
|
||||
'findings' => 'array',
|
||||
'changes' => 'array',
|
||||
'report' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function fleetNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FleetNode::class);
|
||||
}
|
||||
|
||||
public function scopePendingForNode(Builder $query, FleetNode $node): Builder
|
||||
{
|
||||
return $query->where('fleet_node_id', $node->id)
|
||||
->whereIn('status', [self::STATUS_ASSIGNED, self::STATUS_QUEUED])
|
||||
->orderBy('created_at');
|
||||
}
|
||||
}
|
||||
30
php/Models/SyncRecord.php
Normal file
30
php/Models/SyncRecord.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SyncRecord extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'fleet_node_id',
|
||||
'direction',
|
||||
'payload_size',
|
||||
'items_count',
|
||||
'synced_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload_size' => 'integer',
|
||||
'items_count' => 'integer',
|
||||
'synced_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function fleetNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FleetNode::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,19 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Controllers\AgentApiController;
|
||||
use Core\Mod\Agentic\Controllers\Api\AuthController;
|
||||
use Core\Mod\Agentic\Controllers\Api\BrainController;
|
||||
use Core\Mod\Agentic\Controllers\Api\CreditsController;
|
||||
use Core\Mod\Agentic\Controllers\Api\FleetController;
|
||||
use Core\Mod\Agentic\Controllers\Api\IssueController;
|
||||
use Core\Mod\Agentic\Controllers\Api\MessageController;
|
||||
use Core\Mod\Agentic\Controllers\Api\PhaseController;
|
||||
use Core\Mod\Agentic\Controllers\Api\PlanController;
|
||||
use Core\Mod\Agentic\Controllers\Api\SessionController;
|
||||
use Core\Mod\Agentic\Controllers\Api\SprintController;
|
||||
use Core\Mod\Agentic\Controllers\Api\SubscriptionController;
|
||||
use Core\Mod\Agentic\Controllers\Api\SyncController;
|
||||
use Core\Mod\Agentic\Controllers\Api\TaskController;
|
||||
use Core\Mod\Agentic\Middleware\AgentApiAuth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -33,42 +44,46 @@ Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function ()
|
|||
Route::get('v1/agent/checkin', [\Core\Mod\Agentic\Controllers\Api\CheckinController::class, 'checkin']);
|
||||
});
|
||||
|
||||
// Authenticated agent endpoints
|
||||
Route::middleware(AgentApiAuth::class.':brain.read')->group(function () {
|
||||
Route::post('v1/brain/recall', [BrainController::class, 'recall']);
|
||||
Route::get('v1/brain/list', [BrainController::class, 'list']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':brain.write')->group(function () {
|
||||
Route::post('v1/brain/remember', [BrainController::class, 'remember']);
|
||||
Route::delete('v1/brain/forget/{id}', [BrainController::class, 'forget']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':plans.read')->group(function () {
|
||||
// Plans (read)
|
||||
Route::get('v1/plans', [AgentApiController::class, 'listPlans']);
|
||||
Route::get('v1/plans/{slug}', [AgentApiController::class, 'getPlan']);
|
||||
|
||||
// Phases (read)
|
||||
Route::get('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'getPhase']);
|
||||
|
||||
// Sessions (read)
|
||||
Route::get('v1/sessions', [AgentApiController::class, 'listSessions']);
|
||||
Route::get('v1/sessions/{sessionId}', [AgentApiController::class, 'getSession']);
|
||||
Route::get('v1/plans', [PlanController::class, 'index']);
|
||||
Route::get('v1/plans/{slug}', [PlanController::class, 'show']);
|
||||
Route::get('v1/plans/{slug}/phases/{phase}', [PhaseController::class, 'show']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':plans.write')->group(function () {
|
||||
// Plans (write)
|
||||
Route::post('v1/plans', [AgentApiController::class, 'createPlan']);
|
||||
Route::patch('v1/plans/{slug}', [AgentApiController::class, 'updatePlan']);
|
||||
Route::delete('v1/plans/{slug}', [AgentApiController::class, 'archivePlan']);
|
||||
Route::post('v1/plans', [PlanController::class, 'store']);
|
||||
Route::patch('v1/plans/{slug}/status', [PlanController::class, 'update']);
|
||||
Route::delete('v1/plans/{slug}', [PlanController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':phases.write')->group(function () {
|
||||
// Phases (write)
|
||||
Route::patch('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'updatePhase']);
|
||||
Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [AgentApiController::class, 'addCheckpoint']);
|
||||
Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}', [AgentApiController::class, 'updateTask'])
|
||||
->whereNumber('taskIdx');
|
||||
Route::post('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle', [AgentApiController::class, 'toggleTask'])
|
||||
->whereNumber('taskIdx');
|
||||
Route::patch('v1/plans/{slug}/phases/{phase}', [PhaseController::class, 'update']);
|
||||
Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [PhaseController::class, 'checkpoint']);
|
||||
Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{index}', [TaskController::class, 'update'])
|
||||
->whereNumber('index');
|
||||
Route::post('v1/plans/{slug}/phases/{phase}/tasks/{index}/toggle', [TaskController::class, 'toggle'])
|
||||
->whereNumber('index');
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':sessions.read')->group(function () {
|
||||
Route::get('v1/sessions', [SessionController::class, 'index']);
|
||||
Route::get('v1/sessions/{id}', [SessionController::class, 'show']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':sessions.write')->group(function () {
|
||||
// Sessions (write)
|
||||
Route::post('v1/sessions', [AgentApiController::class, 'startSession']);
|
||||
Route::post('v1/sessions/{sessionId}/end', [AgentApiController::class, 'endSession']);
|
||||
Route::post('v1/sessions/{sessionId}/continue', [AgentApiController::class, 'continueSession']);
|
||||
Route::post('v1/sessions', [SessionController::class, 'store']);
|
||||
Route::post('v1/sessions/{id}/continue', [SessionController::class, 'continue']);
|
||||
Route::post('v1/sessions/{id}/end', [SessionController::class, 'end']);
|
||||
});
|
||||
|
||||
// Issue tracker
|
||||
|
|
@ -97,13 +112,62 @@ Route::middleware(AgentApiAuth::class.':sprints.write')->group(function () {
|
|||
Route::delete('v1/sprints/{slug}', [SprintController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// Agent messaging — uses auth.api (same as brain routes) so CORE_BRAIN_KEY works
|
||||
Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function () {
|
||||
Route::get('v1/messages/inbox', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'inbox']);
|
||||
Route::get('v1/messages/conversation/{agent}', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'conversation']);
|
||||
Route::middleware(AgentApiAuth::class.':messages.read')->group(function () {
|
||||
Route::get('v1/messages/inbox', [MessageController::class, 'inbox']);
|
||||
Route::get('v1/messages/conversation/{agent}', [MessageController::class, 'conversation']);
|
||||
});
|
||||
|
||||
Route::middleware(['throttle:60,1', 'auth.api:brain:write'])->group(function () {
|
||||
Route::post('v1/messages/send', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'send']);
|
||||
Route::post('v1/messages/{id}/read', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'markRead']);
|
||||
Route::middleware(AgentApiAuth::class.':messages.write')->group(function () {
|
||||
Route::post('v1/messages/send', [MessageController::class, 'send']);
|
||||
Route::post('v1/messages/{id}/read', [MessageController::class, 'markRead']);
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('v1/agent/auth/provision', [AuthController::class, 'provision']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':auth.write')->group(function () {
|
||||
Route::delete('v1/agent/auth/revoke/{keyId}', [AuthController::class, 'revoke']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':fleet.write')->group(function () {
|
||||
Route::post('v1/fleet/register', [FleetController::class, 'register']);
|
||||
Route::post('v1/fleet/heartbeat', [FleetController::class, 'heartbeat']);
|
||||
Route::post('v1/fleet/deregister', [FleetController::class, 'deregister']);
|
||||
Route::post('v1/fleet/task/assign', [FleetController::class, 'assignTask']);
|
||||
Route::post('v1/fleet/task/complete', [FleetController::class, 'completeTask']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':fleet.read')->group(function () {
|
||||
Route::get('v1/fleet/nodes', [FleetController::class, 'index']);
|
||||
Route::get('v1/fleet/task/next', [FleetController::class, 'nextTask']);
|
||||
Route::get('v1/fleet/events', [FleetController::class, 'events']);
|
||||
Route::get('v1/fleet/stats', [FleetController::class, 'stats']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':sync.write')->group(function () {
|
||||
Route::post('v1/agent/sync', [SyncController::class, 'push']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':sync.read')->group(function () {
|
||||
Route::get('v1/agent/context', [SyncController::class, 'pull']);
|
||||
Route::get('v1/agent/status', [SyncController::class, 'status']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':credits.write')->group(function () {
|
||||
Route::post('v1/credits/award', [CreditsController::class, 'award']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':credits.read')->group(function () {
|
||||
Route::get('v1/credits/balance/{agentId}', [CreditsController::class, 'balance']);
|
||||
Route::get('v1/credits/history/{agentId}', [CreditsController::class, 'history']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':subscription.write')->group(function () {
|
||||
Route::post('v1/subscription/detect', [SubscriptionController::class, 'detect']);
|
||||
Route::put('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'updateBudget']);
|
||||
});
|
||||
|
||||
Route::middleware(AgentApiAuth::class.':subscription.read')->group(function () {
|
||||
Route::get('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'budget']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/agent/pkg/messages"
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
|
|
@ -51,6 +52,13 @@ func (s *PrepSubsystem) autoCreatePR(workspaceDir string) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if s.ServiceRuntime != nil {
|
||||
s.Core().ACTION(messages.WorkspacePushed{
|
||||
Repo: workspaceStatus.Repo,
|
||||
Branch: workspaceStatus.Branch,
|
||||
Org: org,
|
||||
})
|
||||
}
|
||||
|
||||
title := core.Sprintf("[agent/%s] %s", workspaceStatus.Agent, truncate(workspaceStatus.Task, 60))
|
||||
body := s.buildAutoPRBody(workspaceStatus, commitCount)
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result {
|
|||
core.Option{Key: "token", Value: s.brainKey},
|
||||
))
|
||||
|
||||
c.Action("agent.sync.push", s.handleSyncPush).Description = "Push completed dispatch state to the platform API"
|
||||
c.Action("agent.sync.pull", s.handleSyncPull).Description = "Pull fleet context from the platform API"
|
||||
|
||||
c.Action("agentic.dispatch", s.handleDispatch).Description = "Prep workspace and spawn a subagent"
|
||||
c.Action("agentic.prep", s.handlePrep).Description = "Clone repo and build agent prompt"
|
||||
c.Action("agentic.status", s.handleStatus).Description = "List workspace states (running/completed/blocked)"
|
||||
|
|
|
|||
174
pkg/agentic/sync.go
Normal file
174
pkg/agentic/sync.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
type SyncPushInput struct {
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
}
|
||||
|
||||
type SyncPushOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SyncPullInput struct {
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
}
|
||||
|
||||
type SyncPullOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Count int `json:"count"`
|
||||
Context []map[string]any `json:"context"`
|
||||
}
|
||||
|
||||
// result := c.Action("agent.sync.push").Run(ctx, core.NewOptions())
|
||||
func (s *PrepSubsystem) handleSyncPush(ctx context.Context, options core.Options) core.Result {
|
||||
output, err := s.syncPush(ctx, options.String("agent_id"))
|
||||
if err != nil {
|
||||
return core.Result{Value: err, OK: false}
|
||||
}
|
||||
return core.Result{Value: output, OK: true}
|
||||
}
|
||||
|
||||
// result := c.Action("agent.sync.pull").Run(ctx, core.NewOptions())
|
||||
func (s *PrepSubsystem) handleSyncPull(ctx context.Context, options core.Options) core.Result {
|
||||
output, err := s.syncPull(ctx, options.String("agent_id"))
|
||||
if err != nil {
|
||||
return core.Result{Value: err, OK: false}
|
||||
}
|
||||
return core.Result{Value: output, OK: true}
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) syncPush(ctx context.Context, agentID string) (SyncPushOutput, error) {
|
||||
if agentID == "" {
|
||||
agentID = AgentName()
|
||||
}
|
||||
token := s.syncToken()
|
||||
if token == "" {
|
||||
return SyncPushOutput{}, core.E("agent.sync.push", "api token is required", nil)
|
||||
}
|
||||
|
||||
dispatches := collectSyncDispatches()
|
||||
if len(dispatches) == 0 {
|
||||
return SyncPushOutput{Success: true, Count: 0}, nil
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"agent_id": agentID,
|
||||
"dispatches": dispatches,
|
||||
}
|
||||
|
||||
result := HTTPPost(ctx, core.Concat(s.syncAPIURL(), "/v1/agent/sync"), core.JSONMarshalString(payload), token, "Bearer")
|
||||
if !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
if err == nil {
|
||||
err = core.E("agent.sync.push", "sync push failed", nil)
|
||||
}
|
||||
return SyncPushOutput{}, err
|
||||
}
|
||||
|
||||
return SyncPushOutput{Success: true, Count: len(dispatches)}, nil
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) syncPull(ctx context.Context, agentID string) (SyncPullOutput, error) {
|
||||
if agentID == "" {
|
||||
agentID = AgentName()
|
||||
}
|
||||
token := s.syncToken()
|
||||
if token == "" {
|
||||
return SyncPullOutput{}, core.E("agent.sync.pull", "api token is required", nil)
|
||||
}
|
||||
|
||||
endpoint := core.Concat(s.syncAPIURL(), "/v1/agent/context?agent_id=", agentID)
|
||||
result := HTTPGet(ctx, endpoint, token, "Bearer")
|
||||
if !result.OK {
|
||||
err, _ := result.Value.(error)
|
||||
if err == nil {
|
||||
err = core.E("agent.sync.pull", "sync pull failed", nil)
|
||||
}
|
||||
return SyncPullOutput{}, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
parseResult := core.JSONUnmarshalString(result.Value.(string), &response)
|
||||
if !parseResult.OK {
|
||||
err, _ := parseResult.Value.(error)
|
||||
if err == nil {
|
||||
err = core.E("agent.sync.pull", "failed to parse sync response", nil)
|
||||
}
|
||||
return SyncPullOutput{}, err
|
||||
}
|
||||
|
||||
return SyncPullOutput{
|
||||
Success: true,
|
||||
Count: len(response.Data),
|
||||
Context: response.Data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) syncAPIURL() string {
|
||||
if value := core.Env("CORE_API_URL"); value != "" {
|
||||
return value
|
||||
}
|
||||
if s != nil && s.brainURL != "" {
|
||||
return s.brainURL
|
||||
}
|
||||
return "https://api.lthn.sh"
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) syncToken() string {
|
||||
if value := core.Env("CORE_AGENT_API_KEY"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := core.Env("CORE_BRAIN_KEY"); value != "" {
|
||||
return value
|
||||
}
|
||||
if s != nil && s.brainKey != "" {
|
||||
return s.brainKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectSyncDispatches() []map[string]any {
|
||||
var dispatches []map[string]any
|
||||
for _, path := range WorkspaceStatusPaths() {
|
||||
workspaceDir := core.PathDir(path)
|
||||
statusResult := ReadStatusResult(workspaceDir)
|
||||
workspaceStatus, ok := workspaceStatusValue(statusResult)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !shouldSyncStatus(workspaceStatus.Status) {
|
||||
continue
|
||||
}
|
||||
dispatches = append(dispatches, map[string]any{
|
||||
"workspace": WorkspaceName(workspaceDir),
|
||||
"repo": workspaceStatus.Repo,
|
||||
"org": workspaceStatus.Org,
|
||||
"task": workspaceStatus.Task,
|
||||
"agent": workspaceStatus.Agent,
|
||||
"branch": workspaceStatus.Branch,
|
||||
"status": workspaceStatus.Status,
|
||||
"pr_url": workspaceStatus.PRURL,
|
||||
"started_at": workspaceStatus.StartedAt,
|
||||
"updated_at": workspaceStatus.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return dispatches
|
||||
}
|
||||
|
||||
func shouldSyncStatus(status string) bool {
|
||||
switch status {
|
||||
case "completed", "merged", "failed", "blocked":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
13
pkg/agentic/sync_example_test.go
Normal file
13
pkg/agentic/sync_example_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package agentic
|
||||
|
||||
import "fmt"
|
||||
|
||||
func Example_shouldSyncStatus() {
|
||||
fmt.Println(shouldSyncStatus("completed"))
|
||||
fmt.Println(shouldSyncStatus("running"))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
}
|
||||
98
pkg/agentic/sync_test.go
Normal file
98
pkg/agentic/sync_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSync_HandleSyncPush_Good(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("CORE_WORKSPACE", root)
|
||||
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
|
||||
|
||||
workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5")
|
||||
fs.EnsureDir(workspaceDir)
|
||||
writeStatusResult(workspaceDir, &WorkspaceStatus{
|
||||
Status: "completed",
|
||||
Agent: "codex",
|
||||
Repo: "go-io",
|
||||
Org: "core",
|
||||
Task: "Fix tests",
|
||||
Branch: "agent/fix-tests",
|
||||
StartedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v1/agent/sync", r.URL.Path)
|
||||
require.Equal(t, "Bearer secret-token", r.Header.Get("Authorization"))
|
||||
bodyResult := core.ReadAll(r.Body)
|
||||
require.True(t, bodyResult.OK)
|
||||
|
||||
var payload map[string]any
|
||||
parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload)
|
||||
require.True(t, parseResult.OK)
|
||||
require.Equal(t, AgentName(), payload["agent_id"])
|
||||
dispatches, ok := payload["dispatches"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, dispatches, 1)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"synced":1}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
subsystem := &PrepSubsystem{
|
||||
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
|
||||
brainURL: server.URL,
|
||||
}
|
||||
output, err := subsystem.syncPush(context.Background(), "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, output.Success)
|
||||
assert.Equal(t, 1, output.Count)
|
||||
}
|
||||
|
||||
func TestSync_HandleSyncPull_Bad(t *testing.T) {
|
||||
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v1/agent/context", r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
subsystem := &PrepSubsystem{
|
||||
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
|
||||
brainURL: server.URL,
|
||||
}
|
||||
_, err := subsystem.syncPull(context.Background(), "codex")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSync_HandleSyncPull_Ugly(t *testing.T) {
|
||||
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v1/agent/context", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{this is not json`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
subsystem := &PrepSubsystem{
|
||||
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
|
||||
brainURL: server.URL,
|
||||
}
|
||||
_, err := subsystem.syncPull(context.Background(), "codex")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
@ -41,6 +41,13 @@ type PRMerged struct {
|
|||
PRNum int
|
||||
}
|
||||
|
||||
// c.ACTION(messages.WorkspacePushed{Repo: "go-io", Branch: "agent/fix-tests", Org: "core"})
|
||||
type WorkspacePushed struct {
|
||||
Repo string
|
||||
Branch string
|
||||
Org string
|
||||
}
|
||||
|
||||
// c.ACTION(messages.PRNeedsReview{Repo: "go-io", PRNum: 12, Reason: "merge conflict"})
|
||||
type PRNeedsReview struct {
|
||||
Repo string
|
||||
|
|
@ -84,9 +91,8 @@ type HarvestRejected struct {
|
|||
Reason string
|
||||
}
|
||||
|
||||
// c.ACTION(messages.InboxMessage{From: "charon", Subject: "status", Content: "all green"})
|
||||
// c.ACTION(messages.InboxMessage{New: 2, Total: 5})
|
||||
type InboxMessage struct {
|
||||
From string
|
||||
Subject string
|
||||
Content string
|
||||
New int
|
||||
Total int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,3 +32,9 @@ func ExampleQueueDrained() {
|
|||
fmt.Println(ev.Completed)
|
||||
// Output: 3
|
||||
}
|
||||
|
||||
func ExampleWorkspacePushed() {
|
||||
ev := WorkspacePushed{Repo: "go-io", Branch: "agent/fix-tests", Org: "core"}
|
||||
fmt.Println(ev.Repo, ev.Org)
|
||||
// Output: go-io core
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,17 @@ func TestMessages_AllSatisfyMessage_Good(t *testing.T) {
|
|||
QAResult{Workspace: "core/go-io/task-5", Repo: "go-io", Passed: true},
|
||||
PRCreated{Repo: "go-io", Branch: "agent/fix", PRURL: "https://forge.lthn.ai/core/go-io/pulls/1", PRNum: 1},
|
||||
PRMerged{Repo: "go-io", PRURL: "https://forge.lthn.ai/core/go-io/pulls/1", PRNum: 1},
|
||||
WorkspacePushed{Repo: "go-io", Branch: "agent/fix", Org: "core"},
|
||||
PRNeedsReview{Repo: "go-io", PRNum: 1, Reason: "merge conflict"},
|
||||
QueueDrained{Completed: 3},
|
||||
PokeQueue{},
|
||||
RateLimitDetected{Pool: "codex", Duration: "30m"},
|
||||
HarvestComplete{Repo: "go-io", Branch: "agent/fix", Files: 5},
|
||||
HarvestRejected{Repo: "go-io", Branch: "agent/fix", Reason: "binary detected"},
|
||||
InboxMessage{From: "charon", Subject: "test", Content: "hello"},
|
||||
InboxMessage{New: 1, Total: 3},
|
||||
}
|
||||
|
||||
assert.Len(t, msgs, 12, "expected 12 message types")
|
||||
assert.Len(t, msgs, 13, "expected 13 message types")
|
||||
for _, msg := range msgs {
|
||||
assert.NotNil(t, msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -447,6 +447,10 @@ func (m *Subsystem) checkInbox() string {
|
|||
}
|
||||
|
||||
if m.ServiceRuntime != nil {
|
||||
m.Core().ACTION(messages.InboxMessage{
|
||||
New: len(inboxMessages),
|
||||
Total: unread,
|
||||
})
|
||||
if notifierResult := m.Core().Service("mcp"); notifierResult.OK {
|
||||
if notifier, ok := notifierResult.Value.(channelSender); ok {
|
||||
for _, inboxMessage := range inboxMessages {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue