← All source files

V18 / 8d896b8a86e5

KanbanDashboard.cs

Lilith/Core/Kanban/KanbanDashboard.cs

Unchanged from the preceding version
CS
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace Lilith.Core.Kanban;

/// <summary>Loopback-only, session-token authenticated viewer and explicit host controls.</summary>
public sealed class KanbanDashboard : IDisposable
{
    private readonly KanbanSession _session;
    private readonly HttpListener _listener;
    private readonly CancellationTokenSource _stop = new();
    private readonly string _token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
    private readonly string _origin;
    private readonly Task _server;
    public string Url => _origin + "/#" + _token;
    public string PublicUrl => _origin + "/";
    internal KanbanDashboard(KanbanSession session)
    {
        _session = session;
        // The reservation is released before HttpListener binds; retry if another process wins.
        for (int attempt = 0; ; attempt++)
        {
            var reservation = new TcpListener(IPAddress.Loopback, 0); reservation.Start();
            int port = ((IPEndPoint)reservation.LocalEndpoint).Port; reservation.Stop();
            _origin = "http://127.0.0.1:" + port;
            _listener = new HttpListener(); _listener.Prefixes.Add(_origin + "/");
            try { _listener.Start(); break; } catch (HttpListenerException) when (attempt < 4) { _listener.Close(); }
        }
        _server = Serve();
    }
    private async Task Serve()
    {
        while (!_stop.IsCancellationRequested)
        {
            try { var context = await _listener.GetContextAsync(); _ = Handle(context); }
            catch (Exception) when (_stop.IsCancellationRequested) { break; }
        }
    }
    private async Task Handle(HttpListenerContext context)
    {
        var request = context.Request; var response = context.Response;
        response.Headers["Cache-Control"] = "no-store";
        response.Headers["X-Content-Type-Options"] = "nosniff";
        response.Headers["Content-Security-Policy"] = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'";
        try
        {
            if (request.RemoteEndPoint is null || !IPAddress.IsLoopback(request.RemoteEndPoint.Address) || request.Url?.GetLeftPart(UriPartial.Authority) != _origin ||
                request.Headers["Origin"] is { } origin && origin != _origin) { response.StatusCode = 403; return; }
            string route = request.Url.AbsolutePath;
            if (route == "/" && request.HttpMethod == "GET") { await Write(response, Page, "text/html; charset=utf-8"); return; }
            if (request.Headers["X-Lilith-Kanban"] != _token) { response.StatusCode = 401; return; }
            if (route == "/api/boards" && request.HttpMethod == "GET")
            { await Write(response, JsonSerializer.Serialize(_session.Store.List(), KanbanStore.Json)); return; }
            if (route == "/api/proposal" && request.HttpMethod == "GET")
            { await Write(response, _session.ReadPublication(request.QueryString["id"] ?? "")); return; }
            if (route == "/api/image" && request.HttpMethod == "GET")
            {
                if (!int.TryParse(request.QueryString["index"],out int index)) throw new ArgumentException("Invalid image index.");
                byte[] bytes = _session.ReadScreenshot(request.QueryString["id"] ?? "",index);
                response.ContentType = "image/png"; response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); return;
            }
            if (route != "/api/control" || request.HttpMethod != "POST" || request.ContentLength64 is < 1 or > 1024 || request.ContentType != "application/json")
            { response.StatusCode = 400; return; }
            using var input = new MemoryStream();
            byte[] buffer = new byte[1025]; int read = await request.InputStream.ReadAsync(buffer);
            input.Write(buffer, 0, read);
            while (read != 0 && input.Length <= 1024) { read = await request.InputStream.ReadAsync(buffer); input.Write(buffer, 0, read); }
            if (input.Length > 1024) { response.StatusCode = 413; return; }
            using var body = JsonDocument.Parse(input.ToArray());
            string id = body.RootElement.GetProperty("id").GetString()!, action = body.RootElement.GetProperty("action").GetString()!;
            string result = action switch
            {
                "pause" or "chat" => _session.Pause(id),
                "resume" => _session.Resume(id),
                "cancel" => await _session.CancelAsync(id, _stop.Token),
                "approve" => _session.Approve(id, body.RootElement.GetProperty("revision").GetInt64()),
                // Distinct buttons are explicit per-project host decisions, never model tools.
                "test" => await _session.TestFromDashboard(id, _stop.Token),
                "publish" => await _session.PublishFromDashboard(id, _stop.Token),
                "publication_approve" => await _session.ApprovePublicationFromDashboard(id, _stop.Token),
                _ => throw new ArgumentException("Unknown board control.")
            };
            await Write(response, JsonSerializer.Serialize(new { message = result }));
        }
        catch (Exception ex)
        { response.StatusCode = 409; try { await Write(response, JsonSerializer.Serialize(new { error = ex.Message })); } catch (Exception) { } }
        finally { response.Close(); }
    }
    private static async Task Write(HttpListenerResponse response, string body, string type = "application/json; charset=utf-8")
    {
        response.ContentType = type; byte[] bytes = Encoding.UTF8.GetBytes(body);
        response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes);
    }
    public void Dispose() { _stop.Cancel(); _listener.Close(); }

    private const string Page = """
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lilith · Project board</title>
<style>
:root{color-scheme:dark;font-family:Consolas,monospace;background:#0b100e;color:#f1f1e6}*{box-sizing:border-box}body{margin:0;padding:24px}header{display:flex;gap:18px;align-items:center;border-bottom:1px solid #303b2e;padding-bottom:18px}h1{font-size:23px;margin:0;color:#d6f675}button,select{background:#111814;border:1px solid #43513d;color:#d6f675;padding:10px;cursor:pointer}button:disabled{opacity:.4;cursor:default}#status{margin-left:auto;color:#d6f675}#message{white-space:pre-wrap;font:14px/1.6 system-ui;max-height:130px;overflow:auto}#columns{display:grid;grid-template-columns:repeat(7,minmax(170px,1fr));gap:12px;overflow:auto;min-height:65vh}.column{background:#101611;border:1px solid #303b2e;padding:12px}h2{font-size:12px;text-transform:uppercase;color:#a1b69e}.card{display:block;text-align:left;width:100%;margin:12px 0;padding:12px;background:#182018;color:#eee;border-left:3px solid #d6f675;font:14px/1.4 system-ui}.card small{display:block;color:#b1c3a2;margin-top:9px}dialog{background:#111814;color:#f1f1e6;border:1px solid #d6f675;max-width:850px;width:90%;max-height:85vh}dialog::backdrop{background:#000b}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.6 Consolas,monospace}.bar{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}#decision{border:1px solid #d6f675;padding:15px;margin-bottom:16px}#chat{padding:35px;border:1px solid #303b2e}#chat[hidden],#board[hidden],#decision[hidden]{display:none}a{color:#d6f675}@media(max-width:800px){body{padding:12px}header{flex-wrap:wrap}#columns{grid-template-columns:repeat(7,220px)}}
</style>
<header><h1>◕ LILITH / PROJECT BOARD</h1><select id="runs" aria-label="Saved project"></select><span id="status">Loading</span></header>
<section id="board"><div class="bar"><button data-action="pause">Pause</button><button data-action="resume">Resume</button><button data-action="cancel">Cancel</button><button data-action="chat">Return to chat</button></div><p id="message"></p><section id="decision" hidden></section><section id="gallery" style="display:flex;gap:16px;flex-wrap:wrap"></section><div id="columns"></div></section>
<section id="chat" hidden><h2>Conversation</h2><p id="summary"></p><p>Continue in Lilith’s console. The saved board and its evidence remain available.</p><button id="back">View board</button></section>
<dialog id="detail"><button id="close">Close</button><h2 id="title"></h2><pre id="contents"></pre></dialog>
<script>
const token=location.hash.slice(1)||sessionStorage.getItem('kanban-token');if(token)sessionStorage.setItem('kanban-token',token);history.replaceState(null,'',location.pathname);const $=id=>document.getElementById(id);let boards=[],selected='',returned='',timer=null,busy=false;
async function api(path,body){const r=await fetch('/api/'+path,{method:body?'POST':'GET',headers:{'X-Lilith-Kanban':token,...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.error||'Request failed');return data;}
function chat(b){$('board').hidden=true;$('chat').hidden=false;$('summary').textContent=b.Message;returned=b.Id;}
async function control(action){const b=boards.find(b=>b.Id===selected);if(!b||busy)return;if(action==='test'&&!confirm('Execute generated project '+b.Name+' and its bounded repair revisions? This grants execution, not publication.'))return;if(action==='cancel'&&!confirm('Cancel this run and retain its private draft?'))return;busy=true;try{await api('control',{id:b.Id,action,revision:b.Revision});if(action==='chat'){const wait=setInterval(()=>{const latest=boards.find(x=>x.Id===b.Id);if(latest&&['paused','completed','failed','cancelled'].includes(latest.State)){clearInterval(wait);chat(latest);}},500);}}catch(e){alert(e.message);}finally{busy=false;refresh();}}
for(const btn of document.querySelectorAll('[data-action]'))btn.onclick=()=>control(btn.dataset.action);$('detail').addEventListener('close',()=>{$('detail').querySelector('[data-publication]')?.remove();});
$('close').onclick=()=>$('detail').close();$('back').onclick=()=>{$('chat').hidden=true;$('board').hidden=false;};$('runs').onchange=()=>{selected=$('runs').value;returned='';clearTimeout(timer);timer=null;$('chat').hidden=true;$('board').hidden=false;render();};
function button(label,action){const b=document.createElement('button');b.textContent=label;b.onclick=()=>control(action);return b;}
async function reviewPublication(b){try{const p=await api('proposal?id='+encodeURIComponent(b.Id));$('title').textContent='Exact publication proposal';$('contents').textContent=JSON.stringify(p,null,2);const old=$('detail').querySelector('[data-publication]');if(old)old.remove();const approve=button('Approve this exact publication','publication_approve');approve.dataset.publication='true';approve.onclick=async()=>{$('detail').close();await control('publication_approve');};$('detail').append(approve);$('detail').showModal();}catch(e){alert(e.message);}}
let galleryKey='',galleryUrls=[];async function showImages(b){const key=b.Id+JSON.stringify(b.Screenshots);if(key===galleryKey)return;galleryKey=key;for(const url of galleryUrls)URL.revokeObjectURL(url);galleryUrls=[];$('gallery').replaceChildren();for(const [index,image] of (b.Screenshots||[]).entries()){try{const r=await fetch('/api/image?id='+encodeURIComponent(b.Id)+'&index='+index,{headers:{'X-Lilith-Kanban':token}});if(!r.ok)throw Error('Capture unavailable');const url=URL.createObjectURL(await r.blob());if(galleryKey!==key){URL.revokeObjectURL(url);return;}galleryUrls.push(url);const figure=document.createElement('figure'),picture=document.createElement('img'),label=document.createElement('figcaption');picture.src=url;picture.alt=image.Label+' website screenshot';picture.style='max-width:100%;max-height:300px;object-fit:contain';figure.style='margin:0 0 18px;max-width:30%';label.textContent=image.Label+' / '+image.Width+' × '+image.Height;figure.append(picture,label);$('gallery').append(figure);}catch(e){$('message').textContent=e.message;}}}
function render(){const b=boards.find(x=>x.Id===selected);if(!b){$('status').textContent='No runs';return;}$('status').textContent=b.State.toUpperCase()+' · repairs '+b.Cards.reduce((n,c)=>n+c.FailedRepairs,0);$('message').textContent=b.Message;$('columns').replaceChildren();for(const col of ['Backlog','Ready','Doing','Review','Testing','Done','Blocked']){const section=document.createElement('section');section.className='column';const h=document.createElement('h2');const cards=b.Cards.filter(c=>c.Column===col);h.textContent=col+' / '+cards.length;section.append(h);for(const c of cards){const item=document.createElement('button');item.className='card';item.textContent=c.Title;const small=document.createElement('small');small.textContent=c.Role+' · failed repairs '+c.FailedRepairs+'/'+b.RepairLimit;item.append(small);item.onclick=()=>{$('title').textContent=c.Title;$('contents').textContent=JSON.stringify(c,null,2);$('detail').showModal();};section.append(item);}$('columns').append(section);}
const decision=$('decision');decision.replaceChildren();decision.hidden=false;if(b.State==='waiting_plan'){const p=document.createElement('p');p.textContent='Review the PRD and every task before approving this exact plan revision.';decision.append(p,button('Read PRD','view_prd'),button('Approve plan','approve'));decision.children[1].onclick=()=>{$('title').textContent='Approved scope / PRD';$('contents').textContent=b.Prd+'\n\nShared contract:\n'+b.Contract;$('detail').showModal();};}else if(b.State==='waiting_test'){decision.textContent='Execution needs a separate decision for this private project. Read its source and frozen tests using /project-review '+b.DraftId+'.';decision.append(button('Approve project execution','test'));}else if(b.State==='waiting_publication'){decision.textContent='Publication is separate. Review the complete exact proposal before approving it.';if(b.PublicationProposal){const review=button('Review exact publication','view_publication');review.onclick=()=>reviewPublication(b);decision.append(review);}else decision.append(button('Prepare exact publication','publish'));}else decision.hidden=true;
showImages(b);if(['completed','failed','cancelled'].includes(b.State)&&returned!==b.Id&&!timer){timer=setTimeout(()=>{timer=null;chat(b);},10000);}else if(!['completed','failed','cancelled'].includes(b.State)){clearTimeout(timer);timer=null;}}
async function refresh(){try{boards=await api('boards');if(!selected&&boards.length)selected=boards[0].Id;$('runs').replaceChildren(...boards.map(b=>{const o=document.createElement('option');o.value=b.Id;o.textContent=b.Name+' / '+b.State;o.selected=b.Id===selected;return o;}));render();}catch(e){$('status').textContent=e.message;}}refresh();setInterval(refresh,700);
</script></html>
""";
}