← All source files

V18 / 8d896b8a86e5

GuiSession.cs

Interfaces/Gui/LilithGui/GuiSession.cs

Unchanged from the preceding version
CS
using System.Diagnostics;
using System.Text.Json;

namespace Lilith.Gui;

internal sealed record GuiEvent(long Sequence,string Kind,JsonElement Data);
internal sealed class GuiSession(string repo,string? stateRoot=null) : IAsyncDisposable
{
    private readonly object _sync=new();
    private readonly List<GuiEvent> _events=[];
    private readonly Dictionary<string,TaskCompletionSource<JsonElement>> _pending=[];
    private readonly Dictionary<string,string> _images=new(StringComparer.Ordinal);
    private readonly SemaphoreSlim _lifecycle=new(1,1);
    private readonly SemaphoreSlim _writes=new(1,1);
    private Process? _worker;
    private long _sequence;
    private JsonElement? _ready,_status;
    internal Func<JsonElement,Task<object>>? BrowserRequest {get;set;}
    internal GuiVersion? Version {get;private set;}
    internal void Emit(string kind,object data)
    {
        var element=JsonSerializer.SerializeToElement(data);
        lock(_sync)
        {
            if(kind=="reset"){_ready=null;_images.Clear();}
            if(kind=="ready")_ready=element;
            if(kind=="status")_status=element;
            if(kind=="images"&&element.ValueKind==JsonValueKind.Array)
                foreach(var image in element.EnumerateArray())
                {
                    string? hash=image.EnumerateObject().FirstOrDefault(p=>p.Name.Equals("sha256",StringComparison.OrdinalIgnoreCase)).Value.GetString();
                    string? file=image.EnumerateObject().FirstOrDefault(p=>p.Name.Equals("file",StringComparison.OrdinalIgnoreCase)).Value.GetString();
                    if(hash is not null&&file is not null)_images[hash]=file;
                }
            while(_images.Count>100)_images.Remove(_images.Keys.First());
            _events.Add(new(++_sequence,kind,element));if(_events.Count>2000)_events.RemoveRange(0,_events.Count-2000);
        }
    }
    internal byte[] ReadImage(string hash)
    {
        string file;lock(_sync)file=_images.GetValueOrDefault(hash)??throw new FileNotFoundException("Unknown image artifact.");
        string path=Path.GetFullPath(file),root=Path.GetFullPath(Path.Combine(repo,"Admin","GuiState"))+Path.DirectorySeparatorChar;
        if(!path.StartsWith(root,StringComparison.OrdinalIgnoreCase))throw new IOException("Image is outside GUI-owned state.");
        string? check=path;
        while(check is not null&&check.StartsWith(root,StringComparison.OrdinalIgnoreCase))
        {if((File.GetAttributes(check)&FileAttributes.ReparsePoint)!=0)throw new IOException("Linked artifacts are not served.");check=Path.GetDirectoryName(check);}
        if(new FileInfo(path).Length>16000000)throw new IOException("Oversized image.");byte[] bytes=File.ReadAllBytes(path);
        if(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes))!=hash)throw new IOException("Image changed since capture.");
        return bytes;
    }
    internal GuiEvent[] Events(long after){lock(_sync)return _events.Where(e=>e.Sequence>after).Take(500).ToArray();}
    internal object Summary(){lock(_sync)return new{version=Version,ready=_ready,status=_status,sequence=_sequence};}
    internal async Task<JsonElement> BootAsync(int major,JsonElement settings)
    {
        await _lifecycle.WaitAsync();
        try
        {
            await StopAsync();
            var catalog=new VersionCatalog(repo);var version=(await catalog.ListAsync()).Single(v=>v.Major==major);
            Version=version;
            Emit("reset",new{version});
            string backend=await catalog.PrepareAsync(version,text=>Emit("status",new{state="starting",text}));
            string workerProject=Path.Combine(repo,"Interfaces","Gui","LilithGui.Worker","LilithGui.Worker.csproj");
            await VersionCatalog.Run("dotnet",["build",workerProject,"-c","Release","-p:NuGetAudit=false"],repo);
            string worker=Path.Combine(repo,"Admin","Build","bin","Interfaces","Gui","LilithGui.Worker","LilithGui.Worker","Release","net10.0","LilithGui.Worker.dll");
            var start=new ProcessStartInfo("dotnet"){WorkingDirectory=backend,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true,UseShellExecute=false,CreateNoWindow=true};
            start.ArgumentList.Add(worker);start.ArgumentList.Add(backend);
            string state=Path.Combine(stateRoot??Path.Combine(repo,"Admin","GuiState"),"v"+major);Directory.CreateDirectory(state);
            start.ArgumentList.Add(state);start.Environment["AGENT_CORE_CHAT_HISTORY_ROOT"]=Path.Combine(state,"History");
            if(BrowserRequest is not null)start.Environment["LILITH_DESKTOP_BROWSER"]="1";
            start.Environment["LILITH_SCENARIOS_ROOT"]=Path.Combine(backend,"Scenarios");
            // Historical localhost probes have a two-second IPv6 fallback timeout.
            // This affects only the isolated worker, not the user's/global network settings.
            start.Environment["DOTNET_SYSTEM_NET_DISABLEIPV6"]="1";
            _worker=Process.Start(start)??throw new IOException("Backend worker failed to start.");Version=version;
            _=ReadAsync(_worker);_=ReadErrors(_worker);
            return await SendAsync("boot",settings);
        }
        finally{_lifecycle.Release();}
    }
    private async Task ReadAsync(Process process)
    {
        while(await process.StandardOutput.ReadLineAsync() is {} line)
        {
            if(!line.StartsWith("@lilith-gui ",StringComparison.Ordinal))continue;
            try
            {
                using var doc=JsonDocument.Parse(line[12..]);var root=doc.RootElement;string kind=root.GetProperty("kind").GetString()!;var data=root.GetProperty("data").Clone();
                if(kind=="browser_request"&&BrowserRequest is not null){_=HandleBrowserAsync(process,data);continue;}
                if(kind=="reply")
                {
                    string id=data.GetProperty("id").GetString()!;TaskCompletionSource<JsonElement>? completion;
                    lock(_sync){_pending.Remove(id,out completion);}
                    if(data.TryGetProperty("error",out var error))completion?.TrySetException(new InvalidOperationException(error.GetString()));else completion?.TrySetResult(data.GetProperty("result").Clone());
                }
                else Emit(kind,data);
            }
            catch(JsonException){Emit("trace",new{sender="GUI",text="Invalid backend event was ignored."});}
        }
        if(ReferenceEquals(_worker,process))
            lock(_sync){_ready=null;foreach(var pending in _pending.Values)pending.TrySetException(new IOException("Backend process closed."));_pending.Clear();}
    }
    private async Task ReadErrors(Process process){while(await process.StandardError.ReadLineAsync() is {} line)Emit("trace",new{sender="Backend",text=line[..Math.Min(2000,line.Length)]});}
    private async Task HandleBrowserAsync(Process owner,JsonElement data)
    {
        object payload;
        try{payload=new{requestId=data.GetProperty("requestId").GetString(),result=await BrowserRequest!(data)};}
        catch(Exception ex){payload=new{requestId=data.GetProperty("requestId").GetString(),error=ex.Message};}
        await _writes.WaitAsync();
        try{if(ReferenceEquals(_worker,owner)&&!owner.HasExited){await owner.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new{id="browser",action="browser_result",payload}));await owner.StandardInput.FlushAsync();}}
        finally{_writes.Release();}
    }
    internal async Task<JsonElement> SendAsync(string action,JsonElement payload)
    {
        if(_worker is null||_worker.HasExited)throw new InvalidOperationException("Start a version first.");
        string id=Guid.NewGuid().ToString("N");var completion=new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
        lock(_sync)_pending[id]=completion;
        await _writes.WaitAsync();
        try{await _worker.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new{id,action,payload}));await _worker.StandardInput.FlushAsync();}
        finally{_writes.Release();}
        return await completion.Task.WaitAsync(TimeSpan.FromMinutes(45));
    }
    private async Task StopAsync()
    {
        if(_worker is null)return;
        if(!_worker.HasExited)
        {
            try{await SendAsync("stop",JsonSerializer.SerializeToElement(new{})).WaitAsync(TimeSpan.FromSeconds(15));await _worker.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5));}
            catch(Exception){if(!_worker.HasExited)_worker.Kill(true);}
        }
        _worker.Dispose();_worker=null;Version=null;
    }
    public async ValueTask DisposeAsync()=>await StopAsync();
}