← All source files

V17 / d6d373561692

KanbanSession.cs

Lilith/Core/Kanban/KanbanSession.cs

Unchanged from the preceding version
CS
using System.Text.Json;
using Lilith.Core.Projects;
using Lilith.Core.Scenarios;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Kanban;

/// <summary>Host-owned sequential scheduler. Saved scenarios own every model interaction.</summary>
public sealed class KanbanSession : IDisposable
{
    private readonly ProviderClient _provider;
    private readonly string _evidence;
    private readonly object _sync = new();
    private Task? _worker;
    private string? _active;
    private volatile bool _pause, _cancel;
    private volatile bool _testing;
    private readonly CancellationTokenSource _lifetime = new();
    internal CancellationToken Lifetime => _lifetime.Token;
    internal bool OwnsQualification => _testing;
    public KanbanStore Store { get; }
    public KanbanDashboard Dashboard { get; }
    public bool IsWorking { get { lock (_sync) return _testing || _worker is { IsCompleted: false }; } }
    internal Task<string> TestFromDashboard(string id, CancellationToken ct) => _provider.TestKanbanAsync(id, ct);
    internal Task<string> PublishFromDashboard(string id, CancellationToken ct) => _provider.PublishKanbanAsync(id, ct);
    internal Task<string> ApprovePublicationFromDashboard(string id, CancellationToken ct) => _provider.ApproveKanbanPublicationAsync(id, ct);
    internal string ReadPublication(string id)
    {
        var board = Store.Get(id);
        string proposal = board.PublicationProposal ?? throw new InvalidOperationException("Prepare publication first.");
        string path = _provider.Workspace!.Options.GetMetadataPath("pending", proposal) + ".json";
        path.EnsureNoLinks(); return File.ReadAllText(path);
    }
    internal byte[] ReadScreenshot(string id,int index)
    {
        var images = Store.Get(id).Screenshots ?? throw new InvalidOperationException("No captured screenshots.");
        if (index < 0 || index >= images.Length) throw new ArgumentException("Unknown screenshot.");
        var image = images[index]; string path = Path.GetFullPath(image.File);
        string root = Path.GetFullPath(Path.Combine(_provider.Workspace!.Options.RootDirectory,".lilith","website-previews")) + Path.DirectorySeparatorChar;
        if (!path.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new IOException("Screenshot is outside the owned artifact root.");
        path.EnsureNoLinks(); if (new FileInfo(path).Length > 16000000) throw new IOException("Oversized screenshot.");
        byte[] bytes = File.ReadAllBytes(path);
        if (Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)) != image.Sha256) throw new IOException("Screenshot changed since capture.");
        return bytes;
    }
    internal KanbanSession(ProviderClient provider)
    {
        _provider = provider;
        var workspace = provider.Workspace ?? throw new InvalidOperationException("Kanban requires a configured workspace.");
        _evidence = Path.Combine(workspace.Options.RootDirectory, ".lilith", "kanban");
        Store = new(Path.Combine(_evidence, "boards"));
        try { Dashboard = new(this); } catch { Store.Dispose(); throw; }
    }
    internal KanbanBoard Prepare(string name, string kind, string prd, string architecture, int limit)
    {
        if (limit is < 1 or > 100) throw new ArgumentException("Repair limit must be 1-100.");
        if (Store.List().Any(b => b.State is not ("completed" or "failed" or "cancelled"))) throw new InvalidOperationException("Only one active project is allowed. Inspect, resume or cancel the current board.");
        string id = Guid.NewGuid().ToString("N");
        var generation = new ProjectGeneration(_evidence, _provider.Workspace!);
        using var prepared = JsonDocument.Parse(generation.Prepare(name, kind, prd, architecture, ownedId: id));
        string contract = prepared.RootElement.GetProperty("contract").GetString()!;
        var tasks = ProjectJson.Parse<ProjectTask[]>(prepared.RootElement.GetProperty("tasks").GetRawText());
        var cards = tasks.Select(t => new KanbanCard(t.Id, t.Path + (t.Signature.Length == 0 ? "" : " / " + t.Id),
            "Implement the owned component following the PRD and shared interface contract. " + t.Requirements,
            [], ["Source implements the task and shared contract.", "A separate reviewer inspects the component; runtime acceptance remains pending until project testing."],
            "component", Files: [t.Path])).ToList();
        cards.Add(new("integration", "Integrate and review the complete project", "Assemble all owned components, build/parse the project and independently review its exact source. Repair real diagnostics within the approved scope.", tasks.Select(t => t.Id).ToArray(), ["Actual build/parse passes.", "Independent structured source review has no remaining findings."], "integration"));
        cards.Add(new("test_plan", "Prepare independently reviewed acceptance tests", "Create and freeze executable acceptance tests for the actual private draft. Do not execute it yet.", ["integration"], ["The host validates the retained test plan.", "A separate scenario reviews tests against the PRD and source."], "test_plan"));
        cards.Add(new("qualification", "Execute and qualify the project", "After a separate host execution decision, run the complete frozen suite, independent source and coverage reviews, and at most ten total runtime rounds. Retain all failures.", ["test_plan"], ["All retained cases pass on the current source and plan hashes.", "Independent source/coverage review passes."], "qualification"));
        if (kind == "website") cards.Add(new("screenshots", "Capture three website previews", "A separate subagent starts the approved snapshot server; another captures Desktop, Tablet and Mobile PNGs and displays them in chat.", ["qualification"], ["Three actual screenshots have valid PNG data and source hashes.", "The owned preview server and browser are closed."], "screenshots"));
        cards.Add(new("publication", "Publish the exact qualified project", "Prepare a separate exact workspace publication decision; preserve existing V12 stale-signature and backup protection.", kind == "website" ? ["screenshots"] : ["qualification"], ["Workspace receipt confirms publication of the qualified revision."], "publication"));
        bool yolo = _provider.Workspace!.Options.Mode == WorkspaceAccessMode.Yolo;
        return Store.Add(new(1, id, name, kind, prd, limit, yolo ? "ready" : "waiting_plan", cards.ToArray(), architecture, contract, tasks, [],
            Message: yolo ? "Plan accepted by boot-selected YOLO. Execution permission is still separate." : "Review the complete task plan and approve it before execution.", PlanApproved: yolo));
    }
    public string Approve(string id, long revision)
    {
        Store.Update(id, b =>
        {
            if (b.State != "waiting_plan" || b.Revision != revision) throw new InvalidOperationException("Plan changed or no longer awaits approval.");
            return b with { PlanApproved = true, State = "ready", Message = "Exact plan approved by user." };
        });
        return "Plan approved. " + Resume(id);
    }
    public string Pause(string id)
    {
        lock (_sync)
        {
            var board = Store.Get(id);
            if (board.State is "completed" or "failed" or "cancelled") return board.Message;
            _pause = true;
            Store.Update(id, b => b with { State = IsWorking ? "pausing" : "paused", Message = "Pause requested; the current agent may finish. No next agent will start." });
        }
        return "Pause requested. Return to chat when state is paused.";
    }
    public async Task<string> CancelAsync(string id, CancellationToken ct = default)
    {
        string? publication;
        lock (_sync)
        {
            var board = Store.Get(id);
            if (board.State is "completed" or "failed" or "cancelled") return board.Message;
            publication = board.PublicationProposal;
            _cancel = true;
            Store.Update(id, b => b with { State = IsWorking ? "cancelling" : "cancelled", FinishedAt = IsWorking ? null : DateTimeOffset.UtcNow,
                Message = "Cancellation requested. Private source and evidence are retained; no publication is authorized." });
        }
        if (publication is not null) await _provider.Workspace!.ExecuteAsync(new("reject", BinId: publication), ct);
        return "Cancellation requested.";
    }
    public string Resume(string id)
    {
        lock (_sync)
        {
            var board = Store.Get(id);
            if (!board.PlanApproved) throw new InvalidOperationException("User plan approval required.");
            if (board.State is "completed" or "failed" or "cancelled") throw new InvalidOperationException("Run is terminal. Inspect its report; do not silently restart its budgets.");
            if (IsWorking) throw new InvalidOperationException("A Kanban worker is already active.");
            _active = id; _pause = false; _cancel = false;
            _worker = Task.Run(() => _provider.ExecuteKanbanAsync(id));
        }
        return "Kanban running. Board: " + Dashboard.Url;
    }
    internal void StartReady()
    {
        if (IsWorking) return;
        var ready = Store.List().FirstOrDefault(b => b.State == "ready" && b.PlanApproved);
        if (ready is not null) Resume(ready.Id);
    }
    internal void Boundary()
    {
        if (_active is null) return;
        if (_cancel) throw new KanbanYieldException(true);
        if (_pause) throw new KanbanYieldException(false);
    }
    internal void BeginQualification(string id)
    {
        _active = id; _testing = true; _pause = false; _cancel = false;
        Change("qualification", c => c with { Column = KanbanColumn.Testing });
        Store.Update(id, b => b with { State = "testing", Message = "User approved executing this project and its bounded repair revisions." });
    }
    internal void FinishQualification(string id, bool passed, string result)
    {
        Change("qualification", c => c with { Column = passed ? KanbanColumn.Done : KanbanColumn.Blocked, Evidence = result });
        Store.Update(id, b => b with { State = passed ? "waiting_publication" : "failed", FinishedAt = passed ? null : DateTimeOffset.UtcNow,
            Cards = b.Cards.Select(c => c.Id == "publication" && passed ? c with { Column = KanbanColumn.Ready }
                : passed && c.Role is "component" or "integration" or "runtime_repair" ? c with { Column = KanbanColumn.Done }
                : !passed && c.Role == "runtime_repair" ? c with { Column = KanbanColumn.Blocked } : c).ToArray(), Message = result });
        _active = null; _testing = false;
    }
    internal void SetScreenshots(string id, WebsiteScreenshot[] images)
    {
        Store.Update(id,b => b with { Screenshots = images, Cards = b.Cards.Select(c => c.Id == "screenshots" ? c with { Column = KanbanColumn.Done, Evidence = ProjectJson.Serialize(images) } : c).ToArray() });
    }
    internal void YieldQualification(string id, bool cancel)
    {
        var draftId = Store.Get(id).DraftId;
        bool qualified = draftId is not null && new ProjectDraftStore(_provider.Workspace!).Load(draftId).State == "ready_to_publish";
        Store.Update(id, b => b with { State = cancel ? "cancelled" : "paused", FinishedAt = cancel ? DateTimeOffset.UtcNow : null,
            Cards = b.Cards.Select(c => c.Id == "qualification" ? c with { Column = qualified ? KanbanColumn.Done : KanbanColumn.Ready }
                : c.Id == "screenshots" && c.Column == KanbanColumn.Doing ? c with { Column = KanbanColumn.Ready } : c).ToArray(),
            Message = cancel ? "Cancelled; runtime evidence retained; unpublished." : "Qualification paused; explicit resume and fresh execution decision required." });
        _active = null; _testing = false;
    }
    internal void RuntimeRound(ProjectRuntimeReport report, int round)
    {
        if (!_testing || _active is null) return;
        var board = Store.Get(_active);
        bool repairedFailed = !report.Passed && round > 1;
        Store.Update(board.Id, b => b with { Cards = b.Cards.Select(c => c.Id == "qualification"
            ? c with { FailedRepairs = c.FailedRepairs + (repairedFailed ? 1 : 0), Evidence = ProjectJson.Serialize(report) }
            : !report.Passed && c.Role is "component" or "integration" ? c with { Column = KanbanColumn.Testing } : c).ToArray() });
        if (!report.Passed)
        {
            var current = Store.Get(board.Id);
            Store.Update(board.Id, b => b with { Cards = b.Cards.Append(new KanbanCard("runtime_repair_" + round, "Repair runtime findings / round " + round,
                string.Join("\n", report.Errors.Concat(report.Cases.Where(c => c.Status != "passed").Select(c => c.Id + ": " + c.Actual))), [],
                ["Rerun all frozen checks and independent review; do not weaken failures."], "runtime_repair", KanbanColumn.Review, Parent: "qualification", Evidence: report.Evidence)).ToArray() });
            if (current.Cards.Single(c => c.Id == "qualification").FailedRepairs >= current.RepairLimit)
                throw new InvalidOperationException("Per-task failed repair limit reached; project remains unpublished.");
        }
    }
    internal void Publication(string id, WorkspaceResult result)
    {
        bool pending = result.Status == "needs_approval", published = result.Status == "completed";
        Store.Update(id, b => b with { State = pending ? "waiting_publication" : published ? "completed" : "failed", PublicationProposal = pending ? result.ProposalId : null,
            FinishedAt = pending ? null : DateTimeOffset.UtcNow, Message = result.ToReply(),
            Cards = b.Cards.Select(c => c.Id == "publication" ? c with { Column = pending ? KanbanColumn.Ready : published ? KanbanColumn.Done : KanbanColumn.Blocked, Evidence = result.ToReply() } : c).ToArray() });
    }
    internal void Observe(ScenarioProgress progress)
    {
        if (_active is null || progress.Kind != "Started") return;
        var board = Store.Get(_active); var task = _testing ? board.Cards.FirstOrDefault(c => c.Id == "screenshots" && c.Column == KanbanColumn.Doing) ?? board.Cards.Single(c => c.Id == "qualification") : board.Cards.FirstOrDefault(c => c.Role != "repair" && c.Column is KanbanColumn.Doing or KanbanColumn.Review or KanbanColumn.Testing);
        if (task is null) return;
        Change(task.Id, c => c with { Agents = (c.Agents ?? []).Append(progress.RunId).Distinct().TakeLast(200).ToArray() });
    }
    private void Change(string card, Func<KanbanCard, KanbanCard> update) => Store.Update(_active!, b => b with { Cards = b.Cards.Select(c => c.Id == card ? update(c) : c).ToArray() });
    private ProjectGeneration Reconstruct(KanbanBoard b)
    {
        var generation = new ProjectGeneration(_evidence, _provider.Workspace!);
        generation.Prepare(b.Name, b.Kind, b.Prd, b.Architecture, ownedId: b.Id);
        foreach (var pair in b.Sources) generation.Store(b.Id, pair.Key, pair.Value);
        return generation;
    }
    private async Task<string> Scenario(string name, Dictionary<string, string> inputs, CancellationToken ct)
    {
        Boundary(); return await ProjectQualification.Scenario(_provider, name, inputs, ct);
    }
    internal async Task RunAsync(string id, CancellationToken ct = default)
    {
        _active = id;
        try
        {
            Store.Update(id, b => b with { State = "running", Message = "Executing one scenario agent at a time." });
            while (true)
            {
                Boundary(); var board = Store.Get(id);
                foreach (var c in board.Cards.Where(c => c.Column == KanbanColumn.Backlog && c.Dependencies.All(d => board.Cards.Single(t => t.Id == d).Column == KanbanColumn.Done)))
                    Change(c.Id, t => t with { Column = KanbanColumn.Ready });
                board = Store.Get(id);
                var card = board.Cards.FirstOrDefault(c => c.Column == KanbanColumn.Ready);
                if (card is null)
                {
                    bool complete = board.Cards.All(c => c.Column == KanbanColumn.Done);
                    Store.Update(id, b => b with { State = complete ? "completed" : "failed", FinishedAt = DateTimeOffset.UtcNow,
                        Message = complete ? "Qualified project published. See task evidence for coverage limits." : "Unresolved or blocked work remains. Nothing is qualified for publication." }); return;
                }
                if (card.Role is "qualification" or "screenshots" or "publication")
                {
                    Store.Update(id, b => b with { State = card.Role != "publication" ? "waiting_test" : "waiting_publication", Message = card.Role != "publication"
                        ? "Explicit execution permission required for this project: /kanban-test " + id
                        : "Qualified draft ready. Request separate exact publication: /kanban-publish " + id }); return;
                }
                Change(card.Id, c => c with { Column = KanbanColumn.Doing });
                try
                {
                    switch (card.Role)
                    {
                        case "component": await Component(board, card, ct); break;
                        case "integration": await Integrate(board, ct); break;
                        case "test_plan": await PlanTests(board, ct); break;
                        default: throw new InvalidOperationException("Unknown host task role.");
                    }
                    Change(card.Id, c => c with { Column = KanbanColumn.Done });
                    Store.Update(id, b => b with { Cards = b.Cards.Select(c => c.Parent == card.Id && c.Role == "repair" ? c with { Column = KanbanColumn.Done, Evidence = c.Evidence + "\nParent task subsequently passed its declared checks." } : c).ToArray() });
                }
                catch (KanbanYieldException) { Change(card.Id, c => c with { Column = KanbanColumn.Ready }); throw; }
                catch (Exception ex) when (!ct.IsCancellationRequested)
                {
                    FailTask(card.Id, ex.Message);
                }
            }
        }
        catch (KanbanYieldException ex)
        {
            Store.Update(id, b => b with { State = ex.Cancel ? "cancelled" : "paused", FinishedAt = ex.Cancel ? DateTimeOffset.UtcNow : null, Message = ex.Cancel ? "Cancelled; private draft and evidence retained." : "Paused before the next agent. Chat is available." });
        }
        catch (Exception ex)
        { Store.Update(id, b => b with { State = "interrupted", Message = "Stopped without publication: " + ex.Message }); }
        finally { _active = null; }
    }
    private void FailTask(string task, string error)
    {
        var board = Store.Get(_active!); var old = board.Cards.Single(c => c.Id == task);
        int count = old.FailedRepairs + (old.Attempts == 0 ? 0 : 1);
        string repair = task + "_repair_" + (old.Attempts + 1);
        Store.Update(board.Id, b => b with { Cards = b.Cards.Select(c => c.Id == task ? c with { FailedRepairs = count, Attempts = c.Attempts + 1, Column = count >= b.RepairLimit ? KanbanColumn.Blocked : KanbanColumn.Ready, Artifact = c.Role == "component" ? null : c.Artifact, Evidence = error } : c).Append(
            new KanbanCard(repair, "Repair " + old.Title, error, [], ["Resolve the retained diagnostic without weakening acceptance checks."], "repair", KanbanColumn.Blocked, Parent: task, Evidence: error)).ToArray(),
            Message = count >= b.RepairLimit ? "Task repair limit reached: " + task : "Task failed; retained diagnostic requires repair: " + task });
    }
    private async Task Component(KanbanBoard b, KanbanCard card, CancellationToken ct)
    {
        if (b.Sources.ContainsKey(card.Id)) return; // Reviewed source was committed before interruption.
        var task = b.Components.Single(t => t.Id == card.Id);
        var input = new Dictionary<string, string> { ["kind"] = b.Kind, ["prd"] = b.Prd, ["contract"] = b.Contract, ["task"] = ProjectJson.Serialize(task) };
        if (card.Evidence.Length > 0) input["prd"] += "\nActual previous attempt failed; correct this diagnostic within the original scope: " + card.Evidence;
        string draft = card.Artifact ?? await Scenario("project_component", input, ct);
        Change(card.Id, c => c with { Artifact = draft, Column = KanbanColumn.Review });
        input["draft"] = draft;
        string reviewed = await Scenario("project_review_component", input, ct);
        var generation = Reconstruct(Store.Get(b.Id)); generation.Store(b.Id, card.Id, reviewed);
        Store.Update(b.Id, board => board with { Sources = new(board.Sources) { [card.Id] = reviewed }, Cards = board.Cards.Select(c => c.Id == card.Id ? c with { Evidence = "Separate component reviewer returned this source; runtime testing is still pending.", Artifact = reviewed } : c).ToArray() });
    }
    private async Task Integrate(KanbanBoard b, CancellationToken ct)
    {
        if (b.DraftId is not null) { _ = new ProjectDraftStore(_provider.Workspace!).Load(b.DraftId); return; }
        var generation = Reconstruct(b); generation.Assemble(b.Id);
        if (b.Bundle is not null) await generation.ValidateAsync(b.Id, b.Bundle, ct);
        string bundle = generation.CurrentDisplay;
        string diagnostic = Store.Get(b.Id).Cards.Single(c => c.Id == "integration").Evidence;
            Boundary();
            string integrated = await Scenario("project_integrate", new() { ["kind"] = b.Kind, ["prd"] = b.Prd, ["architecture"] = b.Architecture, ["bundle"] = bundle, ["diagnostics"] = diagnostic.Length == 0 ? "Initial integration; no runtime execution has occurred." : diagnostic }, ct);
            string validated = await generation.ValidateAsync(b.Id, integrated, ct);
            bundle = generation.CurrentDisplay;
            Store.Update(b.Id, board => board with { Bundle = ProjectJson.Serialize(generation.CurrentBundle) });
            Change("integration", c => c with { Column = KanbanColumn.Review, Evidence = validated });
            string review = await Scenario("project_review_project", new() { ["kind"] = b.Kind, ["prd"] = b.Prd, ["bundle"] = bundle, ["diagnostics"] = validated }, ct);
            string result = generation.Review(b.Id, review);
            using var parsed = JsonDocument.Parse(result);
            if (parsed.RootElement.GetProperty("passed").GetBoolean())
            {
                string staged = generation.Stage(b.Id); using var receipt = JsonDocument.Parse(staged);
                Store.Update(b.Id, board => board with { DraftId = receipt.RootElement.GetProperty("id").GetString() });
                Change("integration", c => c with { Evidence = result }); return;
            }
            throw new InvalidOperationException(result);
    }
    private async Task PlanTests(KanbanBoard b, CancellationToken ct)
    {
        if (b.DraftId is null) throw new InvalidOperationException("No staged project draft.");
        var retained = new ProjectDraftStore(_provider.Workspace!).Load(b.DraftId);
        if (retained.Tests.Length > 0 && retained.State == "awaiting_test_approval") return;
        string result = await Scenario("project_plan_tests", new() { ["id"] = b.DraftId }, ct);
        var draft = new ProjectDraftStore(_provider.Workspace!).Load(b.DraftId);
        if (draft.Tests.Length == 0 || draft.State != "awaiting_test_approval") throw new InvalidOperationException(result);
        Change("test_plan", c => c with { Evidence = result });
    }
    public async Task StopAsync()
    {
        _cancel = true; _lifetime.Cancel();
        Task? work; lock (_sync) work = _worker;
        if (work is not null) await work;
        while (_testing) await Task.Delay(25);
    }
    public void Dispose() { _cancel = true; _lifetime.Cancel(); Dashboard.Dispose(); Store.Dispose(); _lifetime.Dispose(); }
}

internal sealed class KanbanYieldException(bool cancel) : Exception("Kanban yielded at an agent boundary.")
{ internal bool Cancel { get; } = cancel; }