using System.Text; using ClawdDotNet.Core.Storage; using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.Tasks; /// /// Der Ausführungszustand des Taskboards in der Instanz-Datenbank. /// /// Der Kern ist : ein bedingtes UPDATE, das genau eine /// Zeile trifft und damit garantiert, dass nie zwei Läufe denselben Termin ziehen. Genau /// das kann ein Dateisystem nicht verlässlich — deshalb liegt der Zustand hier und nicht /// bei den Task-Dateien. /// /// Zeitangaben werden als sortierbares ISO-UTC ("O") abgelegt, damit /// Zeichenketten-Vergleiche in SQL derselben Ordnung folgen wie die Zeit selbst. /// public sealed class SqliteTaskRepository : ITaskRepository { private readonly SqliteStorage _storage; public SqliteTaskRepository(SqliteStorage storage) => _storage = storage; public Task UpsertAsync(TaskItem task, CancellationToken ct) => _storage.WriteAsync(async conn => { var now = DateTime.UtcNow; var existing = await ReadAsync(conn, task.Id, ct); if (existing is null) { using var insert = conn.CreateCommand(); insert.CommandText = """ INSERT INTO Tasks (Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt) VALUES (@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz, @requireApproval, @acceptance, @blockedBy, @market, @body, @fileName, @toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt); """; BindDefinition(insert, task); insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status)); insert.Parameters.AddWithValue("@createdAt", Format(now)); insert.Parameters.AddWithValue("@updatedAt", Format(now)); await insert.ExecuteNonQueryAsync(ct); } else { // Nur die Definition aktualisieren. Status und Ausführungszustand // (Marker, Claim) gehören der DB — ein Re-Import darf einen laufenden // oder abgeschlossenen Zustand nicht zurücksetzen. Status-Änderungen // laufen über SetStatusAsync bzw. den Scanner. using var update = conn.CreateCommand(); update.CommandText = """ UPDATE Tasks SET Title = @title, Type = @type, Priority = @priority, Assignee = @assignee, WhenKind = @whenKind, WhenValue = @whenValue, WhenTz = @whenTz, RequireApproval = @requireApproval, Acceptance = @acceptance, BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body, FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType, UpdatedAt = @updatedAt WHERE Id = @id """; BindDefinition(update, task); update.Parameters.AddWithValue("@updatedAt", Format(now)); await update.ExecuteNonQueryAsync(ct); } return (await ReadAsync(conn, task.Id, ct))!; }, ct); public async Task GetAsync(string id, CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); return await ReadAsync(conn, id, ct); } public async Task> ListAsync(TaskQuery query, CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); var sql = new StringBuilder("SELECT " + Columns + " FROM Tasks WHERE 1 = 1"); if (query.Status is { } status) { sql.Append(" AND Status = @status"); cmd.Parameters.AddWithValue("@status", TaskText.Of(status)); } else if (!query.IncludeArchived) { sql.Append(" AND Status <> 'archived'"); } if (!string.IsNullOrWhiteSpace(query.Assignee)) { sql.Append(" AND Assignee = @assignee COLLATE NOCASE"); cmd.Parameters.AddWithValue("@assignee", query.Assignee.Trim()); } if (!string.IsNullOrWhiteSpace(query.Search)) { sql.Append(" AND (Title LIKE @search ESCAPE '\\' COLLATE NOCASE" + " OR Body LIKE @search ESCAPE '\\' COLLATE NOCASE)"); cmd.Parameters.AddWithValue("@search", "%" + Escape(query.Search.Trim()) + "%"); } // Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält. sql.Append(" ORDER BY Priority DESC, UpdatedAt DESC LIMIT @limit"); cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 500)); cmd.CommandText = sql.ToString(); var results = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) results.Add(Read(reader)); return results; } public Task TryClaimAsync( string id, string occurrenceKey, string claimToken, DateTime now, DateTime leaseCutoff, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET ClaimToken = @token, ClaimedAt = @now, Status = 'in_progress', LastOccurrence = @occ, UpdatedAt = @now WHERE Id = @id AND Status = 'todo' AND (LastOccurrence IS NULL OR LastOccurrence < @occ) AND (ClaimToken IS NULL OR ClaimedAt < @leaseCutoff) """; cmd.Parameters.AddWithValue("@token", claimToken); cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@occ", occurrenceKey); cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff)); return await cmd.ExecuteNonQueryAsync(ct) == 1; }, ct); public Task CompleteClaimAsync( string id, string claimToken, TaskItemStatus finalStatus, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET Status = @status, ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now WHERE Id = @id AND ClaimToken = @token """; cmd.Parameters.AddWithValue("@status", TaskText.Of(finalStatus)); cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@token", claimToken); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); public Task ReleaseClaimAsync( string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, LastOccurrence = @occ, UpdatedAt = @now WHERE Id = @id AND ClaimToken = @token """; cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value); cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@token", claimToken); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); public Task SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = "UPDATE Tasks SET Status = @status, UpdatedAt = @now WHERE Id = @id"; cmd.Parameters.AddWithValue("@status", TaskText.Of(status)); cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@id", id); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); public Task CarryOverAsync( string id, string claimToken, string? previousOccurrence, DateTime deferredUntil, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, LastOccurrence = @occ, UpdatedAt = @now, BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now, DeferredUntil = @deferred WHERE Id = @id AND ClaimToken = @token """; cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value); cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@deferred", Format(deferredUntil)); cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@token", claimToken); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); public Task RecordBudgetMissAsync( string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); // A pending catch-up keeps its date: several misses still mean one catch-up run. cmd.CommandText = """ UPDATE Tasks SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now, BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now, CatchUpPending = CASE WHEN @catchUp = 1 THEN 1 ELSE CatchUpPending END, DeferredUntil = CASE WHEN @catchUp = 1 AND CatchUpPending = 0 THEN @deferred ELSE DeferredUntil END WHERE Id = @id AND ClaimToken = @token """; cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@catchUp", catchUpUntil is null ? 0 : 1); cmd.Parameters.AddWithValue("@deferred", catchUpUntil is { } until ? Format(until) : DBNull.Value); cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@token", claimToken); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); public Task ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET BudgetMissCount = 0, CatchUpPending = 0, DeferredUntil = NULL, UpdatedAt = @now WHERE Id = @id """; cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@id", id); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); public Task ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct) => _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now WHERE Status = 'in_progress' AND ClaimToken IS NOT NULL AND ClaimedAt < @leaseCutoff """; cmd.Parameters.AddWithValue("@now", Format(now)); cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff)); return await cmd.ExecuteNonQueryAsync(ct); }, ct); public async Task> ListAllAsync(bool includeArchived, CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT " + Columns + " FROM Tasks" + (includeArchived ? "" : " WHERE Status <> 'archived'") + " ORDER BY Priority DESC, UpdatedAt DESC"; var results = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) results.Add(Read(reader)); return results; } public async Task> ListRunnableAsync(CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Status = @status"; cmd.Parameters.AddWithValue("@status", TaskText.Of(TaskItemStatus.Todo)); var results = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) results.Add(Read(reader)); return results; } public async Task> ListBlockedByAsync(string blockerId, CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE BlockedBy LIKE @needle ESCAPE '\\'"; cmd.Parameters.AddWithValue("@needle", "%|" + Escape(blockerId.Trim()) + "|%"); var results = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) results.Add(Read(reader)); return results; } public async Task CountAsync(CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM Tasks"; return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); } // ─── Hilfsfunktionen ─── private const string Columns = "Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " + "RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " + "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, " + "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending"; private static void BindDefinition(SqliteCommand cmd, TaskItem task) { cmd.Parameters.AddWithValue("@id", task.Id); cmd.Parameters.AddWithValue("@title", task.Title); cmd.Parameters.AddWithValue("@type", TaskText.Of(task.Type)); cmd.Parameters.AddWithValue("@priority", Math.Clamp(task.Priority, 1, 5)); cmd.Parameters.AddWithValue("@assignee", task.Assignee); cmd.Parameters.AddWithValue("@whenKind", (object?)(task.When is null ? null : TaskText.Of(task.When.Kind)) ?? DBNull.Value); cmd.Parameters.AddWithValue("@whenValue", (object?)task.When?.Value ?? DBNull.Value); cmd.Parameters.AddWithValue("@whenTz", (object?)task.When?.TimeZone ?? DBNull.Value); cmd.Parameters.AddWithValue("@requireApproval", task.RequireApproval ? 1 : 0); cmd.Parameters.AddWithValue("@acceptance", task.Acceptance); cmd.Parameters.AddWithValue("@blockedBy", SerializeBlockedBy(task.BlockedBy)); cmd.Parameters.AddWithValue("@market", task.OnlyWhenMarketOpen ? 1 : 0); cmd.Parameters.AddWithValue("@body", task.Body); cmd.Parameters.AddWithValue("@fileName", task.FileName); cmd.Parameters.AddWithValue("@toolName", task.ToolName); cmd.Parameters.AddWithValue("@jobType", task.JobTypeId); } private static async Task ReadAsync(SqliteConnection conn, string id, CancellationToken ct) { using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Id = @id LIMIT 1"; cmd.Parameters.AddWithValue("@id", id); await using var reader = await cmd.ExecuteReaderAsync(ct); return await reader.ReadAsync(ct) ? Read(reader) : null; } private static TaskItem Read(SqliteDataReader r) { TaskWhen? when = null; if (!r.IsDBNull(6) && TaskText.WhenKind(r.GetString(6)) is { } kind) { when = new TaskWhen { Kind = kind, Value = r.IsDBNull(7) ? "" : r.GetString(7), TimeZone = r.IsDBNull(8) ? "" : r.GetString(8) }; } return new TaskItem { Id = r.GetString(0), Title = r.GetString(1), Status = TaskText.Status(r.GetString(2)), Type = TaskText.Type(r.GetString(3)), Priority = r.GetInt32(4), Assignee = r.GetString(5), When = when, RequireApproval = r.GetInt32(9) != 0, Acceptance = r.GetString(10), BlockedBy = DeserializeBlockedBy(r.GetString(11)), OnlyWhenMarketOpen = r.GetInt32(12) != 0, Body = r.GetString(13), FileName = r.GetString(14), ToolName = r.GetString(15), JobTypeId = r.GetString(16), LastOccurrence = r.IsDBNull(17) ? null : r.GetString(17), ClaimToken = r.IsDBNull(18) ? null : r.GetString(18), ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)), CreatedAt = Parse(r.GetString(20)), UpdatedAt = Parse(r.GetString(21)), BudgetMissCount = r.GetInt32(22), LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)), DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)), CatchUpPending = r.GetInt32(25) != 0 }; } /// Blocker als "|a|b|" — die Begrenzer erlauben eine Suche nach ganzen Ids, /// ohne dass t-1 auch t-10 trifft. private static string SerializeBlockedBy(IReadOnlyList ids) { var cleaned = ids .Select(x => x.Trim().Replace("|", "")) .Where(x => x.Length > 0) .Distinct() .ToList(); return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|"; } private static IReadOnlyList DeserializeBlockedBy(string raw) => raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private static string Escape(string value) => value .Replace(@"\", @"\\") .Replace("%", @"\%") .Replace("_", @"\_"); private static string Format(DateTime value) => value.ToUniversalTime().ToString("O"); private static DateTime Parse(string value) => DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) ? dt : DateTime.MinValue; }