From 8c051d8f620bacff9820ce9a50c7f7aa024a240c Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 11:19:54 +0200 Subject: [PATCH] feat(data): add subtasks table, repository and prompt integration Per-task checklist backend: subtasks table with CASCADE delete, SubtaskEntity + SubtaskRepository (connection-per-op, async), DI registration in App and Worker, TaskRunner composes a '## Sub-Tasks' markdown block into the Claude prompt when subtasks exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- schema/schema.sql | 10 +++ src/ClaudeDo.App/Program.cs | 4 +- src/ClaudeDo.Data/Models/SubtaskEntity.cs | 11 +++ .../Repositories/SubtaskRepository.cs | 81 +++++++++++++++++++ src/ClaudeDo.Worker/Program.cs | 1 + src/ClaudeDo.Worker/Runner/TaskRunner.cs | 16 +++- .../Services/QueueServiceTests.cs | 3 +- 7 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/ClaudeDo.Data/Models/SubtaskEntity.cs create mode 100644 src/ClaudeDo.Data/Repositories/SubtaskRepository.cs diff --git a/schema/schema.sql b/schema/schema.sql index 5f65955..1e75c85 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS task_runs ( CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id); +CREATE TABLE IF NOT EXISTS subtasks ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + title TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + order_num INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id); + -- Seed: minimal tag set (ignored if already present) INSERT OR IGNORE INTO tags (name) VALUES ('agent'); INSERT OR IGNORE INTO tags (name) VALUES ('manual'); diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 7a5ce37..b5442c2 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -49,6 +49,7 @@ sealed class Program // Repositories sc.AddSingleton(); sc.AddSingleton(); + sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); @@ -66,7 +67,8 @@ sealed class Program sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); sc.AddSingleton(sp => { var taskRepo = sp.GetRequiredService(); diff --git a/src/ClaudeDo.Data/Models/SubtaskEntity.cs b/src/ClaudeDo.Data/Models/SubtaskEntity.cs new file mode 100644 index 0000000..dbe0c4c --- /dev/null +++ b/src/ClaudeDo.Data/Models/SubtaskEntity.cs @@ -0,0 +1,11 @@ +namespace ClaudeDo.Data.Models; + +public sealed class SubtaskEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required string Title { get; set; } + public bool Completed { get; set; } + public int OrderNum { get; set; } + public required DateTime CreatedAt { get; init; } +} diff --git a/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs new file mode 100644 index 0000000..68a77b9 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs @@ -0,0 +1,81 @@ +using ClaudeDo.Data.Models; +using Microsoft.Data.Sqlite; + +namespace ClaudeDo.Data.Repositories; + +public sealed class SubtaskRepository +{ + private readonly SqliteConnectionFactory _factory; + + public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory; + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num"; + cmd.Parameters.AddWithValue("@task_id", taskId); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + var result = new List(); + while (await reader.ReadAsync(ct)) + result.Add(ReadSubtask(reader)); + return result; + } + + public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at) + VALUES (@id, @task_id, @title, @completed, @order_num, @created_at) + """; + BindSubtask(cmd, entity); + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", entity.Id); + cmd.Parameters.AddWithValue("@title", entity.Title); + cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0); + cmd.Parameters.AddWithValue("@order_num", entity.OrderNum); + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM subtasks WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(ct); + } + + private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e) + { + cmd.Parameters.AddWithValue("@id", e.Id); + cmd.Parameters.AddWithValue("@task_id", e.TaskId); + cmd.Parameters.AddWithValue("@title", e.Title); + cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0); + cmd.Parameters.AddWithValue("@order_num", e.OrderNum); + cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o")); + } + + private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new() + { + Id = r.GetString(0), + TaskId = r.GetString(1), + Title = r.GetString(2), + Completed = r.GetInt64(3) != 0, + OrderNum = r.GetInt32(4), + CreatedAt = DateTime.Parse(r.GetString(5)), + }; +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 84af2ec..15220a5 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddSingleton(dbFactory); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index d40cad1..63cfcb2 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -12,6 +12,7 @@ public sealed class TaskRunner private readonly TaskRunRepository _runRepo; private readonly ListRepository _listRepo; private readonly WorktreeRepository _wtRepo; + private readonly SubtaskRepository _subtaskRepo; private readonly HubBroadcaster _broadcaster; private readonly WorktreeManager _wtManager; private readonly ClaudeArgsBuilder _argsBuilder; @@ -24,6 +25,7 @@ public sealed class TaskRunner TaskRunRepository runRepo, ListRepository listRepo, WorktreeRepository wtRepo, + SubtaskRepository subtaskRepo, HubBroadcaster broadcaster, WorktreeManager wtManager, ClaudeArgsBuilder argsBuilder, @@ -35,6 +37,7 @@ public sealed class TaskRunner _runRepo = runRepo; _listRepo = listRepo; _wtRepo = wtRepo; + _subtaskRepo = subtaskRepo; _broadcaster = broadcaster; _wtManager = wtManager; _argsBuilder = argsBuilder; @@ -91,9 +94,16 @@ public sealed class TaskRunner await _broadcaster.TaskStarted(slot, task.Id, now); // Build prompt. - var prompt = string.IsNullOrWhiteSpace(task.Description) - ? task.Title - : $"{task.Title}\n\n{task.Description.Trim()}"; + var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct); + var sb = new System.Text.StringBuilder(task.Title); + if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim()); + if (subtasks.Count > 0) + { + sb.Append("\n\n## Sub-Tasks\n"); + foreach (var s in subtasks) + sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n'); + } + var prompt = sb.ToString(); // Run 1. var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 3d3d5c5..fc2fe1d 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -51,7 +51,8 @@ public sealed class QueueServiceTests : IDisposable var runRepo = new TaskRunRepository(_db.Factory); var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); - var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg, + var subtaskRepo = new SubtaskRepository(_db.Factory); + var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg, NullLogger.Instance); var service = new QueueService(_taskRepo, runner, _cfg, NullLogger.Instance); return (service, fake);