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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 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)
|
-- Seed: minimal tag set (ignored if already present)
|
||||||
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
||||||
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ sealed class Program
|
|||||||
// Repositories
|
// Repositories
|
||||||
sc.AddSingleton<ListRepository>();
|
sc.AddSingleton<ListRepository>();
|
||||||
sc.AddSingleton<TaskRepository>();
|
sc.AddSingleton<TaskRepository>();
|
||||||
|
sc.AddSingleton<SubtaskRepository>();
|
||||||
sc.AddSingleton<TagRepository>();
|
sc.AddSingleton<TagRepository>();
|
||||||
sc.AddSingleton<WorktreeRepository>();
|
sc.AddSingleton<WorktreeRepository>();
|
||||||
|
|
||||||
@@ -66,7 +67,8 @@ sealed class Program
|
|||||||
sp.GetRequiredService<ListRepository>(),
|
sp.GetRequiredService<ListRepository>(),
|
||||||
sp.GetRequiredService<GitService>(),
|
sp.GetRequiredService<GitService>(),
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<WorkerClient>(),
|
||||||
sp.GetRequiredService<TagRepository>()));
|
sp.GetRequiredService<TagRepository>(),
|
||||||
|
sp.GetRequiredService<SubtaskRepository>()));
|
||||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
sc.AddSingleton<TaskListViewModel>(sp =>
|
||||||
{
|
{
|
||||||
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
||||||
|
|||||||
11
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
11
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
81
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
81
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
@@ -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<List<SubtaskEntity>> 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<SubtaskEntity>();
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ builder.Services.AddSingleton(dbFactory);
|
|||||||
builder.Services.AddSingleton<TagRepository>();
|
builder.Services.AddSingleton<TagRepository>();
|
||||||
builder.Services.AddSingleton<ListRepository>();
|
builder.Services.AddSingleton<ListRepository>();
|
||||||
builder.Services.AddSingleton<TaskRepository>();
|
builder.Services.AddSingleton<TaskRepository>();
|
||||||
|
builder.Services.AddSingleton<SubtaskRepository>();
|
||||||
builder.Services.AddSingleton<WorktreeRepository>();
|
builder.Services.AddSingleton<WorktreeRepository>();
|
||||||
builder.Services.AddSingleton<TaskRunRepository>();
|
builder.Services.AddSingleton<TaskRunRepository>();
|
||||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class TaskRunner
|
|||||||
private readonly TaskRunRepository _runRepo;
|
private readonly TaskRunRepository _runRepo;
|
||||||
private readonly ListRepository _listRepo;
|
private readonly ListRepository _listRepo;
|
||||||
private readonly WorktreeRepository _wtRepo;
|
private readonly WorktreeRepository _wtRepo;
|
||||||
|
private readonly SubtaskRepository _subtaskRepo;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly WorktreeManager _wtManager;
|
private readonly WorktreeManager _wtManager;
|
||||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||||
@@ -24,6 +25,7 @@ public sealed class TaskRunner
|
|||||||
TaskRunRepository runRepo,
|
TaskRunRepository runRepo,
|
||||||
ListRepository listRepo,
|
ListRepository listRepo,
|
||||||
WorktreeRepository wtRepo,
|
WorktreeRepository wtRepo,
|
||||||
|
SubtaskRepository subtaskRepo,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
WorktreeManager wtManager,
|
WorktreeManager wtManager,
|
||||||
ClaudeArgsBuilder argsBuilder,
|
ClaudeArgsBuilder argsBuilder,
|
||||||
@@ -35,6 +37,7 @@ public sealed class TaskRunner
|
|||||||
_runRepo = runRepo;
|
_runRepo = runRepo;
|
||||||
_listRepo = listRepo;
|
_listRepo = listRepo;
|
||||||
_wtRepo = wtRepo;
|
_wtRepo = wtRepo;
|
||||||
|
_subtaskRepo = subtaskRepo;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_wtManager = wtManager;
|
_wtManager = wtManager;
|
||||||
_argsBuilder = argsBuilder;
|
_argsBuilder = argsBuilder;
|
||||||
@@ -91,9 +94,16 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||||
|
|
||||||
// Build prompt.
|
// Build prompt.
|
||||||
var prompt = string.IsNullOrWhiteSpace(task.Description)
|
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
|
||||||
? task.Title
|
var sb = new System.Text.StringBuilder(task.Title);
|
||||||
: $"{task.Title}\n\n{task.Description.Trim()}";
|
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.
|
// Run 1.
|
||||||
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
var runRepo = new TaskRunRepository(_db.Factory);
|
var runRepo = new TaskRunRepository(_db.Factory);
|
||||||
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
|
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
var argsBuilder = new ClaudeArgsBuilder();
|
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<TaskRunner>.Instance);
|
NullLogger<TaskRunner>.Instance);
|
||||||
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
||||||
return (service, fake);
|
return (service, fake);
|
||||||
|
|||||||
Reference in New Issue
Block a user