From cc1c4c1adceeae76d0ca29090fe5493e2a07e391 Mon Sep 17 00:00:00 2001 From: Clotho Date: Mon, 23 Feb 2026 15:11:55 +0000 Subject: [PATCH] feat: add plan archival with retention policy (#34) - Add `agentic.plan_retention_days` config (default 90 days via AGENTIC_PLAN_RETENTION_DAYS env) - Add SoftDeletes and `archived_at` timestamp to AgentPlan model - Add migration for `deleted_at` and `archived_at` columns on agent_plans - Create `agentic:plan-cleanup` command with --dry-run and --days options - Schedule retention cleanup to run daily via service provider - Register PlanRetentionCommand in ConsoleBooting handler - Add PlanRetentionTest feature test suite covering all retention scenarios - Fix archive() to store archived_at as dedicated column (not metadata string) Co-Authored-By: Claude Sonnet 4.6 --- Boot.php | 19 ++ Console/Commands/PlanRetentionCommand.php | 61 ++++++ ...000006_add_soft_deletes_to_agent_plans.php | 33 ++++ Models/AgentPlan.php | 8 +- agentic.php | 21 +++ tests/Feature/AgentPlanTest.php | 2 +- tests/Feature/PlanRetentionTest.php | 177 ++++++++++++++++++ 7 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 Console/Commands/PlanRetentionCommand.php create mode 100644 Migrations/0001_01_01_000006_add_soft_deletes_to_agent_plans.php create mode 100644 agentic.php create mode 100644 tests/Feature/PlanRetentionTest.php diff --git a/Boot.php b/Boot.php index 575cd41..7234470 100644 --- a/Boot.php +++ b/Boot.php @@ -8,6 +8,7 @@ use Core\Events\AdminPanelBooting; use Core\Events\ConsoleBooting; use Core\Events\McpToolsRegistering; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; @@ -32,6 +33,18 @@ class Boot extends ServiceProvider $this->loadMigrationsFrom(__DIR__.'/Migrations'); $this->loadTranslationsFrom(__DIR__.'/Lang', 'agentic'); $this->configureRateLimiting(); + $this->scheduleRetentionCleanup(); + } + + /** + * Register the daily retention cleanup schedule. + */ + protected function scheduleRetentionCleanup(): void + { + $this->app->booted(function (): void { + $schedule = $this->app->make(Schedule::class); + $schedule->command('agentic:plan-cleanup')->daily(); + }); } /** @@ -53,6 +66,11 @@ class Boot extends ServiceProvider 'mcp' ); + $this->mergeConfigFrom( + __DIR__.'/agentic.php', + 'agentic' + ); + $this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class); } @@ -95,6 +113,7 @@ class Boot extends ServiceProvider $event->command(Console\Commands\TaskCommand::class); $event->command(Console\Commands\PlanCommand::class); $event->command(Console\Commands\GenerateCommand::class); + $event->command(Console\Commands\PlanRetentionCommand::class); } /** diff --git a/Console/Commands/PlanRetentionCommand.php b/Console/Commands/PlanRetentionCommand.php new file mode 100644 index 0000000..5f746ee --- /dev/null +++ b/Console/Commands/PlanRetentionCommand.php @@ -0,0 +1,61 @@ +option('days') ?? config('agentic.plan_retention_days', 90)); + + if ($days <= 0) { + $this->info('Retention cleanup is disabled (plan_retention_days is 0).'); + + return self::SUCCESS; + } + + $cutoff = now()->subDays($days); + + $query = AgentPlan::where('status', AgentPlan::STATUS_ARCHIVED) + ->whereNotNull('archived_at') + ->where('archived_at', '<', $cutoff); + + $count = $query->count(); + + if ($count === 0) { + $this->info('No archived plans found past the retention period.'); + + return self::SUCCESS; + } + + if ($this->option('dry-run')) { + $this->info("DRY RUN: {$count} archived plan(s) would be permanently deleted (archived before {$cutoff->toDateString()})."); + + return self::SUCCESS; + } + + $deleted = 0; + + $query->chunkById(100, function ($plans) use (&$deleted): void { + foreach ($plans as $plan) { + $plan->forceDelete(); + $deleted++; + } + }); + + $this->info("Permanently deleted {$deleted} archived plan(s) archived before {$cutoff->toDateString()}."); + + return self::SUCCESS; + } +} diff --git a/Migrations/0001_01_01_000006_add_soft_deletes_to_agent_plans.php b/Migrations/0001_01_01_000006_add_soft_deletes_to_agent_plans.php new file mode 100644 index 0000000..94c9df3 --- /dev/null +++ b/Migrations/0001_01_01_000006_add_soft_deletes_to_agent_plans.php @@ -0,0 +1,33 @@ +timestamp('archived_at')->nullable()->after('source_file'); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::table('agent_plans', function (Blueprint $table) { + $table->dropColumn('archived_at'); + $table->dropSoftDeletes(); + }); + } +}; diff --git a/Models/AgentPlan.php b/Models/AgentPlan.php index fc071c7..5bb118a 100644 --- a/Models/AgentPlan.php +++ b/Models/AgentPlan.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -33,6 +34,8 @@ use Spatie\Activitylog\Traits\LogsActivity; * @property string|null $current_phase * @property array|null $metadata * @property string|null $source_file + * @property \Carbon\Carbon|null $archived_at + * @property \Carbon\Carbon|null $deleted_at * @property \Carbon\Carbon|null $created_at * @property \Carbon\Carbon|null $updated_at */ @@ -44,6 +47,7 @@ class AgentPlan extends Model use HasFactory; use LogsActivity; + use SoftDeletes; protected static function newFactory(): AgentPlanFactory { @@ -61,12 +65,14 @@ class AgentPlan extends Model 'current_phase', 'metadata', 'source_file', + 'archived_at', ]; protected $casts = [ 'context' => 'array', 'phases' => 'array', 'metadata' => 'array', + 'archived_at' => 'datetime', ]; // Status constants @@ -166,11 +172,11 @@ class AgentPlan extends Model $metadata = $this->metadata ?? []; if ($reason) { $metadata['archive_reason'] = $reason; - $metadata['archived_at'] = now()->toIso8601String(); } $this->update([ 'status' => self::STATUS_ARCHIVED, + 'archived_at' => now(), 'metadata' => $metadata, ]); diff --git a/agentic.php b/agentic.php new file mode 100644 index 0000000..689c61c --- /dev/null +++ b/agentic.php @@ -0,0 +1,21 @@ + env('AGENTIC_PLAN_RETENTION_DAYS', 90), + +]; diff --git a/tests/Feature/AgentPlanTest.php b/tests/Feature/AgentPlanTest.php index 67d6da2..07b1622 100644 --- a/tests/Feature/AgentPlanTest.php +++ b/tests/Feature/AgentPlanTest.php @@ -76,7 +76,7 @@ class AgentPlanTest extends TestCase $fresh = $plan->fresh(); $this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status); $this->assertEquals('No longer needed', $fresh->metadata['archive_reason']); - $this->assertNotNull($fresh->metadata['archived_at']); + $this->assertNotNull($fresh->archived_at); } public function test_it_generates_unique_slugs(): void diff --git a/tests/Feature/PlanRetentionTest.php b/tests/Feature/PlanRetentionTest.php new file mode 100644 index 0000000..cec9141 --- /dev/null +++ b/tests/Feature/PlanRetentionTest.php @@ -0,0 +1,177 @@ +workspace = Workspace::factory()->create(); + } + + public function test_cleanup_permanently_deletes_archived_plans_past_retention(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(91), + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 90]) + ->assertSuccessful(); + + $this->assertNull(AgentPlan::withTrashed()->find($plan->id)); + } + + public function test_cleanup_keeps_recently_archived_plans(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(10), + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 90]) + ->assertSuccessful(); + + $this->assertNotNull(AgentPlan::find($plan->id)); + } + + public function test_cleanup_does_not_affect_non_archived_plans(): void + { + $active = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ACTIVE, + ]); + + $draft = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_DRAFT, + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 1]) + ->assertSuccessful(); + + $this->assertNotNull(AgentPlan::find($active->id)); + $this->assertNotNull(AgentPlan::find($draft->id)); + } + + public function test_cleanup_skips_archived_plans_without_archived_at(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => null, + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 1]) + ->assertSuccessful(); + + $this->assertNotNull(AgentPlan::find($plan->id)); + } + + public function test_dry_run_does_not_delete_plans(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(100), + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 90, '--dry-run' => true]) + ->assertSuccessful(); + + $this->assertNotNull(AgentPlan::find($plan->id)); + } + + public function test_dry_run_reports_count(): void + { + AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(100), + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 90, '--dry-run' => true]) + ->expectsOutputToContain('DRY RUN') + ->assertSuccessful(); + } + + public function test_cleanup_disabled_when_days_is_zero(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(1000), + ]); + + $this->artisan('agentic:plan-cleanup', ['--days' => 0]) + ->assertSuccessful(); + + $this->assertNotNull(AgentPlan::find($plan->id)); + } + + public function test_uses_config_retention_days_by_default(): void + { + config(['agentic.plan_retention_days' => 30]); + + $old = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(31), + ]); + + $recent = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'status' => AgentPlan::STATUS_ARCHIVED, + 'archived_at' => now()->subDays(10), + ]); + + $this->artisan('agentic:plan-cleanup') + ->assertSuccessful(); + + $this->assertNull(AgentPlan::withTrashed()->find($old->id)); + $this->assertNotNull(AgentPlan::find($recent->id)); + } + + public function test_archive_sets_archived_at(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->assertNull($plan->archived_at); + + $plan->archive('test reason'); + + $fresh = $plan->fresh(); + $this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status); + $this->assertNotNull($fresh->archived_at); + $this->assertEquals('test reason', $fresh->metadata['archive_reason']); + } + + public function test_archive_without_reason_still_sets_archived_at(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $plan->archive(); + + $fresh = $plan->fresh(); + $this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status); + $this->assertNotNull($fresh->archived_at); + } +}