V17 / d6d373561692
KanbanStore.cs
Lilith/Core/Kanban/KanbanStore.cs
Unchanged from the preceding versionCS
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();
}