Merge pull request 'feat: add plan archival with retention policy' (#62) from feat/plan-retention-policy into main
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 2s

This commit is contained in:
Charon 2026-02-24 13:20:38 +00:00
commit a9a6e258e1
7 changed files with 319 additions and 2 deletions

View file

@ -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);
}
/**

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class PlanRetentionCommand extends Command
{
protected $signature = 'agentic:plan-cleanup
{--dry-run : Preview deletions without making changes}
{--days= : Override retention period (overrides agentic.plan_retention_days config)}';
protected $description = 'Permanently delete archived plans past the retention period';
public function handle(): int
{
$days = (int) ($this->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;
}
}

View file

@ -0,0 +1,33 @@
<?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
{
/**
* Add soft delete support and archived_at timestamp to agent_plans.
*
* - archived_at: dedicated timestamp for when a plan was archived, used by
* the retention cleanup command to determine when to permanently delete.
* - deleted_at: standard Laravel soft-delete column.
*/
public function up(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->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();
});
}
};

View file

@ -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,
]);

21
agentic.php Normal file
View file

@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Plan Retention Policy
|--------------------------------------------------------------------------
|
| Archived plans are permanently deleted after this many days. This frees
| up storage and keeps the database lean over time.
|
| Set to 0 or null to disable automatic cleanup entirely.
|
| Default: 90 days
|
*/
'plan_retention_days' => env('AGENTIC_PLAN_RETENTION_DAYS', 90),
];

View file

@ -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

View file

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PlanRetentionTest extends TestCase
{
use RefreshDatabase;
private Workspace $workspace;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}