feat(agent/php): AgentProfile migration + Eloquent model + scopes (#825)

Adds Phase 3 foundation: agent_profiles table (Postgres-compatible, FK-free),
AgentProfile Eloquent model with encrypted api_key_cipher cast, array
capability_tags cast, datetime last_dispatched_at cast, active() and
forClass(string) local scopes.

AX-10 Pest Feature test covers:
- active() returns only enabled+headroom>0 rows
- active()->forClass('A') chains correctly
- api_key_cipher round-trips via encrypted cast
- capability_tags round-trips as array

Codex note: php -l clean on all 3 files; pest+artisan unavailable at
repo root for runtime verification (composer + bootstrap live downstream
in lab/lthn.ai).

Closes tasks.lthn.sh/view.php?id=825

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Snider 2026-04-26 00:34:18 +01:00
parent 084e69357f
commit 38dcb17083
3 changed files with 218 additions and 0 deletions

View file

@ -0,0 +1,37 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
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
{
if (Schema::hasTable('agent_profiles')) {
return;
}
Schema::create('agent_profiles', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('name')->unique();
$table->string('gateway_url');
$table->text('api_key_cipher');
$table->string('cost_class', 1);
$table->json('capability_tags');
$table->integer('quota_headroom_pct')->default(100);
$table->boolean('enabled')->default(true);
$table->timestamp('last_dispatched_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('agent_profiles');
}
};

View file

@ -0,0 +1,43 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class AgentProfile extends Model
{
protected $fillable = [
'name',
'gateway_url',
'api_key_cipher',
'cost_class',
'capability_tags',
'quota_headroom_pct',
'enabled',
'last_dispatched_at',
];
protected $casts = [
'api_key_cipher' => 'encrypted',
'capability_tags' => 'array',
'quota_headroom_pct' => 'integer',
'enabled' => 'boolean',
'last_dispatched_at' => 'datetime',
];
public function scopeActive(Builder $query): Builder
{
return $query->where('enabled', true)
->where('quota_headroom_pct', '>', 0);
}
public function scopeForClass(Builder $query, string $class): Builder
{
return $query->where('cost_class', $class);
}
}

View file

@ -0,0 +1,138 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Models\AgentProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
if (! is_string(config('app.key')) || config('app.key') === '') {
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
}
});
it('returns only enabled profiles with remaining headroom from the active scope', function () {
$activeProfile = AgentProfile::create([
'name' => 'active-profile',
'gateway_url' => 'https://gateway-a.example.com',
'api_key_cipher' => 'secret-active',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 100,
'enabled' => true,
]);
AgentProfile::create([
'name' => 'disabled-profile',
'gateway_url' => 'https://gateway-b.example.com',
'api_key_cipher' => 'secret-disabled',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 100,
'enabled' => false,
]);
AgentProfile::create([
'name' => 'exhausted-profile',
'gateway_url' => 'https://gateway-c.example.com',
'api_key_cipher' => 'secret-exhausted',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 0,
'enabled' => true,
]);
$profiles = AgentProfile::active()->get();
expect($profiles)->toHaveCount(1)
->and($profiles->first()->id)->toBe($activeProfile->id);
});
it('chains the active and forClass scopes correctly', function () {
$matchingProfile = AgentProfile::create([
'name' => 'class-a-active',
'gateway_url' => 'https://gateway-d.example.com',
'api_key_cipher' => 'secret-class-a',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 75,
'enabled' => true,
]);
AgentProfile::create([
'name' => 'class-b-active',
'gateway_url' => 'https://gateway-e.example.com',
'api_key_cipher' => 'secret-class-b',
'cost_class' => 'B',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 75,
'enabled' => true,
]);
AgentProfile::create([
'name' => 'class-a-disabled',
'gateway_url' => 'https://gateway-f.example.com',
'api_key_cipher' => 'secret-class-a-disabled',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 75,
'enabled' => false,
]);
AgentProfile::create([
'name' => 'class-a-exhausted',
'gateway_url' => 'https://gateway-g.example.com',
'api_key_cipher' => 'secret-class-a-exhausted',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 0,
'enabled' => true,
]);
$profiles = AgentProfile::active()->forClass('A')->get();
expect($profiles)->toHaveCount(1)
->and($profiles->first()->id)->toBe($matchingProfile->id);
});
it('round-trips the api_key_cipher attribute through the encrypted cast', function () {
$profile = AgentProfile::create([
'name' => 'encrypted-profile',
'gateway_url' => 'https://gateway-h.example.com',
'api_key_cipher' => 'plain-secret-value',
'cost_class' => 'A',
'capability_tags' => ['dispatch', 'review'],
'quota_headroom_pct' => 100,
'enabled' => true,
]);
$rawCiphertext = DB::table('agent_profiles')
->where('id', $profile->id)
->value('api_key_cipher');
expect($rawCiphertext)->not->toBe('plain-secret-value')
->and($profile->fresh()->api_key_cipher)->toBe('plain-secret-value');
});
it('round-trips capability tags as an array', function () {
$profile = AgentProfile::create([
'name' => 'tagged-profile',
'gateway_url' => 'https://gateway-i.example.com',
'api_key_cipher' => 'plain-secret-tags',
'cost_class' => 'C',
'capability_tags' => ['dispatch', 'analysis', 'handoff'],
'quota_headroom_pct' => 100,
'enabled' => true,
]);
expect($profile->fresh()->capability_tags)->toBe([
'dispatch',
'analysis',
'handoff',
]);
});