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