Merge nucleic/warm-north-vole-pf37 into dev

This commit is contained in:
2026-08-04 02:46:53 -07:00
parent c34eb1ff61
commit f5ee895d3f
18 changed files with 1515 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Cross-platform-testable renderer/application core for item 11. The WinUI executable and
XAML screens layer onto this project in item 12; no canonical host state lives here. -->
<PropertyGroup>
<RootNamespace>NucleicApp</RootNamespace>
<AssemblyName>NucleicApp.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../NucleicProtocol.Interop/NucleicProtocol.Interop.csproj" />
</ItemGroup>
</Project>
+223
View File
@@ -0,0 +1,223 @@
using NucleicProtocol.Interop;
namespace NucleicApp.Services;
public enum HostConnectionState
{
Stopped,
Connecting,
Connected,
Reconnecting,
Failed,
}
/// Owns one native client handle for one host. Native callbacks arrive on the DLL's dedicated
/// thread and are marshalled onto the renderer dispatcher before state is projected.
public sealed class HostConnection : IAsyncDisposable
{
private readonly object gate = new();
private readonly RendererStore store;
private readonly IProtocolClientFactory clientFactory;
private readonly IRendererDispatcher dispatcher;
private readonly string identityDirectory;
private readonly string deviceLabel;
private IProtocolClient? client;
private CancellationTokenSource? lifetime;
private Task? retryTask;
private string? rendezvousPath;
private int retryAttempt;
public HostConnectionState State { get; private set; } = HostConnectionState.Stopped;
public ProtocolState? LastProtocolState { get; private set; }
public event Action<HostConnectionState>? StateChanged;
public HostConnection(
RendererStore store, string identityDirectory,
IProtocolClientFactory? clientFactory = null,
IRendererDispatcher? dispatcher = null,
string deviceLabel = "Nucleic for Windows")
{
this.store = store;
this.identityDirectory = identityDirectory;
this.clientFactory = clientFactory ?? new ProtocolClientFactory();
this.dispatcher = dispatcher ?? InlineRendererDispatcher.Instance;
this.deviceLabel = deviceLabel;
}
public async Task StartLocalAsync(string rendezvousPath, CancellationToken cancellationToken = default)
{
lock (gate)
{
if (lifetime is not null) throw new InvalidOperationException("connection is already started");
var createdLifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
IProtocolClient? createdClient = null;
try
{
createdClient = clientFactory.Create(new ProtocolClientConfiguration
{
IdentityDirectory = identityDirectory,
DeviceLabel = deviceLabel,
Channel = BuildChannel.Current,
});
createdClient.MessageJsonReceived += OnMessage;
createdClient.StateJsonReceived += OnState;
this.rendezvousPath = rendezvousPath;
lifetime = createdLifetime;
client = createdClient;
}
catch
{
createdClient?.Dispose();
createdLifetime.Dispose();
throw;
}
}
if (await ConnectNowAsync(initial: true, lifetime.Token).ConfigureAwait(false)
!= ProtocolResult.Ok)
{
ScheduleReconnect();
}
}
public ProtocolResult SendIntent(string json)
{
lock (gate) return client?.SendIntent(json) ?? ProtocolResult.InvalidState;
}
private async Task<ProtocolResult> ConnectNowAsync(
bool initial, CancellationToken cancellationToken)
{
IProtocolClient active;
string path;
lock (gate)
{
active = client ?? throw new InvalidOperationException("connection has no client");
path = rendezvousPath ?? throw new InvalidOperationException("connection has no rendezvous");
}
SetState(initial ? HostConnectionState.Connecting : HostConnectionState.Reconnecting);
var record = await RendezvousFile.ReadAsync(path, cancellationToken).ConfigureAwait(false);
var result = active.ConnectLocal(record);
if (result != ProtocolResult.Ok)
SetState(HostConnectionState.Failed);
return result;
}
private void OnMessage(string json) => dispatcher.Post(() => store.Apply(json));
private void OnState(string json)
{
ProtocolState state;
try { state = ProtocolState.Parse(json); }
catch { return; }
dispatcher.Post(() =>
{
LastProtocolState = state;
switch (state.State)
{
case "connecting": SetState(HostConnectionState.Connecting); break;
case "ready":
retryAttempt = 0;
SetState(HostConnectionState.Connected);
_ = SendIntent(ClientIntents.ListSessions());
_ = SendIntent(ClientIntents.ListDashboard());
_ = SendIntent(ClientIntents.ListPeers());
break;
case "failed": SetState(HostConnectionState.Failed); ScheduleReconnect(); break;
case "closed": ScheduleReconnect(); break;
}
});
}
private void ScheduleReconnect()
{
lock (gate)
{
if (lifetime is null || lifetime.IsCancellationRequested || retryTask is { IsCompleted: false })
return;
var cancellationToken = lifetime.Token;
var delay = TimeSpan.FromMilliseconds(Math.Min(30_000, 250 * Math.Pow(2, retryAttempt++)));
retryTask = Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
if (await ConnectNowAsync(initial: false, cancellationToken)
.ConfigureAwait(false) == ProtocolResult.Ok)
{
return;
}
}
catch (OperationCanceledException) { return; }
catch { SetState(HostConnectionState.Failed); }
// A synchronous admission failure (notably InvalidState while the native
// close callback is just ahead of its task cleanup) must not strand the
// connection. The old recursive scheduler observed its own retry task as
// active and silently declined to schedule another attempt.
delay = TimeSpan.FromMilliseconds(
Math.Min(30_000, 250 * Math.Pow(2, retryAttempt++)));
}
}, cancellationToken);
}
}
private void SetState(HostConnectionState next)
{
if (State == next) return;
State = next;
StateChanged?.Invoke(next);
}
public async ValueTask DisposeAsync()
{
CancellationTokenSource? cancellation;
Task? retry;
IProtocolClient? closing;
lock (gate)
{
cancellation = lifetime;
lifetime = null;
retry = retryTask;
retryTask = null;
closing = client;
client = null;
}
if (cancellation is not null) await cancellation.CancelAsync().ConfigureAwait(false);
if (retry is not null) try { await retry.ConfigureAwait(false); } catch (OperationCanceledException) { }
closing?.Dispose();
cancellation?.Dispose();
SetState(HostConnectionState.Stopped);
}
}
internal static class BuildChannel
{
public static string Current
{
get
{
#if NUCLEIC_STABLE
return "release";
#elif NUCLEIC_RC
return "rc";
#elif NUCLEIC_BETA
return "beta";
#elif NUCLEIC_CANARY
return "canary";
#else
return "local";
#endif
}
}
public static string Suffix => Current switch
{
"release" => string.Empty,
"rc" => "-rc",
"beta" => "-beta",
"canary" => "-canary",
_ => "-local",
};
}
+101
View File
@@ -0,0 +1,101 @@
using System.Diagnostics;
using System.Net.Sockets;
using NucleicProtocol.Interop;
namespace NucleicApp.Services;
public sealed record HostdLaunchOptions(
string ExecutablePath,
string RendezvousPath,
string? WorkingDirectory = null,
TimeSpan? StartupTimeout = null);
/// Starts the channel-matched full-trust host and waits for a live owner-only rendezvous.
public sealed class HostdLauncher
{
private readonly HostdLaunchOptions options;
public HostdLauncher(HostdLaunchOptions options) => this.options = options;
public async Task<HostdRendezvous> EnsureRunningAsync(
CancellationToken cancellationToken = default)
{
if (await TryHealthyRendezvousAsync(cancellationToken).ConfigureAwait(false) is { } healthy)
return healthy;
Process? launched = null;
if (!HostMutexExists())
{
if (!File.Exists(options.ExecutablePath))
throw new FileNotFoundException("nucleic-hostd was not found", options.ExecutablePath);
launched = Process.Start(new ProcessStartInfo
{
FileName = options.ExecutablePath,
WorkingDirectory = options.WorkingDirectory
?? Path.GetDirectoryName(options.ExecutablePath) ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
}) ?? throw new InvalidOperationException("Windows refused to start nucleic-hostd");
}
var timeout = options.StartupTimeout ?? TimeSpan.FromSeconds(30);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(timeout);
while (true)
{
deadline.Token.ThrowIfCancellationRequested();
if (launched is { HasExited: true })
throw new InvalidOperationException($"nucleic-hostd exited during startup ({launched.ExitCode})");
if (await TryHealthyRendezvousAsync(deadline.Token).ConfigureAwait(false) is { } ready)
return ready;
await Task.Delay(100, deadline.Token).ConfigureAwait(false);
}
}
private async Task<HostdRendezvous?> TryHealthyRendezvousAsync(CancellationToken cancellationToken)
{
HostdRendezvous record;
try { record = await RendezvousFile.ReadAsync(options.RendezvousPath, cancellationToken); }
catch (Exception error) when (error is IOException or UnauthorizedAccessException or System.Text.Json.JsonException)
{
return null;
}
if (!ProcessIsAlive(record.ProcessId)) return null;
try
{
using var client = new TcpClient();
using var probe = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
probe.CancelAfter(TimeSpan.FromSeconds(1));
await client.ConnectAsync(record.Host, record.Port, probe.Token).ConfigureAwait(false);
return record;
}
catch (Exception error) when (error is SocketException or OperationCanceledException)
{
return null;
}
}
private static bool ProcessIsAlive(int processId)
{
try { return !Process.GetProcessById(processId).HasExited; }
catch (ArgumentException) { return false; }
}
private static bool HostMutexExists()
{
if (!OperatingSystem.IsWindows()) return false;
try
{
if (!Mutex.TryOpenExisting("Local\\nucleic-hostd" + BuildChannel.Suffix, out var mutex))
return false;
mutex.Dispose();
return true;
}
catch (UnauthorizedAccessException)
{
// Existence without access is still existence; hostd's own guard remains definitive.
return true;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
namespace NucleicApp.Services;
public enum OnboardingPhase
{
Idle,
StartingHost,
Connecting,
Ready,
Failed,
}
/// First-run coordinator for the item-11 core. Component installation and OAuth screens plug
/// into the same phase surface once their host intents land; host launch + authenticated local
/// sync are real today.
public sealed class OnboardingService
{
private readonly HostdLauncher launcher;
private readonly HostConnection connection;
public OnboardingPhase Phase { get; private set; } = OnboardingPhase.Idle;
public string? Failure { get; private set; }
public event Action<OnboardingPhase>? PhaseChanged;
public OnboardingService(HostdLauncher launcher, HostConnection connection)
{
this.launcher = launcher;
this.connection = connection;
}
public async Task StartAsync(string rendezvousPath, CancellationToken cancellationToken = default)
{
try
{
SetPhase(OnboardingPhase.StartingHost);
_ = await launcher.EnsureRunningAsync(cancellationToken).ConfigureAwait(false);
SetPhase(OnboardingPhase.Connecting);
var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void Changed(HostConnectionState state)
{
if (state == HostConnectionState.Connected) ready.TrySetResult();
else if (state == HostConnectionState.Failed)
ready.TrySetException(new InvalidOperationException(
connection.LastProtocolState?.Message ?? "local host connection failed"));
}
connection.StateChanged += Changed;
try
{
await connection.StartLocalAsync(rendezvousPath, cancellationToken).ConfigureAwait(false);
await ready.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false);
}
finally { connection.StateChanged -= Changed; }
SetPhase(OnboardingPhase.Ready);
}
catch (Exception error)
{
Failure = error.Message;
SetPhase(OnboardingPhase.Failed);
throw;
}
}
private void SetPhase(OnboardingPhase phase)
{
if (Phase == phase) return;
Phase = phase;
PhaseChanged?.Invoke(phase);
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace NucleicApp.Services;
/// Isolates WinUI's DispatcherQueue from the renderer state machine and keeps tests portable.
public interface IRendererDispatcher
{
void Post(Action action);
}
public sealed class SynchronizationContextDispatcher : IRendererDispatcher
{
private readonly SynchronizationContext context;
public SynchronizationContextDispatcher(SynchronizationContext? context = null)
{
this.context = context ?? SynchronizationContext.Current
?? throw new InvalidOperationException("renderer has no SynchronizationContext");
}
public void Post(Action action) => context.Post(static state => ((Action)state!).Invoke(), action);
}
public sealed class InlineRendererDispatcher : IRendererDispatcher
{
public static InlineRendererDispatcher Instance { get; } = new();
private InlineRendererDispatcher() { }
public void Post(Action action) => action();
}
+155
View File
@@ -0,0 +1,155 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
using NucleicProtocol.Interop;
namespace NucleicApp.Services;
/// Read-only projection of host authority (WINDOWS_PORT §7). Every mutation enters through a
/// HostMsg; user actions leave as ClientMsg intents through ``HostConnection``.
public sealed class RendererStore : INotifyPropertyChanged, IDisposable
{
private readonly Dictionary<string, SessionSummary> sessions = new(StringComparer.Ordinal);
private readonly Dictionary<string, ApprovalRequest> approvals = new(StringComparer.Ordinal);
private readonly ITranscriptProjector? projector;
private IReadOnlyList<SessionSummary> sessionSnapshot = [];
private IReadOnlyList<ApprovalRequest> approvalSnapshot = [];
private JsonElement? dashboard;
private JsonElement? settings;
private JsonElement? peers;
private JsonElement? intelligenceCatalog;
private string? lastError;
public RendererStore(ITranscriptProjector? projector = null) => this.projector = projector;
public IReadOnlyList<SessionSummary> Sessions => sessionSnapshot;
public IReadOnlyList<ApprovalRequest> PendingApprovals => approvalSnapshot;
public JsonElement? Dashboard => dashboard;
public JsonElement? Settings => settings;
public JsonElement? Peers => peers;
public JsonElement? IntelligenceCatalog => intelligenceCatalog;
public string? LastError => lastError;
public event PropertyChangedEventHandler? PropertyChanged;
public event Action<ProjectionDiff>? ProjectionChanged;
public event Action<ProtocolMessage>? MessageApplied;
public void Apply(string hostMessageJson) => Apply(ProtocolMessage.Parse(hostMessageJson));
public void Apply(ProtocolMessage message)
{
var root = message.Root;
switch (message.Tag)
{
case "sessionList":
sessions.Clear();
if (root.TryGetProperty("input", out var list))
foreach (var item in list.EnumerateArray()) UpsertSession(item);
PublishSessions();
break;
case "sessionUpdated":
UpsertSession(root);
PublishSessions();
break;
case "snapshot":
if (root.TryGetProperty("summary", out var summary))
{
UpsertSession(summary);
PublishSessions();
}
if (root.TryGetProperty("pendingApprovals", out var pending))
{
foreach (var item in pending.EnumerateArray()) UpsertApproval(item);
PublishApprovals();
}
break;
case "approvalRequested":
UpsertApproval(root);
PublishApprovals();
break;
case "approvalResolved":
if (String(root, "id") is { } resolved) approvals.Remove(resolved);
PublishApprovals();
break;
case "dashboard":
dashboard = root.Clone();
Changed(nameof(Dashboard));
break;
case "settings":
settings = root.GetProperty("settings").Clone();
Changed(nameof(Settings));
break;
case "peerList":
peers = root.GetProperty("input").Clone();
Changed(nameof(Peers));
break;
case "intelligenceCatalog":
intelligenceCatalog = root.GetProperty("intelligenceCatalog").Clone();
Changed(nameof(IntelligenceCatalog));
break;
case "error":
lastError = String(root, "message") ?? "The host reported an error.";
Changed(nameof(LastError));
break;
}
if (projector is not null)
{
var diff = projector.Apply(message.Json);
if (diff.Root.GetProperty("splices").GetArrayLength() > 0)
ProjectionChanged?.Invoke(diff);
}
MessageApplied?.Invoke(message);
}
private void UpsertSession(JsonElement json)
{
var id = String(json, "sessionID") ?? throw new JsonException("session summary has no sessionID");
sessions[id] = new SessionSummary(
id, String(json, "title") ?? "Untitled", String(json, "projectID"),
String(json, "projectName"), String(json, "status") ?? "unknown",
Int(json, "pendingApprovalCount"), json.Clone());
}
private void UpsertApproval(JsonElement json)
{
var id = String(json, "id") ?? throw new JsonException("approval has no id");
approvals[id] = new ApprovalRequest(
id, String(json, "sessionID") ?? string.Empty, String(json, "title") ?? "Approval",
String(json, "toolName") ?? string.Empty, String(json, "risk") ?? string.Empty,
json.Clone());
}
private void PublishSessions()
{
sessionSnapshot = sessions.Values
.OrderByDescending(item => item.Raw.TryGetProperty("updatedAt", out var value)
? value.GetDouble() : 0)
.ToArray();
Changed(nameof(Sessions));
}
private void PublishApprovals()
{
approvalSnapshot = approvals.Values.ToArray();
Changed(nameof(PendingApprovals));
}
private static string? String(JsonElement root, string name) =>
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() : null;
private static int Int(JsonElement root, string name) =>
root.TryGetProperty(name, out var value) && value.TryGetInt32(out var number) ? number : 0;
private void Changed([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
public void Dispose() => projector?.Dispose();
}
public sealed record SessionSummary(
string Id, string Title, string? ProjectId, string? ProjectName,
string Status, int PendingApprovalCount, JsonElement Raw);
public sealed record ApprovalRequest(
string Id, string SessionId, string Title, string ToolName, string Risk, JsonElement Raw);
+13
View File
@@ -0,0 +1,13 @@
using NucleicProtocol.Interop;
namespace NucleicApp.Services;
public static class RendezvousFile
{
public static async Task<HostdRendezvous> ReadAsync(
string path, CancellationToken cancellationToken = default)
{
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return ProtocolJson.DeserializeRendezvous(json);
}
}