feat(attachments): data layer for task file attachments
TaskAttachmentEntity (+config, cascade FK), TaskAttachmentRepository, and an AttachmentStore that writes files under ~/.todo-app/attachments/<taskId>/ with a path-traversal guard and a 5 MB cap. TaskPromptComposer gains an optional read-only 'Reference files' section. Migration AddTaskAttachments.
This commit is contained in:
73
src/ClaudeDo.Data/AttachmentStore.cs
Normal file
73
src/ClaudeDo.Data/AttachmentStore.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public sealed class AttachmentStore
|
||||
{
|
||||
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
private readonly string _root;
|
||||
|
||||
public AttachmentStore(string? root = null)
|
||||
=> _root = root ?? Paths.Expand("~/.todo-app/attachments");
|
||||
|
||||
public string TaskDir(string taskId)
|
||||
=> Path.Combine(_root, taskId);
|
||||
|
||||
public async Task<long> SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default)
|
||||
{
|
||||
if (Path.GetFileName(fileName) != fileName)
|
||||
throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName));
|
||||
|
||||
var dir = TaskDir(taskId);
|
||||
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
||||
|
||||
// Containment guard: resolved path must stay inside TaskDir
|
||||
var resolvedDir = Path.GetFullPath(dir);
|
||||
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
||||
throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName));
|
||||
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
// Buffer up to MaxBytes + 1 to detect oversize without reading fully
|
||||
await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await content.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
total += read;
|
||||
if (total > MaxBytes)
|
||||
{
|
||||
fs.Close();
|
||||
try { File.Delete(resolvedPath); } catch { }
|
||||
throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit.");
|
||||
}
|
||||
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
public void DeleteFile(string taskId, string fileName)
|
||||
{
|
||||
if (Path.GetFileName(fileName) != fileName)
|
||||
return; // traversal attempt — ignore silently
|
||||
|
||||
var dir = TaskDir(taskId);
|
||||
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
||||
var resolvedDir = Path.GetFullPath(dir);
|
||||
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
||||
return; // containment violation — ignore silently
|
||||
|
||||
try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { }
|
||||
}
|
||||
|
||||
public void DeleteTaskDir(string taskId)
|
||||
{
|
||||
var dir = TaskDir(taskId);
|
||||
try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||||
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TaskAttachmentEntityConfiguration : IEntityTypeConfiguration<TaskAttachmentEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TaskAttachmentEntity> builder)
|
||||
{
|
||||
builder.ToTable("task_attachments");
|
||||
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id");
|
||||
builder.Property(a => a.TaskId).HasColumnName("task_id").IsRequired();
|
||||
builder.Property(a => a.FileName).HasColumnName("file_name").IsRequired();
|
||||
builder.Property(a => a.ByteSize).HasColumnName("byte_size").IsRequired();
|
||||
builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.HasOne(a => a.Task)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.TaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(a => a.TaskId).HasDatabaseName("idx_task_attachments_task_id");
|
||||
}
|
||||
}
|
||||
739
src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs
generated
Normal file
739
src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs
generated
Normal file
@@ -0,0 +1,739 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260622150934_AddTaskAttachments")]
|
||||
partial class AddTaskAttachments
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_attachments",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
file_name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
byte_size = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_attachments", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_attachments_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_task_attachments_task_id",
|
||||
table: "task_attachments",
|
||||
column: "task_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_attachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,38 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -625,6 +657,17 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
|
||||
13
src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs
Normal file
13
src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class TaskAttachmentEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string TaskId { get; init; }
|
||||
public required string FileName { get; set; }
|
||||
public long ByteSize { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
// Navigation property
|
||||
public TaskEntity Task { get; set; } = null!;
|
||||
}
|
||||
45
src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs
Normal file
45
src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TaskAttachmentRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TaskAttachmentRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(TaskAttachmentEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.TaskAttachments.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TaskAttachmentEntity>> ListByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<TaskAttachmentEntity?> GetAsync(string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.TaskAttachments
|
||||
.FirstOrDefaultAsync(a => a.TaskId == taskId && a.FileName == fileName, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId && a.FileName == fileName)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAllForTaskAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ namespace ClaudeDo.Data;
|
||||
/// </summary>
|
||||
public static class TaskPromptComposer
|
||||
{
|
||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks)
|
||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks,
|
||||
IEnumerable<string>? attachmentPaths = null)
|
||||
{
|
||||
var sb = new StringBuilder((title ?? "").Trim());
|
||||
|
||||
@@ -24,6 +25,14 @@ public static class TaskPromptComposer
|
||||
sb.Append("- [ ] ").Append(s.Title).Append('\n');
|
||||
}
|
||||
|
||||
var paths = attachmentPaths?.ToList();
|
||||
if (paths is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n## Reference files\nThese files were attached to this task as read-only reference (they live outside the repo). Read them as needed:\n");
|
||||
foreach (var p in paths)
|
||||
sb.Append("- ").Append(p).Append('\n');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
94
tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs
Normal file
94
tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class AttachmentStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly AttachmentStore _store;
|
||||
|
||||
public AttachmentStoreTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_root);
|
||||
_store = new AttachmentStore(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_then_readback_size_matches()
|
||||
{
|
||||
var bytes = new byte[1024];
|
||||
new Random(42).NextBytes(bytes);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
|
||||
var written = await _store.SaveAsync("task1", "data.bin", ms);
|
||||
|
||||
Assert.Equal(1024, written);
|
||||
var filePath = Path.Combine(_store.TaskDir("task1"), "data.bin");
|
||||
Assert.Equal(1024, new FileInfo(filePath).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_fileName_with_dotdot()
|
||||
{
|
||||
using var ms = new MemoryStream(new byte[10]);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_store.SaveAsync("task1", "../escape.txt", ms));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_fileName_with_directory_separator()
|
||||
{
|
||||
using var ms = new MemoryStream(new byte[10]);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_store.SaveAsync("task1", "sub/file.txt", ms));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_content_over_5MB()
|
||||
{
|
||||
var over = new byte[5 * 1024 * 1024 + 1];
|
||||
using var ms = new MemoryStream(over);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_store.SaveAsync("task1", "big.bin", ms));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteTaskDir_removes_directory()
|
||||
{
|
||||
var dir = _store.TaskDir("task2");
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, "a.txt"), "x");
|
||||
|
||||
_store.DeleteTaskDir("task2");
|
||||
|
||||
Assert.False(Directory.Exists(dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_missing_file_is_noop()
|
||||
{
|
||||
// Should not throw
|
||||
_store.DeleteFile("taskX", "nonexistent.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteFile_removes_file_and_ignores_missing_second_call()
|
||||
{
|
||||
var dir = _store.TaskDir("task3");
|
||||
Directory.CreateDirectory(dir);
|
||||
var filePath = Path.Combine(dir, "remove.txt");
|
||||
File.WriteAllText(filePath, "data");
|
||||
|
||||
_store.DeleteFile("task3", "remove.txt");
|
||||
Assert.False(File.Exists(filePath));
|
||||
|
||||
// Second call must not throw
|
||||
_store.DeleteFile("task3", "remove.txt");
|
||||
}
|
||||
}
|
||||
116
tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs
Normal file
116
tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class TaskAttachmentRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskAttachmentRepository _repo;
|
||||
|
||||
private const string ListId = "l1";
|
||||
private const string TaskId = "t1";
|
||||
|
||||
public TaskAttachmentRepositoryTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
_ctx = new ClaudeDoDbContext(options);
|
||||
_ctx.Database.EnsureCreated();
|
||||
_repo = new TaskAttachmentRepository(_ctx);
|
||||
|
||||
_ctx.Lists.Add(new ListEntity { Id = ListId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
||||
_ctx.Tasks.Add(new TaskEntity { Id = TaskId, ListId = ListId, Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow });
|
||||
_ctx.SaveChanges();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(_dbPath + suffix); } catch { }
|
||||
}
|
||||
|
||||
private TaskAttachmentEntity MakeAttachment(string fileName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = TaskId,
|
||||
FileName = fileName,
|
||||
ByteSize = 100,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Add_ListByTaskId_Delete_roundtrip()
|
||||
{
|
||||
var a1 = MakeAttachment("file1.txt");
|
||||
var a2 = MakeAttachment("file2.txt");
|
||||
|
||||
await _repo.AddAsync(a1);
|
||||
await _repo.AddAsync(a2);
|
||||
|
||||
var list = await _repo.ListByTaskIdAsync(TaskId);
|
||||
Assert.Equal(2, list.Count);
|
||||
Assert.Contains(list, a => a.FileName == "file1.txt");
|
||||
Assert.Contains(list, a => a.FileName == "file2.txt");
|
||||
|
||||
await _repo.DeleteAsync(TaskId, "file1.txt");
|
||||
|
||||
var afterDelete = await _repo.ListByTaskIdAsync(TaskId);
|
||||
Assert.Single(afterDelete);
|
||||
Assert.Equal("file2.txt", afterDelete[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_correct_entity()
|
||||
{
|
||||
var a = MakeAttachment("target.txt");
|
||||
await _repo.AddAsync(a);
|
||||
|
||||
var found = await _repo.GetAsync(TaskId, "target.txt");
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(a.Id, found!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_null_when_missing()
|
||||
{
|
||||
var result = await _repo.GetAsync(TaskId, "nope.txt");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllForTask_clears_all_rows_for_task()
|
||||
{
|
||||
await _repo.AddAsync(MakeAttachment("a.txt"));
|
||||
await _repo.AddAsync(MakeAttachment("b.txt"));
|
||||
await _repo.AddAsync(MakeAttachment("c.txt"));
|
||||
|
||||
await _repo.DeleteAllForTaskAsync(TaskId);
|
||||
|
||||
var list = await _repo.ListByTaskIdAsync(TaskId);
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByTaskId_ordered_by_created_at()
|
||||
{
|
||||
var base_ = DateTime.UtcNow;
|
||||
var a1 = new TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = TaskId, FileName = "first.txt", ByteSize = 1, CreatedAt = base_ };
|
||||
var a2 = new TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = TaskId, FileName = "second.txt", ByteSize = 1, CreatedAt = base_.AddSeconds(1) };
|
||||
|
||||
await _repo.AddAsync(a2);
|
||||
await _repo.AddAsync(a1);
|
||||
|
||||
var list = await _repo.ListByTaskIdAsync(TaskId);
|
||||
Assert.Equal("first.txt", list[0].FileName);
|
||||
Assert.Equal("second.txt", list[1].FileName);
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,44 @@ public class TaskPromptComposerTests
|
||||
{
|
||||
Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attachment_section_present_when_paths_given()
|
||||
{
|
||||
var result = TaskPromptComposer.Compose(
|
||||
"Title", null, System.Array.Empty<(string, bool)>(),
|
||||
new[] { "/a/b/file1.txt", "/a/b/file2.txt" });
|
||||
|
||||
Assert.Contains("## Reference files", result);
|
||||
Assert.Contains("- /a/b/file1.txt", result);
|
||||
Assert.Contains("- /a/b/file2.txt", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attachment_section_absent_when_null()
|
||||
{
|
||||
var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), null);
|
||||
Assert.DoesNotContain("Reference files", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attachment_section_absent_when_empty()
|
||||
{
|
||||
var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(),
|
||||
System.Array.Empty<string>());
|
||||
Assert.DoesNotContain("Reference files", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attachment_paths_order_preserved()
|
||||
{
|
||||
var paths = new[] { "/z/last.txt", "/a/first.txt", "/m/middle.txt" };
|
||||
var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), paths);
|
||||
|
||||
var idxZ = result.IndexOf("/z/last.txt", StringComparison.Ordinal);
|
||||
var idxA = result.IndexOf("/a/first.txt", StringComparison.Ordinal);
|
||||
var idxM = result.IndexOf("/m/middle.txt", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(idxZ < idxA && idxA < idxM, "Paths should appear in the original order.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user