← All source files

V16 / 6df58cd3ed86

KanbanStore.cs

Lilith/Core/Kanban/KanbanStore.cs

Changed in this version
CS
using System.Text.Json;
using System.Text.Json.Serialization;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Kanban;

/// <summary>One owner, atomic snapshots, bounded journal; never silently resumes code execution.</summary>
public sealed class KanbanStore : IDisposable
{
    internal static readonly JsonSerializerOptions Json = new() { WriteIndented = true,
        Converters = { new JsonStringEnumConverter() } };
    private readonly string _root;
    private readonly FileStream _lease;
    private readonly object _sync = new();
    private readonly Dictionary<string, KanbanBoard> _boards = new(StringComparer.Ordinal);
    public event Action<KanbanBoard>? Changed;
    public KanbanStore(string root)
    {
        _root = Path.GetFullPath(root); _root.EnsureNoLinks(); Directory.CreateDirectory(_root);
        _lease = new(Path.Combine(_root, "owner.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
        try
        {
            foreach (string path in Directory.EnumerateFiles(_root, "*.json"))
            {
                path.EnsureNoLinks();
                if (new FileInfo(path).Length > 2000000) throw new InvalidDataException("Oversized Kanban snapshot.");
                var board = JsonSerializer.Deserialize<KanbanBoard>(File.ReadAllText(path), Json) ?? throw new InvalidDataException("Missing board.");
                Validate(board);
                if (Path.GetFileNameWithoutExtension(path) != board.Id) throw new InvalidDataException("Board filename mismatch.");
                if (board.State is "running" or "pausing" or "waiting_test" or "testing" or "waiting_publication")
                    board = board with { State = "interrupted", Message = "Explicit resume required. Execution permission was not retained.",
                        Cards = board.Cards.Select(c => c.Column is KanbanColumn.Doing or KanbanColumn.Review or KanbanColumn.Testing ? c with { Column = KanbanColumn.Ready } : c).ToArray() };
                _boards.Add(board.Id, board); Write(board);
            }
        }
        catch { _lease.Dispose(); throw; }
    }
    public KanbanBoard[] List() { lock (_sync) return _boards.Values.OrderByDescending(b => b.Revision).ToArray(); }
    public KanbanBoard Get(string id) { lock (_sync) return _boards.GetValueOrDefault(id) ?? throw new ArgumentException("Unknown board ID."); }
    public KanbanBoard Add(KanbanBoard board)
    {
        lock (_sync)
        {
            if (_boards.Values.Any(b => b.State is not ("completed" or "failed" or "cancelled"))) throw new InvalidOperationException("Finish or cancel the current Kanban project first.");
            if (_boards.Count >= 100) throw new InvalidOperationException("Kanban board retention limit (100) reached.");
            if (_boards.ContainsKey(board.Id)) throw new InvalidOperationException("Duplicate board.");
            Validate(board); Write(board); _boards.Add(board.Id, board);
        }
        Notify(board); return board;
    }
    public KanbanBoard Update(string id, Func<KanbanBoard, KanbanBoard> update)
    {
        KanbanBoard board;
        lock (_sync)
        {
            var prior = Get(id); board = update(prior) with { Revision = prior.Revision + 1 };
            if (board.Id != prior.Id || board.Prd != prior.Prd || board.RepairLimit != prior.RepairLimit || board.Name != prior.Name || board.Kind != prior.Kind ||
                board.Architecture != prior.Architecture || board.Contract != prior.Contract ||
                prior.Cards.Any(old => !board.Cards.Any(c => c.Id == old.Id && c.Goal == old.Goal && c.Role == old.Role && c.Dependencies.SequenceEqual(old.Dependencies) && c.Acceptance.SequenceEqual(old.Acceptance))))
                throw new InvalidDataException("A new scope requires a new approved plan.");
            Validate(board); Write(board); _boards[id] = board;
        }
        Notify(board); return board;
    }
    private void Write(KanbanBoard board)
    {
        string target = Path.Combine(_root, board.Id + ".json"), temp = target + ".tmp";
        target.EnsureNoLinks(); temp.EnsureNoLinks();
        string json = JsonSerializer.Serialize(board, Json);
        if (json.Length > 1000000) throw new InvalidDataException("Board exceeds storage bound.");
        using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
        { var bytes = System.Text.Encoding.UTF8.GetBytes(json); stream.Write(bytes); stream.Flush(true); }
        File.Move(temp, target, true);
    }
    private void Notify(KanbanBoard board)
    {
        foreach (var handler in Changed?.GetInvocationList() ?? [])
            try { ((Action<KanbanBoard>)handler)(board); } catch { /* Observers cannot change durable success. */ }
    }
    private static void Validate(KanbanBoard b)
    {
        if (b.Schema != 1 || !Guid.TryParseExact(b.Id, "N", out _) || b.RepairLimit is < 1 or > 100 || b.Cards.Length is < 1 or > 2500 ||
            b.Cards.Select(c => c.Id).Distinct(StringComparer.Ordinal).Count() != b.Cards.Length || b.Prd.Length is < 30 or > 16000 || b.Kind is not ("website" or "console"))
            throw new InvalidDataException("Invalid board schema, bounds or task IDs.");
        var ids = b.Cards.Select(c => c.Id).ToHashSet(StringComparer.Ordinal);
        var visited = new HashSet<string>(); var active = new HashSet<string>();
        void Visit(string id)
        {
            if (visited.Contains(id)) return;
            if (!active.Add(id)) throw new InvalidDataException("Cyclic task dependency.");
            var card = b.Cards.Single(c => c.Id == id);
            if (!Enum.IsDefined(card.Column) || card.FailedRepairs < 0 || card.Dependencies.Any(d => !ids.Contains(d)) || card.Goal.Length > 20000 || card.Acceptance.Length == 0)
                throw new InvalidDataException("Invalid task or dependency.");
            foreach (string d in card.Dependencies) Visit(d);
            active.Remove(id); visited.Add(id);
        }
        foreach (string id in ids) Visit(id);
    }
    public void Dispose() => _lease.Dispose();
}