From 38dcb170834fb3e5750876645e5aca159472df11 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 26 Apr 2026 00:34:18 +0100 Subject: [PATCH] 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 --- ..._25_000001_create_agent_profiles_table.php | 37 +++++ php/Models/AgentProfile.php | 43 ++++++ php/tests/Feature/AgentProfileTest.php | 138 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 php/Migrations/2026_04_25_000001_create_agent_profiles_table.php create mode 100644 php/Models/AgentProfile.php create mode 100644 php/tests/Feature/AgentProfileTest.php diff --git a/php/Migrations/2026_04_25_000001_create_agent_profiles_table.php b/php/Migrations/2026_04_25_000001_create_agent_profiles_table.php new file mode 100644 index 0000000..e2d73c2 --- /dev/null +++ b/php/Migrations/2026_04_25_000001_create_agent_profiles_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/php/Models/AgentProfile.php b/php/Models/AgentProfile.php new file mode 100644 index 0000000..98be511 --- /dev/null +++ b/php/Models/AgentProfile.php @@ -0,0 +1,43 @@ + '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); + } +} diff --git a/php/tests/Feature/AgentProfileTest.php b/php/tests/Feature/AgentProfileTest.php new file mode 100644 index 0000000..38eb62a --- /dev/null +++ b/php/tests/Feature/AgentProfileTest.php @@ -0,0 +1,138 @@ + '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', + ]); +});