Merge pull request 'feat: add plan archival with retention policy' (#62) from feat/plan-retention-policy into main
This commit is contained in:
commit
a9a6e258e1
7 changed files with 319 additions and 2 deletions
19
Boot.php
19
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
61
Console/Commands/PlanRetentionCommand.php
Normal file
61
Console/Commands/PlanRetentionCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
21
agentic.php
Normal 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),
|
||||
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
177
tests/Feature/PlanRetentionTest.php
Normal file
177
tests/Feature/PlanRetentionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue