New artisan command brain:prune {--older-than=90} {--chunk=100} {--dry-run}
that completes the soft-delete → hard-delete lifecycle by:
1. selecting BrainMemory::onlyTrashed() where deleted_at < now - N days
2. dispatching DeleteFromIndex for each (Qdrant + ES cleanup)
3. forceDelete()'ing the rows
--dry-run counts without dispatching.
Complements brain:clean (which cleans recent soft-deletes) with a
retention-bounded terminal cleanup.
Pest coverage: Good (dispatch + forceDelete on aged trashed rows), Bad
(invalid chunk), Ugly (--dry-run skips both dispatch and delete).
Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=62
118 lines
4 KiB
PHP
118 lines
4 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Core\Mod\Agentic\Console\Commands\BrainPruneCommand;
|
|
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
|
|
use Core\Mod\Agentic\Models\BrainMemory;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
beforeEach(function (): void {
|
|
Carbon::setTestNow(Carbon::parse('2026-04-23 12:00:00'));
|
|
|
|
$this->app->make(Kernel::class)->registerCommand(
|
|
$this->app->make(BrainPruneCommand::class),
|
|
);
|
|
});
|
|
|
|
afterEach(function (): void {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function brainPruneMemory(array $attributes = []): BrainMemory
|
|
{
|
|
return BrainMemory::create(array_merge([
|
|
'workspace_id' => $attributes['workspace_id'] ?? createWorkspace()->id,
|
|
'agent_id' => 'virgil',
|
|
'type' => 'observation',
|
|
'content' => 'Brain prune command test memory.',
|
|
'confidence' => 0.8,
|
|
], $attributes));
|
|
}
|
|
|
|
function brainPruneSoftDelete(BrainMemory $memory, int $daysAgo): BrainMemory
|
|
{
|
|
$memory->delete();
|
|
$memory->forceFill([
|
|
'deleted_at' => now()->subDays($daysAgo),
|
|
])->save();
|
|
|
|
return BrainMemory::onlyTrashed()->findOrFail($memory->id);
|
|
}
|
|
|
|
test('BrainPruneCommand_handle_Good_force_deletes_stale_soft_deleted_memories', function (): void {
|
|
Queue::fake();
|
|
|
|
$workspace = createWorkspace();
|
|
$firstStaleMemory = brainPruneSoftDelete(
|
|
brainPruneMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'content' => 'First stale memory.',
|
|
]),
|
|
91,
|
|
);
|
|
$secondStaleMemory = brainPruneSoftDelete(
|
|
brainPruneMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'content' => 'Second stale memory.',
|
|
]),
|
|
120,
|
|
);
|
|
$recentMemory = brainPruneSoftDelete(
|
|
brainPruneMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'content' => 'Recently deleted memory.',
|
|
]),
|
|
90,
|
|
);
|
|
$activeMemory = brainPruneMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'content' => 'Active memory.',
|
|
]);
|
|
|
|
$this->artisan('brain:prune', ['--older-than' => 90, '--chunk' => 1])
|
|
->expectsOutput('Pruned 2 stale soft-deleted brain memory record(s).')
|
|
->assertSuccessful();
|
|
|
|
Queue::assertPushed(DeleteFromIndex::class, 2);
|
|
Queue::assertPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $firstStaleMemory->id);
|
|
Queue::assertPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $secondStaleMemory->id);
|
|
Queue::assertNotPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $recentMemory->id);
|
|
Queue::assertNotPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $activeMemory->id);
|
|
|
|
expect(BrainMemory::withTrashed()->find($firstStaleMemory->id))->toBeNull()
|
|
->and(BrainMemory::withTrashed()->find($secondStaleMemory->id))->toBeNull()
|
|
->and(BrainMemory::onlyTrashed()->find($recentMemory->id))->not->toBeNull()
|
|
->and(BrainMemory::query()->find($activeMemory->id))->not->toBeNull();
|
|
});
|
|
|
|
test('BrainPruneCommand_handle_Bad_reports_dry_run_without_dispatching_jobs_or_deleting_records', function (): void {
|
|
Queue::fake();
|
|
|
|
$memory = brainPruneSoftDelete(brainPruneMemory(), 180);
|
|
|
|
$this->artisan('brain:prune', ['--dry-run' => true])
|
|
->expectsOutput('DRY RUN: 1 stale soft-deleted brain memory record(s) would be permanently deleted.')
|
|
->assertSuccessful();
|
|
|
|
Queue::assertNotPushed(DeleteFromIndex::class);
|
|
|
|
expect(BrainMemory::onlyTrashed()->find($memory->id))->not->toBeNull();
|
|
});
|
|
|
|
test('BrainPruneCommand_handle_Ugly_rejects_invalid_retention_window', function (): void {
|
|
Queue::fake();
|
|
|
|
brainPruneSoftDelete(brainPruneMemory(), 180);
|
|
|
|
$this->artisan('brain:prune', ['--older-than' => 0])
|
|
->expectsOutput('--older-than must be a positive integer.')
|
|
->assertExitCode(Command::FAILURE);
|
|
|
|
Queue::assertNotPushed(DeleteFromIndex::class);
|
|
});
|