feat(attachments): inject reference files into the run + clean up files on delete

TaskRunner appends attached files (absolute paths) to the run prompt as the
read-only Reference files section. Task and list deletes now remove the
on-disk attachment dir eagerly, and a startup AttachmentOrphanRecovery sweep
drops any attachments/<taskId>/ whose task no longer exists (covers list
cascade and planning-discard paths).
This commit is contained in:
Mika Kuns
2026-06-22 17:22:36 +02:00
parent 5be4b5c5fb
commit 6a0c0f59a5
15 changed files with 265 additions and 12 deletions

View File

@@ -0,0 +1,85 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests;
/// <summary>
/// Verifies that TaskRepository.DeleteAsync and ListRepository.DeleteAsync
/// delete the on-disk attachment directories for the affected tasks.
/// </summary>
public sealed class RepositoryAttachmentCleanupTests : IDisposable
{
private readonly string _dbPath;
private readonly string _attachRoot;
private readonly ClaudeDoDbContext _ctx;
private readonly AttachmentStore _store;
public RepositoryAttachmentCleanupTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_repoclean_{Guid.NewGuid():N}.db");
_attachRoot = Path.Combine(Path.GetTempPath(), $"claudedo_repoclean_att_{Guid.NewGuid():N}");
Directory.CreateDirectory(_attachRoot);
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
_store = new AttachmentStore(_attachRoot);
}
public void Dispose()
{
_ctx.Dispose();
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
try { Directory.Delete(_attachRoot, recursive: true); } catch { }
}
private async Task<(string listId, string taskId)> SeedAsync()
{
var listId = Guid.NewGuid().ToString();
var taskId = Guid.NewGuid().ToString();
_ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
_ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
return (listId, taskId);
}
[Fact]
public async Task TaskRepository_DeleteAsync_RemovesAttachmentDir()
{
var (_, taskId) = await SeedAsync();
var taskDir = _store.TaskDir(taskId);
Directory.CreateDirectory(taskDir);
Assert.True(Directory.Exists(taskDir));
var repo = new TaskRepository(_ctx, _store);
await repo.DeleteAsync(taskId);
Assert.False(Directory.Exists(taskDir));
}
[Fact]
public async Task ListRepository_DeleteAsync_RemovesAttachmentDirForEachTask()
{
var (listId, taskId) = await SeedAsync();
var taskDir = _store.TaskDir(taskId);
Directory.CreateDirectory(taskDir);
Assert.True(Directory.Exists(taskDir));
var repo = new ListRepository(_ctx, _store);
await repo.DeleteAsync(listId);
Assert.False(Directory.Exists(taskDir));
}
}