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:
Mika Kuns
2026-06-22 17:10:51 +02:00
parent 5231ad6b86
commit 3f9f047955
12 changed files with 1249 additions and 1 deletions

View 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) { }
}
}

View File

@@ -46,6 +46,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>(); public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>(); public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>(); public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>(); public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>(); public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();

View File

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

View 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
}
}
}

View File

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

View File

@@ -294,6 +294,38 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("subtasks", (string)null); 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 => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -625,6 +657,17 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("Task"); 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 => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)

View 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!;
}

View 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);
}
}

View File

@@ -9,7 +9,8 @@ namespace ClaudeDo.Data;
/// </summary> /// </summary>
public static class TaskPromptComposer 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()); var sb = new StringBuilder((title ?? "").Trim());
@@ -24,6 +25,14 @@ public static class TaskPromptComposer
sb.Append("- [ ] ").Append(s.Title).Append('\n'); 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(); return sb.ToString();
} }
} }

View 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");
}
}

View 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);
}
}

View File

@@ -42,4 +42,44 @@ public class TaskPromptComposerTests
{ {
Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>())); 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.");
}
} }