using System.Globalization; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; namespace ClawdDotNet.Core.Budget; /// /// #717: A special budget — an amount for exactly one task and everything below it, approved /// by a human. /// /// The one exception to "budgets stay at the agent" (concept 8a). It is a pot of /// its own: runs in the task tree draw on it instead of the agent's daily limit, so a large /// job neither eats the day's regular work nor fails at the daily limit halfway. It does not /// raise the daily limit — work outside the tree never sees it — and it is never silent: it /// exists only through a human's approval (staging), and its runs are marked in the cost /// analysis. Once it is used up, the tree's runs count against the daily budget again, like /// any other work. /// public sealed record SpecialBudget { /// The task at the top of the tree the pot is for. public string TaskId { get; init; } = ""; public decimal AmountUsd { get; init; } public string Reason { get; init; } = ""; /// The agent that asked for it. public string RequestedBy { get; init; } = ""; public DateTime ApprovedAt { get; init; } } /// #717: Where special budgets are kept, and what they have used so far. public interface ISpecialBudgetRepository { Task GetAsync(string taskId, CancellationToken ct); /// Sets or replaces the pot of a task — a second approval replaces the amount. Task SetAsync(SpecialBudget budget, CancellationToken ct); Task> ListAsync(CancellationToken ct); /// What runs have drawn on the pot, from the usage records marked with it. Task SpentAsync(string taskId, CancellationToken ct); } public sealed class SqliteSpecialBudgetRepository(SqliteStorage storage) : ISpecialBudgetRepository { public async Task GetAsync(string taskId, CancellationToken ct) { await using var conn = await storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt FROM SpecialBudgets WHERE TaskId = @id COLLATE NOCASE"; cmd.Parameters.AddWithValue("@id", taskId.Trim()); await using var reader = await cmd.ExecuteReaderAsync(ct); return await reader.ReadAsync(ct) ? Read(reader) : null; } public Task SetAsync(SpecialBudget budget, CancellationToken ct) => storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO SpecialBudgets (TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt) VALUES (@id, @amount, @reason, @by, @at) ON CONFLICT(TaskId) DO UPDATE SET AmountUsd = excluded.AmountUsd, Reason = excluded.Reason, RequestedBy = excluded.RequestedBy, ApprovedAt = excluded.ApprovedAt """; cmd.Parameters.AddWithValue("@id", budget.TaskId.Trim()); cmd.Parameters.AddWithValue("@amount", budget.AmountUsd.ToString(CultureInfo.InvariantCulture)); cmd.Parameters.AddWithValue("@reason", budget.Reason); cmd.Parameters.AddWithValue("@by", budget.RequestedBy); cmd.Parameters.AddWithValue("@at", budget.ApprovedAt.ToUniversalTime().ToString("O")); await cmd.ExecuteNonQueryAsync(ct); }, ct); public async Task> ListAsync(CancellationToken ct) { await using var conn = await storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt FROM SpecialBudgets ORDER BY ApprovedAt DESC"; var result = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) result.Add(Read(reader)); return result; } public async Task SpentAsync(string taskId, CancellationToken ct) { await using var conn = await storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); // Amounts are text (exact decimals); summed here, not in SQL. cmd.CommandText = "SELECT CostUsd FROM RunUsage WHERE BudgetPot = @id COLLATE NOCASE"; cmd.Parameters.AddWithValue("@id", taskId.Trim()); var spent = 0m; await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) spent += decimal.TryParse(reader.GetString(0), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m; return spent; } private static SpecialBudget Read(Microsoft.Data.Sqlite.SqliteDataReader r) => new() { TaskId = r.GetString(0), AmountUsd = decimal.TryParse(r.GetString(1), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m, Reason = r.GetString(2), RequestedBy = r.GetString(3), ApprovedAt = DateTime.TryParse(r.GetString(4), null, DateTimeStyles.RoundtripKind, out var at) ? at : DateTime.MinValue }; } /// #717: Finding the pot a task draws on — its own, or the nearest one above it. public static class SpecialBudgets { /// A tree deeper than this is a mistake, not a plan; the walk stops there. private const int MaxDepth = 32; public static async Task FindAsync( string taskId, ITaskRepository tasks, ISpecialBudgetRepository pots, CancellationToken ct) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var current = taskId.Trim(); for (var depth = 0; depth < MaxDepth && current.Length > 0 && seen.Add(current); depth++) { if (await pots.GetAsync(current, ct) is { } pot) return pot; current = (await tasks.GetAsync(current, ct))?.ParentId?.Trim() ?? ""; } return null; } }