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