diff --git a/AGENTS.md b/AGENTS.md index 57d0d3d..cb08f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,8 +152,11 @@ For this app: - `DotPilot/DotPilot.csproj` keeps `GenerateDocumentationFile=true` with `CS1591` suppressed so `IDE0005` stays enforceable in CI across all target frameworks without inventing command-line-only build flags - architecture work must keep a vertical-slice shape: each feature owns its contracts, orchestration, and tests behind clear boundaries instead of growing a shared horizontal service layer - keep the Uno app project presentation-only; domain, runtime host, orchestration, integrations, and persistence code must live in separate class-library projects so UI composition does not mix with feature implementation +- when the user asks to implement an epic, the delivery branch and PR must cover all of that epic's direct child issues that belong to the requested scope, not just one child issue with a partial close-out +- epic implementation PRs must include automated tests for every direct child issue they claim to cover, plus the broader runtime and UI regressions required by the touched flows +- do not claim an epic is implemented unless every direct child issue in the requested scope is both realized in code and covered by automated tests; partial coverage is not an acceptable close-out - structure both `DotPilot.Tests` and `DotPilot.UITests` by vertical slice and explicit harness boundaries; do not keep test files in one flat project-root pile -- the first embedded Orleans host cut must use `UseLocalhostClustering` plus in-memory grain storage and reminders; do not introduce remote clustering or external durable stores until a later backlog item explicitly requires them +- the first embedded Orleans runtime cut must use `UseLocalhostClustering` together with in-memory Orleans grain storage and in-memory reminders; do not introduce remote clustering or external durable stores until a later backlog item explicitly requires them, and keep durable resume/replay outside Orleans storage until the cluster topology is intentionally upgraded - GitHub is the backlog, not the product: use issues and PRs only to drive task scope and traceability, and never copy GitHub issue text, labels, workflow language, or tracker metadata into production code, runtime snapshots, or user-facing UI - never claim an epic is complete until its current GitHub scope is verified against the live issue graph; check which issues are real children versus issues that merely depend on the epic or belong to a different parent epic - Desktop responsiveness is a product requirement: avoid synchronous probe, filesystem, network, or process work on UI-facing construction and navigation paths so the app stays fast and immediately reactive diff --git a/Directory.Packages.props b/Directory.Packages.props index 6f6b460..3a8467b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs index 365c16d..0e51348 100644 --- a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs +++ b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs @@ -10,4 +10,7 @@ public enum RuntimeCommunicationProblemCode RuntimeHostUnavailable, OrchestrationUnavailable, PolicyRejected, + SessionArchiveMissing, + ResumeCheckpointMissing, + SessionArchiveCorrupted, } diff --git a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs index ef72978..91d2176 100644 --- a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs +++ b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs @@ -16,6 +16,9 @@ public static class RuntimeCommunicationProblems private const string RuntimeHostUnavailableDetail = "The embedded runtime host is unavailable for the requested operation."; private const string OrchestrationUnavailableDetail = "The orchestration runtime is unavailable for the requested operation."; private const string PolicyRejectedFormat = "The requested action was rejected by policy: {0}."; + private const string SessionArchiveMissingFormat = "No persisted runtime session archive exists for session {0}."; + private const string ResumeCheckpointMissingFormat = "Session {0} does not have a checkpoint that can be resumed."; + private const string SessionArchiveCorruptedFormat = "Session {0} has corrupted persisted runtime state."; public static Problem InvalidPrompt() { @@ -86,6 +89,33 @@ public static Problem PolicyRejected(string policyName) HttpStatusCode.Forbidden); } + public static Problem SessionArchiveMissing(SessionId sessionId) + { + return CreateProblem( + RuntimeCommunicationProblemCode.SessionArchiveMissing, + SessionArchiveMissingFormat, + sessionId.ToString(), + HttpStatusCode.NotFound); + } + + public static Problem ResumeCheckpointMissing(SessionId sessionId) + { + return CreateProblem( + RuntimeCommunicationProblemCode.ResumeCheckpointMissing, + ResumeCheckpointMissingFormat, + sessionId.ToString(), + HttpStatusCode.Conflict); + } + + public static Problem SessionArchiveCorrupted(SessionId sessionId) + { + return CreateProblem( + RuntimeCommunicationProblemCode.SessionArchiveCorrupted, + SessionArchiveCorruptedFormat, + sessionId.ToString(), + HttpStatusCode.InternalServerError); + } + private static Problem CreateProblem( RuntimeCommunicationProblemCode code, string detailFormat, diff --git a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs new file mode 100644 index 0000000..5c53ae0 --- /dev/null +++ b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs @@ -0,0 +1,32 @@ +namespace DotPilot.Core.Features.RuntimeFoundation; + +public sealed record EmbeddedRuntimeTrafficTransitionDescriptor( + string Source, + string Target, + IReadOnlyList SourceMethods, + IReadOnlyList TargetMethods, + bool IsReentrant); + +public sealed record EmbeddedRuntimeTrafficPolicySnapshot( + int IssueNumber, + string IssueLabel, + string Summary, + string MermaidDiagram, + IReadOnlyList AllowedTransitions); + +public sealed record EmbeddedRuntimeTrafficProbe( + Type SourceGrainType, + string SourceMethod, + Type TargetGrainType, + string TargetMethod); + +public sealed record EmbeddedRuntimeTrafficDecision( + bool IsAllowed, + string MermaidDiagram); + +public interface IEmbeddedRuntimeTrafficPolicyCatalog +{ + EmbeddedRuntimeTrafficPolicySnapshot GetSnapshot(); + + EmbeddedRuntimeTrafficDecision Evaluate(EmbeddedRuntimeTrafficProbe probe); +} diff --git a/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs b/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs index 33974d2..8eb6db8 100644 --- a/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs +++ b/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs @@ -1,3 +1,4 @@ +using DotPilot.Core.Features.ControlPlaneDomain; using ManagedCode.Communication; namespace DotPilot.Core.Features.RuntimeFoundation; @@ -5,4 +6,8 @@ namespace DotPilot.Core.Features.RuntimeFoundation; public interface IAgentRuntimeClient { ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken); + + ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken); + + ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken); } diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs index 4aed32f..e3792e1 100644 --- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs +++ b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs @@ -9,6 +9,8 @@ public static class RuntimeFoundationIssues public const int CommunicationContracts = 23; public const int EmbeddedOrleansHost = 24; public const int AgentFrameworkRuntime = 25; + public const int GrainTrafficPolicy = 26; + public const int SessionPersistence = 27; public static string FormatIssueLabel(int issueNumber) => string.Concat(IssuePrefix, issueNumber); } diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs new file mode 100644 index 0000000..99e6d49 --- /dev/null +++ b/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs @@ -0,0 +1,25 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.RuntimeFoundation; + +public sealed record AgentTurnResumeRequest( + SessionId SessionId, + ApprovalState ApprovalState, + string Summary); + +public sealed record RuntimeSessionReplayEntry( + string Kind, + string Summary, + SessionPhase Phase, + ApprovalState ApprovalState, + DateTimeOffset RecordedAt); + +public sealed record RuntimeSessionArchive( + SessionId SessionId, + string WorkflowSessionId, + SessionPhase Phase, + ApprovalState ApprovalState, + DateTimeOffset UpdatedAt, + string? CheckpointId, + IReadOnlyList Replay, + IReadOnlyList Artifacts); diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs index 8ff3dca..59af2ed 100644 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs @@ -20,6 +20,7 @@ public static IHostBuilder UseDotPilotEmbeddedRuntime( services.AddSingleton(resolvedOptions); services.AddSingleton(); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(); services.AddHostedService(); }); diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs index bf4dab1..aff05e0 100644 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs @@ -7,6 +7,8 @@ internal static class EmbeddedRuntimeHostNames public const int DefaultSiloPort = 11_111; public const int DefaultGatewayPort = 30_000; public const string GrainStorageProviderName = "runtime-foundation-memory"; + public const string ClientSourceName = "Client"; + public const string ClientSourceMethodName = "Invoke"; public const string SessionStateName = "session"; public const string WorkspaceStateName = "workspace"; public const string FleetStateName = "fleet"; diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs new file mode 100644 index 0000000..85494bd --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs @@ -0,0 +1,124 @@ +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +internal static class EmbeddedRuntimeTrafficPolicy +{ + private const string PolicySummary = + "Client and grain transitions stay explicit so the embedded host can reject unsupported hops before the runtime model grows."; + private const string MermaidHeader = "flowchart LR"; + private const string MermaidArrow = " --> "; + private const string MermaidActiveArrow = " ==> "; + + public static string Summary => PolicySummary; + + public static IReadOnlyList AllowedTransitions => + [ + CreateClientTransition(EmbeddedRuntimeHostNames.SessionGrainName), + CreateClientTransition(EmbeddedRuntimeHostNames.WorkspaceGrainName), + CreateClientTransition(EmbeddedRuntimeHostNames.FleetGrainName), + CreateClientTransition(EmbeddedRuntimeHostNames.PolicyGrainName), + CreateClientTransition(EmbeddedRuntimeHostNames.ArtifactGrainName), + CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.WorkspaceGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IWorkspaceGrain.GetAsync)), + CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.FleetGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IFleetGrain.GetAsync)), + CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.PolicyGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IPolicyGrain.GetAsync)), + CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.ArtifactGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IArtifactGrain.UpsertAsync)), + CreateTransition(EmbeddedRuntimeHostNames.FleetGrainName, EmbeddedRuntimeHostNames.PolicyGrainName, nameof(IFleetGrain.GetAsync), nameof(IPolicyGrain.GetAsync)), + ]; + + public static bool IsAllowed(EmbeddedRuntimeTrafficProbe probe) + { + ArgumentNullException.ThrowIfNull(probe); + + var sourceName = GetGrainName(probe.SourceGrainType); + var targetName = GetGrainName(probe.TargetGrainType); + + return AllowedTransitions.Any(transition => + string.Equals(transition.Source, sourceName, StringComparison.Ordinal) && + string.Equals(transition.Target, targetName, StringComparison.Ordinal) && + transition.SourceMethods.Contains(probe.SourceMethod, StringComparer.Ordinal) && + transition.TargetMethods.Contains(probe.TargetMethod, StringComparer.Ordinal)); + } + + public static string CreateMermaidDiagram() + { + return CreateMermaidDiagramCore(activeTransition: null); + } + + public static string CreateMermaidDiagram(EmbeddedRuntimeTrafficProbe probe) + { + ArgumentNullException.ThrowIfNull(probe); + + var activeTransition = ( + Source: GetGrainName(probe.SourceGrainType), + Target: GetGrainName(probe.TargetGrainType), + SourceMethod: probe.SourceMethod, + TargetMethod: probe.TargetMethod); + return CreateMermaidDiagramCore(activeTransition); + } + + private static EmbeddedRuntimeTrafficTransitionDescriptor CreateClientTransition(string target) + { + return new( + EmbeddedRuntimeHostNames.ClientSourceName, + target, + [EmbeddedRuntimeHostNames.ClientSourceMethodName], + [nameof(ISessionGrain.GetAsync), nameof(ISessionGrain.UpsertAsync)], + false); + } + + private static EmbeddedRuntimeTrafficTransitionDescriptor CreateTransition( + string source, + string target, + string sourceMethod, + string targetMethod) + { + return new( + source, + target, + [sourceMethod], + [targetMethod], + false); + } + + private static string GetGrainName(Type grainType) + { + ArgumentNullException.ThrowIfNull(grainType); + + return grainType == typeof(ISessionGrain) ? EmbeddedRuntimeHostNames.SessionGrainName + : grainType == typeof(IWorkspaceGrain) ? EmbeddedRuntimeHostNames.WorkspaceGrainName + : grainType == typeof(IFleetGrain) ? EmbeddedRuntimeHostNames.FleetGrainName + : grainType == typeof(IPolicyGrain) ? EmbeddedRuntimeHostNames.PolicyGrainName + : grainType == typeof(IArtifactGrain) ? EmbeddedRuntimeHostNames.ArtifactGrainName + : grainType.Name; + } + + private static string CreateMermaidDiagramCore((string Source, string Target, string SourceMethod, string TargetMethod)? activeTransition) + { + var lines = new List(AllowedTransitions.Count + 1) + { + MermaidHeader, + }; + + foreach (var transition in AllowedTransitions) + { + var isActive = activeTransition is not null && + string.Equals(transition.Source, activeTransition.Value.Source, StringComparison.Ordinal) && + string.Equals(transition.Target, activeTransition.Value.Target, StringComparison.Ordinal) && + transition.SourceMethods.Contains(activeTransition.Value.SourceMethod, StringComparer.Ordinal) && + transition.TargetMethods.Contains(activeTransition.Value.TargetMethod, StringComparer.Ordinal); + var arrow = isActive ? MermaidActiveArrow : MermaidArrow; + lines.Add( + string.Concat( + transition.Source, + arrow, + transition.Target, + " : ", + string.Join(", ", transition.SourceMethods), + " -> ", + string.Join(", ", transition.TargetMethods))); + } + + return string.Join(Environment.NewLine, lines); + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs new file mode 100644 index 0000000..f9236c8 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs @@ -0,0 +1,25 @@ +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +internal sealed class EmbeddedRuntimeTrafficPolicyCatalog : IEmbeddedRuntimeTrafficPolicyCatalog +{ + public EmbeddedRuntimeTrafficPolicySnapshot GetSnapshot() + { + return new( + RuntimeFoundationIssues.GrainTrafficPolicy, + RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.GrainTrafficPolicy), + EmbeddedRuntimeTrafficPolicy.Summary, + EmbeddedRuntimeTrafficPolicy.CreateMermaidDiagram(), + EmbeddedRuntimeTrafficPolicy.AllowedTransitions); + } + + public EmbeddedRuntimeTrafficDecision Evaluate(EmbeddedRuntimeTrafficProbe probe) + { + ArgumentNullException.ThrowIfNull(probe); + + return new EmbeddedRuntimeTrafficDecision( + EmbeddedRuntimeTrafficPolicy.IsAllowed(probe), + EmbeddedRuntimeTrafficPolicy.CreateMermaidDiagram(probe)); + } +} diff --git a/DotPilot.Runtime/DotPilot.Runtime.csproj b/DotPilot.Runtime/DotPilot.Runtime.csproj index 504203a..729ab80 100644 --- a/DotPilot.Runtime/DotPilot.Runtime.csproj +++ b/DotPilot.Runtime/DotPilot.Runtime.csproj @@ -6,6 +6,11 @@ $(NoWarn);CS1591 + + + + + diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs b/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs new file mode 100644 index 0000000..bba90f6 --- /dev/null +++ b/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs @@ -0,0 +1,447 @@ +using System.Text.Json; +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeCommunication; +using DotPilot.Core.Features.RuntimeFoundation; +using ManagedCode.Communication; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; + +namespace DotPilot.Runtime.Features.RuntimeFoundation; + +public sealed class AgentFrameworkRuntimeClient : IAgentRuntimeClient +{ + private const string WorkflowName = "DotPilotRuntimeFoundationWorkflow"; + private const string WorkflowDescription = + "Runs the local-first runtime flow with checkpointed pause and resume support for approval-gated sessions."; + private const string ExecutorId = "runtime-foundation"; + private const string StateKey = "runtime-foundation-state"; + private const string StartReplayKind = "run-started"; + private const string PauseReplayKind = "approval-pending"; + private const string ResumeReplayKind = "run-resumed"; + private const string RejectedReplayKind = "approval-rejected"; + private const string CompletedReplayKind = "run-completed"; + private const string ResumeNotAllowedDetailFormat = + "Session {0} is not paused with pending approval and cannot be resumed."; + private static readonly System.Text.CompositeFormat ResumeNotAllowedDetailCompositeFormat = + System.Text.CompositeFormat.Parse(ResumeNotAllowedDetailFormat); + private readonly IGrainFactory _grainFactory; + private readonly RuntimeSessionArchiveStore _archiveStore; + private readonly DeterministicAgentTurnEngine _turnEngine; + private readonly Workflow _workflow; + private readonly TimeProvider _timeProvider; + + public AgentFrameworkRuntimeClient(IGrainFactory grainFactory, RuntimeSessionArchiveStore archiveStore) + : this(grainFactory, archiveStore, TimeProvider.System) + { + } + + internal AgentFrameworkRuntimeClient( + IGrainFactory grainFactory, + RuntimeSessionArchiveStore archiveStore, + TimeProvider timeProvider) + { + _grainFactory = grainFactory; + _archiveStore = archiveStore; + _timeProvider = timeProvider; + _turnEngine = new DeterministicAgentTurnEngine(timeProvider); + _workflow = BuildWorkflow(); + } + + public async ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var validation = _turnEngine.Execute(request); + if (validation.IsFailed) + { + return validation; + } + + var checkpointDirectory = _archiveStore.CreateCheckpointDirectory(request.SessionId); + using var checkpointStore = new FileSystemJsonCheckpointStore(checkpointDirectory); + var checkpointManager = CheckpointManager.CreateJson(checkpointStore); + await using var run = await InProcessExecution.RunAsync( + _workflow, + RuntimeWorkflowSignal.Start(request), + checkpointManager, + request.SessionId.ToString(), + cancellationToken); + + var result = ExtractOutput(run); + if (result is null) + { + return Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()); + } + + var checkpoint = await ResolveCheckpointAsync(run, checkpointDirectory, request.SessionId.ToString(), cancellationToken); + await PersistRuntimeStateAsync( + request, + result, + checkpoint, + result.ApprovalState is ApprovalState.Pending ? PauseReplayKind : StartReplayKind, + cancellationToken); + + return Result.Succeed(result); + } + + public async ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + StoredRuntimeSessionArchive? archive; + try + { + archive = await _archiveStore.LoadAsync(request.SessionId, cancellationToken); + } + catch (JsonException) + { + return Result.Fail(RuntimeCommunicationProblems.SessionArchiveCorrupted(request.SessionId)); + } + + if (archive is null) + { + return Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(request.SessionId)); + } + + if (archive.Phase is not SessionPhase.Paused || archive.ApprovalState is not ApprovalState.Pending) + { + return Result.Fail(CreateResumeNotAllowedProblem(request.SessionId)); + } + + if (string.IsNullOrWhiteSpace(archive.CheckpointId)) + { + return Result.Fail(RuntimeCommunicationProblems.ResumeCheckpointMissing(request.SessionId)); + } + + var checkpointDirectory = _archiveStore.CreateCheckpointDirectory(request.SessionId); + using var checkpointStore = new FileSystemJsonCheckpointStore(checkpointDirectory); + var checkpointManager = CheckpointManager.CreateJson(checkpointStore); + await using var restoredRun = await InProcessExecution.ResumeAsync( + _workflow, + new CheckpointInfo(archive.WorkflowSessionId, archive.CheckpointId), + checkpointManager, + cancellationToken); + _ = await restoredRun.ResumeAsync(cancellationToken, [RuntimeWorkflowSignal.Resume(request)]); + + var result = ExtractOutput(restoredRun); + if (result is null) + { + return Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()); + } + + var resolvedCheckpoint = + await ResolveCheckpointAsync(restoredRun, checkpointDirectory, archive.WorkflowSessionId, cancellationToken) ?? + new CheckpointInfo(archive.WorkflowSessionId, archive.CheckpointId); + await PersistRuntimeStateAsync( + archive.OriginalRequest, + result, + resolvedCheckpoint, + ResolveResumeReplayKind(request.ApprovalState), + cancellationToken); + + return Result.Succeed(result); + } + + public async ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var archive = await _archiveStore.LoadAsync(sessionId, cancellationToken); + if (archive is null) + { + return Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(sessionId)); + } + + return Result.Succeed(RuntimeSessionArchiveStore.ToSnapshot(archive)); + } + catch (JsonException) + { + return Result.Fail(RuntimeCommunicationProblems.SessionArchiveCorrupted(sessionId)); + } + } + + private static AgentTurnResult? ExtractOutput(Run run) + { + return run.OutgoingEvents + .OfType() + .Select(output => output.Data) + .OfType() + .LastOrDefault(); + } + + private static CheckpointInfo? ExtractCheckpoint(Run run) + { + var checkpoints = run.Checkpoints; + return run.OutgoingEvents + .OfType() + .Select(step => step.CompletionInfo?.Checkpoint) + .LastOrDefault(checkpoint => checkpoint is not null) ?? + run.LastCheckpoint ?? + (checkpoints.Count > 0 ? checkpoints[checkpoints.Count - 1] : null); + } + + private static ValueTask ResolveCheckpointAsync( + Run run, + DirectoryInfo checkpointDirectory, + string workflowSessionId, + CancellationToken cancellationToken) + { + return ResolveCheckpointCoreAsync(run, checkpointDirectory, workflowSessionId, cancellationToken); + } + + private static async ValueTask ResolveCheckpointCoreAsync( + Run run, + DirectoryInfo checkpointDirectory, + string workflowSessionId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + for (var attempt = 0; attempt < 200; attempt++) + { + var inMemoryCheckpoint = ExtractCheckpoint(run); + if (inMemoryCheckpoint is not null) + { + return inMemoryCheckpoint; + } + + var persistedCheckpoint = checkpointDirectory + .EnumerateFiles($"{workflowSessionId}_*.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(file => file.LastWriteTimeUtc) + .Select(file => TryCreateCheckpointInfo(workflowSessionId, file)) + .FirstOrDefault(checkpoint => checkpoint is not null); + if (persistedCheckpoint is not null) + { + return persistedCheckpoint; + } + + var status = await run.GetStatusAsync(cancellationToken); + if (status is not RunStatus.Running) + { + await Task.Yield(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken); + } + + return ExtractCheckpoint(run); + } + + private static CheckpointInfo? TryCreateCheckpointInfo(string workflowSessionId, FileInfo file) + { + var fileName = Path.GetFileNameWithoutExtension(file.Name); + var prefix = $"{workflowSessionId}_"; + if (!fileName.StartsWith(prefix, StringComparison.Ordinal)) + { + return null; + } + + var checkpointId = fileName[prefix.Length..]; + return string.IsNullOrWhiteSpace(checkpointId) + ? null + : new CheckpointInfo(workflowSessionId, checkpointId); + } + + private async ValueTask PersistRuntimeStateAsync( + AgentTurnRequest originalRequest, + AgentTurnResult result, + CheckpointInfo? checkpoint, + string replayKind, + CancellationToken cancellationToken) + { + var existingArchive = await _archiveStore.LoadAsync(originalRequest.SessionId, cancellationToken); + var replay = existingArchive?.Replay.ToList() ?? []; + var recordedAt = _timeProvider.GetUtcNow(); + replay.Add( + new RuntimeSessionReplayEntry( + replayKind, + result.Summary, + result.NextPhase, + result.ApprovalState, + recordedAt)); + if (result.NextPhase is SessionPhase.Execute or SessionPhase.Review or SessionPhase.Failed) + { + replay.Add( + new RuntimeSessionReplayEntry( + CompletedReplayKind, + result.Summary, + result.NextPhase, + result.ApprovalState, + recordedAt)); + } + + var archive = new StoredRuntimeSessionArchive( + originalRequest.SessionId, + checkpoint?.SessionId ?? existingArchive?.WorkflowSessionId ?? originalRequest.SessionId.ToString(), + checkpoint?.CheckpointId, + originalRequest, + result.NextPhase, + result.ApprovalState, + recordedAt, + replay, + result.ProducedArtifacts); + + await _archiveStore.SaveAsync(archive, cancellationToken); + await UpsertSessionStateAsync(originalRequest, result, recordedAt); + await UpsertArtifactsAsync(result.ProducedArtifacts); + } + + private async ValueTask UpsertSessionStateAsync(AgentTurnRequest request, AgentTurnResult result, DateTimeOffset timestamp) + { + var session = new SessionDescriptor + { + Id = request.SessionId, + WorkspaceId = new WorkspaceId(Guid.Empty), + Title = request.Prompt, + Phase = result.NextPhase, + ApprovalState = result.ApprovalState, + AgentProfileIds = [request.AgentProfileId], + CreatedAt = timestamp, + UpdatedAt = timestamp, + }; + + await _grainFactory.GetGrain(request.SessionId.ToString()).UpsertAsync(session); + } + + private async ValueTask UpsertArtifactsAsync(IReadOnlyList artifacts) + { + foreach (var artifact in artifacts) + { + await _grainFactory.GetGrain(artifact.Id.ToString()).UpsertAsync(artifact); + } + } + + private Workflow BuildWorkflow() + { + var runtimeExecutor = new FunctionExecutor( + ExecutorId, + HandleSignalAsync, + outputTypes: [typeof(AgentTurnResult)], + declareCrossRunShareable: true); + var builder = new WorkflowBuilder(runtimeExecutor) + .WithName(WorkflowName) + .WithDescription(WorkflowDescription) + .WithOutputFrom(runtimeExecutor); + return builder.Build(); + } + + private async ValueTask HandleSignalAsync( + RuntimeWorkflowSignal signal, + IWorkflowContext context, + CancellationToken cancellationToken) + { + var state = await context.ReadOrInitStateAsync(StateKey, static () => new RuntimeWorkflowState(), cancellationToken); + + switch (signal.Kind) + { + case RuntimeWorkflowSignalKind.Start: + await HandleStartAsync(signal, context, cancellationToken); + return; + case RuntimeWorkflowSignalKind.Resume: + await HandleResumeAsync(signal, state, context, cancellationToken); + return; + default: + await context.RequestHaltAsync(); + return; + } + } + + private async ValueTask HandleStartAsync( + RuntimeWorkflowSignal signal, + IWorkflowContext context, + CancellationToken cancellationToken) + { + var request = signal.Request ?? throw new InvalidOperationException("Runtime workflow start requires an AgentTurnRequest."); + var result = _turnEngine.Execute(request); + if (result.IsFailed) + { + await context.RequestHaltAsync(); + return; + } + + var output = result.Value!; + await context.QueueStateUpdateAsync( + StateKey, + new RuntimeWorkflowState + { + OriginalRequest = request, + ApprovalPending = output.ApprovalState is ApprovalState.Pending, + }, + cancellationToken); + await context.YieldOutputAsync(output, cancellationToken); + await context.RequestHaltAsync(); + } + + private static string ResolveResumeReplayKind(ApprovalState approvalState) + { + return approvalState switch + { + ApprovalState.Approved => ResumeReplayKind, + ApprovalState.Rejected => RejectedReplayKind, + _ => PauseReplayKind, + }; + } + + private static Problem CreateResumeNotAllowedProblem(SessionId sessionId) + { + return Problem.Create( + RuntimeCommunicationProblemCode.ResumeCheckpointMissing, + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + ResumeNotAllowedDetailCompositeFormat, + sessionId), + (int)System.Net.HttpStatusCode.Conflict); + } + + private async ValueTask HandleResumeAsync( + RuntimeWorkflowSignal signal, + RuntimeWorkflowState state, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (state.OriginalRequest is null) + { + await context.RequestHaltAsync(); + return; + } + + var resumeRequest = signal.ResumeRequest ?? throw new InvalidOperationException("Runtime workflow resume requires an AgentTurnResumeRequest."); + var resumedOutput = _turnEngine.Resume(state.OriginalRequest, resumeRequest); + await context.QueueStateUpdateAsync( + StateKey, + state with + { + ApprovalPending = resumedOutput.ApprovalState is ApprovalState.Pending, + }, + cancellationToken); + await context.YieldOutputAsync(resumedOutput, cancellationToken); + await context.RequestHaltAsync(); + } +} + +internal enum RuntimeWorkflowSignalKind +{ + Start, + Resume, +} + +internal sealed record RuntimeWorkflowSignal( + RuntimeWorkflowSignalKind Kind, + AgentTurnRequest? Request, + AgentTurnResumeRequest? ResumeRequest) +{ + public static RuntimeWorkflowSignal Start(AgentTurnRequest request) => + new(RuntimeWorkflowSignalKind.Start, request, null); + + public static RuntimeWorkflowSignal Resume(AgentTurnResumeRequest request) => + new(RuntimeWorkflowSignalKind.Resume, null, request); +} + +internal sealed record RuntimeWorkflowState +{ + public AgentTurnRequest? OriginalRequest { get; init; } + + public bool ApprovalPending { get; init; } +} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs index 2222f8d..1d32121 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs @@ -7,81 +7,54 @@ namespace DotPilot.Runtime.Features.RuntimeFoundation; public sealed class DeterministicAgentRuntimeClient : IAgentRuntimeClient { - private const string ApprovalKeyword = "approval"; - private const string PlanSummary = - "Planned the runtime foundation flow with contracts first, then communication, host lifecycle, and orchestration."; - private const string ExecuteSummary = - "Executed the provider-independent runtime path with the deterministic client and produced the expected artifact manifest."; - private const string PendingApprovalSummary = - "The deterministic runtime path paused because the prompt explicitly requested an approval checkpoint."; - private const string ReviewSummary = - "Reviewed the runtime foundation output and confirmed the slice is ready for the next implementation branch."; - private const string PlanArtifact = "runtime-foundation.plan.md"; - private const string ExecuteArtifact = "runtime-foundation.snapshot.json"; - private const string ReviewArtifact = "runtime-foundation.review.md"; + private readonly DeterministicAgentTurnEngine _engine; + + public DeterministicAgentRuntimeClient() + : this(TimeProvider.System) + { + } + + internal DeterministicAgentRuntimeClient(TimeProvider timeProvider) + { + _engine = new DeterministicAgentTurnEngine(timeProvider); + } public ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(request.Prompt)) - { - return ValueTask.FromResult(Result.Fail(RuntimeCommunicationProblems.InvalidPrompt())); - } - - if (request.ProviderStatus is not ProviderConnectionStatus.Available) - { - return ValueTask.FromResult( - Result.Fail( - RuntimeCommunicationProblems.ProviderUnavailable( - request.ProviderStatus, - ProviderToolchainNames.DeterministicClientDisplayName))); - } + return ValueTask.FromResult(NormalizeArtifacts(_engine.Execute(request))); + } - return ValueTask.FromResult(request.Mode switch - { - AgentExecutionMode.Plan => Result.Succeed( - new AgentTurnResult( - PlanSummary, - SessionPhase.Plan, - ApprovalState.NotRequired, - [CreateArtifact(request.SessionId, PlanArtifact, ArtifactKind.Plan)])), - AgentExecutionMode.Execute when RequiresApproval(request.Prompt) => Result.Succeed( - new AgentTurnResult( - PendingApprovalSummary, - SessionPhase.Paused, - ApprovalState.Pending, - [CreateArtifact(request.SessionId, ExecuteArtifact, ArtifactKind.Snapshot)])), - AgentExecutionMode.Execute => Result.Succeed( - new AgentTurnResult( - ExecuteSummary, - SessionPhase.Execute, - ApprovalState.NotRequired, - [CreateArtifact(request.SessionId, ExecuteArtifact, ArtifactKind.Snapshot)])), - AgentExecutionMode.Review => Result.Succeed( - new AgentTurnResult( - ReviewSummary, - SessionPhase.Review, - ApprovalState.Approved, - [CreateArtifact(request.SessionId, ReviewArtifact, ArtifactKind.Report)])), - _ => Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()), - }); + public ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _ = request; + return ValueTask.FromResult(Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable())); } - private static bool RequiresApproval(string prompt) + public ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken) { - return prompt.Contains(ApprovalKeyword, StringComparison.OrdinalIgnoreCase); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(sessionId))); } - private static ArtifactDescriptor CreateArtifact(SessionId sessionId, string artifactName, ArtifactKind artifactKind) + private static Result NormalizeArtifacts(Result result) { - return new ArtifactDescriptor + if (result.IsFailed || result.Value is null) { - Id = RuntimeFoundationDeterministicIdentity.CreateArtifactId(sessionId, artifactName), - SessionId = sessionId, - Name = artifactName, - Kind = artifactKind, - RelativePath = artifactName, - CreatedAt = RuntimeFoundationDeterministicIdentity.ArtifactCreatedAt, - }; + return result; + } + + var outcome = result.Value; + var normalizedArtifacts = outcome.ProducedArtifacts + .Select(artifact => artifact with { CreatedAt = RuntimeFoundationDeterministicIdentity.ArtifactCreatedAt }) + .ToArray(); + + return Result.Succeed( + new AgentTurnResult( + outcome.Summary, + outcome.NextPhase, + outcome.ApprovalState, + normalizedArtifacts)); } } diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs new file mode 100644 index 0000000..264956d --- /dev/null +++ b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs @@ -0,0 +1,171 @@ +using System.Security.Cryptography; +using System.Text; +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeCommunication; +using DotPilot.Core.Features.RuntimeFoundation; +using ManagedCode.Communication; + +namespace DotPilot.Runtime.Features.RuntimeFoundation; + +internal sealed class DeterministicAgentTurnEngine(TimeProvider timeProvider) +{ + private const string ApprovalKeyword = "approval"; + private const string PlanSummary = + "Prepared a local-first runtime plan with isolated orchestration, storage, and policy boundaries."; + private const string ExecuteSummary = + "Completed the deterministic runtime flow and produced the expected local session artifacts."; + private const string PendingApprovalSummary = + "Paused the deterministic runtime flow because the prompt requested an approval checkpoint."; + private const string ResumedExecutionSummary = + "Resumed the persisted runtime flow after approval and completed the pending execution step."; + private const string RejectedExecutionSummary = + "Stopped the persisted runtime flow because the approval checkpoint was rejected."; + private const string ReviewSummary = + "Reviewed the runtime output and prepared the local session summary for the next operator action."; + private const string PlanArtifact = "runtime-foundation.plan.md"; + private const string ExecuteArtifact = "runtime-foundation.snapshot.json"; + private const string ReviewArtifact = "runtime-foundation.review.md"; + private const string ArtifactIdentityDelimiter = "|"; + + public Result Execute(AgentTurnRequest request) + { + if (string.IsNullOrWhiteSpace(request.Prompt)) + { + return Result.Fail(RuntimeCommunicationProblems.InvalidPrompt()); + } + + if (request.ProviderStatus is not ProviderConnectionStatus.Available) + { + return Result.Fail( + RuntimeCommunicationProblems.ProviderUnavailable( + request.ProviderStatus, + ProviderToolchainNames.DeterministicClientDisplayName)); + } + + return request.Mode switch + { + AgentExecutionMode.Plan => Result.Succeed( + CreateResult( + request.SessionId, + request.AgentProfileId, + PlanSummary, + SessionPhase.Plan, + ApprovalState.NotRequired, + PlanArtifact, + ArtifactKind.Plan)), + AgentExecutionMode.Execute when RequiresApproval(request.Prompt) => Result.Succeed( + CreateResult( + request.SessionId, + request.AgentProfileId, + PendingApprovalSummary, + SessionPhase.Paused, + ApprovalState.Pending, + ExecuteArtifact, + ArtifactKind.Snapshot)), + AgentExecutionMode.Execute => Result.Succeed( + CreateResult( + request.SessionId, + request.AgentProfileId, + ExecuteSummary, + SessionPhase.Execute, + ApprovalState.NotRequired, + ExecuteArtifact, + ArtifactKind.Snapshot)), + AgentExecutionMode.Review => Result.Succeed( + CreateResult( + request.SessionId, + request.AgentProfileId, + ReviewSummary, + SessionPhase.Review, + ApprovalState.Approved, + ReviewArtifact, + ArtifactKind.Report)), + _ => Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()), + }; + } + + public AgentTurnResult Resume(AgentTurnRequest request, AgentTurnResumeRequest resumeRequest) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resumeRequest); + + return resumeRequest.ApprovalState switch + { + ApprovalState.Approved => CreateResult( + request.SessionId, + request.AgentProfileId, + ResumedExecutionSummary, + SessionPhase.Execute, + ApprovalState.Approved, + ExecuteArtifact, + ArtifactKind.Snapshot), + ApprovalState.Rejected => CreateResult( + request.SessionId, + request.AgentProfileId, + string.IsNullOrWhiteSpace(resumeRequest.Summary) ? RejectedExecutionSummary : resumeRequest.Summary, + SessionPhase.Failed, + ApprovalState.Rejected, + ReviewArtifact, + ArtifactKind.Report), + _ => CreateResult( + request.SessionId, + request.AgentProfileId, + PendingApprovalSummary, + SessionPhase.Paused, + ApprovalState.Pending, + ExecuteArtifact, + ArtifactKind.Snapshot), + }; + } + + public static bool RequiresApproval(string prompt) + { + return prompt.Contains(ApprovalKeyword, StringComparison.OrdinalIgnoreCase); + } + + private AgentTurnResult CreateResult( + SessionId sessionId, + AgentProfileId agentProfileId, + string summary, + SessionPhase nextPhase, + ApprovalState approvalState, + string artifactName, + ArtifactKind artifactKind) + { + return new AgentTurnResult( + summary, + nextPhase, + approvalState, + [CreateArtifact(sessionId, agentProfileId, artifactName, artifactKind)]); + } + + private ArtifactDescriptor CreateArtifact( + SessionId sessionId, + AgentProfileId agentProfileId, + string artifactName, + ArtifactKind artifactKind) + { + return new ArtifactDescriptor + { + Id = new ArtifactId(CreateDeterministicGuid(sessionId, artifactName, artifactKind)), + SessionId = sessionId, + AgentProfileId = agentProfileId, + Name = artifactName, + Kind = artifactKind, + RelativePath = artifactName, + CreatedAt = timeProvider.GetUtcNow(), + }; + } + + private static Guid CreateDeterministicGuid(SessionId sessionId, string artifactName, ArtifactKind artifactKind) + { + var rawIdentity = string.Join( + ArtifactIdentityDelimiter, + sessionId.ToString(), + artifactName, + artifactKind.ToString()); + Span bytes = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(rawIdentity), bytes); + return new Guid(bytes[..16]); + } +} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs index a6118c6..1c31d8b 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs @@ -1,11 +1,12 @@ using DotPilot.Core.Features.ControlPlaneDomain; using DotPilot.Core.Features.RuntimeFoundation; + namespace DotPilot.Runtime.Features.RuntimeFoundation; public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog { private const string EpicSummary = - "Runtime contracts, host sequencing, and orchestration seams stay isolated so the Uno app can remain presentation-only."; + "The embedded runtime stays local-first by isolating contracts, host wiring, orchestration, policy, and durable session archives away from the Uno presentation layer."; private const string EpicLabelValue = "LOCAL RUNTIME READINESS"; private const string DeterministicProbePrompt = "Summarize the runtime foundation readiness for a local-first session that may require approval."; @@ -25,7 +26,13 @@ public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog private const string OrchestrationLabel = "ORCHESTRATION"; private const string OrchestrationName = "Orchestration runtime"; private const string OrchestrationSummary = - "Agent Framework integration is prepared as a separate slice that can plug into the embedded host without reshaping the UI layer."; + "Agent Framework orchestrates local runs, approvals, and checkpoints without moving execution logic into the Uno app."; + private const string TrafficPolicyName = "Traffic policy"; + private const string TrafficPolicySummary = + "Allowed grain transitions are explicit, testable, and surfaced through the embedded traffic-policy Mermaid catalog instead of hidden conventions."; + private const string SessionPersistenceName = "Session persistence"; + private const string SessionPersistenceSummary = + "Checkpoint, replay, and resume data survive host restarts in local session archives without changing the Orleans storage topology."; private readonly IReadOnlyList _providers; public RuntimeFoundationCatalog() => _providers = Array.AsReadOnly(CreateProviders()); @@ -69,6 +76,18 @@ private static IReadOnlyList CreateSlices() OrchestrationName, OrchestrationSummary, RuntimeSliceState.Sequenced), + new( + RuntimeFoundationIssues.GrainTrafficPolicy, + RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.GrainTrafficPolicy), + TrafficPolicyName, + TrafficPolicySummary, + RuntimeSliceState.Sequenced), + new( + RuntimeFoundationIssues.SessionPersistence, + RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.SessionPersistence), + SessionPersistenceName, + SessionPersistenceSummary, + RuntimeSliceState.Sequenced), ]; } diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs new file mode 100644 index 0000000..67d39ad --- /dev/null +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using DotPilot.Core.Features.RuntimeFoundation; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Runtime.Features.RuntimeFoundation; + +public static class RuntimeFoundationServiceCollectionExtensions +{ + public static IServiceCollection AddDesktopRuntimeFoundation( + this IServiceCollection services, + RuntimePersistenceOptions? persistenceOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(persistenceOptions ?? new RuntimePersistenceOptions()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IServiceCollection AddBrowserRuntimeFoundation(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs new file mode 100644 index 0000000..463cdca --- /dev/null +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs @@ -0,0 +1,19 @@ +namespace DotPilot.Runtime.Features.RuntimeFoundation; + +public sealed class RuntimePersistenceOptions +{ + private const string DotPilotDirectoryName = "dotPilot"; + private const string RuntimeDirectoryName = "runtime"; + private const string SessionsDirectoryName = "sessions"; + + public string RootDirectoryPath { get; init; } = CreateDefaultRootDirectoryPath(); + + public static string CreateDefaultRootDirectoryPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + DotPilotDirectoryName, + RuntimeDirectoryName, + SessionsDirectoryName); + } +} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs new file mode 100644 index 0000000..6cf6e19 --- /dev/null +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs @@ -0,0 +1,123 @@ +using System.Text; +using System.Text.Json; +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Features.RuntimeFoundation; + +public sealed class RuntimeSessionArchiveStore(RuntimePersistenceOptions options) +{ + private const string ArchiveFileName = "archive.json"; + private const string ReplayFileName = "replay.md"; + private const string CheckpointsDirectoryName = "checkpoints"; + private const string ReplayBulletPrefix = "- "; + private const string ReplaySeparator = " | "; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }; + + internal async ValueTask LoadAsync(SessionId sessionId, CancellationToken cancellationToken) + { + var archivePath = GetArchivePath(sessionId); + if (!File.Exists(archivePath)) + { + return null; + } + + await using var stream = File.OpenRead(archivePath); + return await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken); + } + + internal async ValueTask SaveAsync(StoredRuntimeSessionArchive archive, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(archive); + + var sessionDirectory = GetSessionDirectory(archive.SessionId); + Directory.CreateDirectory(sessionDirectory); + var archivePath = GetArchivePath(archive.SessionId); + await using (var stream = File.Create(archivePath)) + { + await JsonSerializer.SerializeAsync(stream, archive, SerializerOptions, cancellationToken); + } + + await File.WriteAllTextAsync( + GetReplayPath(archive.SessionId), + BuildReplayMarkdown(archive.Replay), + Encoding.UTF8, + cancellationToken); + } + + internal DirectoryInfo CreateCheckpointDirectory(SessionId sessionId) + { + var checkpointDirectory = GetCheckpointDirectoryPath(sessionId); + Directory.CreateDirectory(checkpointDirectory); + return new DirectoryInfo(checkpointDirectory); + } + + internal static RuntimeSessionArchive ToSnapshot(StoredRuntimeSessionArchive archive) + { + ArgumentNullException.ThrowIfNull(archive); + + return new RuntimeSessionArchive( + archive.SessionId, + archive.WorkflowSessionId, + archive.Phase, + archive.ApprovalState, + archive.UpdatedAt, + archive.CheckpointId, + archive.Replay, + archive.Artifacts); + } + + private string GetSessionDirectory(SessionId sessionId) + { + return Path.Combine(options.RootDirectoryPath, sessionId.ToString()); + } + + private string GetArchivePath(SessionId sessionId) + { + return Path.Combine(GetSessionDirectory(sessionId), ArchiveFileName); + } + + private string GetReplayPath(SessionId sessionId) + { + return Path.Combine(GetSessionDirectory(sessionId), ReplayFileName); + } + + private string GetCheckpointDirectoryPath(SessionId sessionId) + { + return Path.Combine(GetSessionDirectory(sessionId), CheckpointsDirectoryName); + } + + private static string BuildReplayMarkdown(IReadOnlyList replayEntries) + { + var builder = new StringBuilder(); + foreach (var entry in replayEntries) + { + _ = builder.Append(ReplayBulletPrefix) + .Append(entry.RecordedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)) + .Append(ReplaySeparator) + .Append(entry.Kind) + .Append(ReplaySeparator) + .Append(entry.Phase) + .Append(ReplaySeparator) + .Append(entry.ApprovalState) + .Append(ReplaySeparator) + .AppendLine(entry.Summary); + } + + return builder.ToString(); + } +} + +internal sealed record StoredRuntimeSessionArchive( + SessionId SessionId, + string WorkflowSessionId, + string? CheckpointId, + AgentTurnRequest OriginalRequest, + SessionPhase Phase, + ApprovalState ApprovalState, + DateTimeOffset UpdatedAt, + IReadOnlyList Replay, + IReadOnlyList Artifacts); diff --git a/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs new file mode 100644 index 0000000..8436f49 --- /dev/null +++ b/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs @@ -0,0 +1,371 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.RuntimeFoundation; + +public sealed class AgentFrameworkRuntimeClientTests +{ + private const string ApprovalPrompt = "Execute the runtime flow and stop for approval before any file change."; + private const string PlanPrompt = "Plan the embedded runtime rollout."; + private const string ApprovedResumeSummary = "Approved by the operator."; + private const string RejectedResumeSummary = "Rejected by the operator."; + private const string ResumeRejectedKind = "approval-rejected"; + private const string ArchiveFileName = "archive.json"; + private const string ReplayFileName = "replay.md"; + private static readonly DateTimeOffset FixedTimestamp = new(2026, 3, 14, 9, 30, 0, TimeSpan.Zero); + + [Test] + public async Task ExecuteAsyncPersistsAReplayArchiveForPlanMode() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + using var host = CreateHost(runtimeDirectory.Root); + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + var request = CreateRequest(PlanPrompt, AgentExecutionMode.Plan); + + var result = await client.ExecuteAsync(request, CancellationToken.None); + var archiveResult = await client.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value!.NextPhase.Should().Be(SessionPhase.Plan); + archiveResult.IsSuccess.Should().BeTrue(); + archiveResult.Value!.Phase.Should().Be(SessionPhase.Plan); + archiveResult.Value.Replay.Should().ContainSingle(entry => entry.Kind == "run-started"); + File.Exists(Path.Combine(runtimeDirectory.Root, request.SessionId.ToString(), ArchiveFileName)).Should().BeTrue(); + File.Exists(Path.Combine(runtimeDirectory.Root, request.SessionId.ToString(), ReplayFileName)).Should().BeTrue(); + } + + [Test] + public async Task ExecuteAsyncPausesForApprovalAndResumeAsyncCompletesAfterHostRestart() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var request = CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute); + + { + using var firstHost = CreateHost(runtimeDirectory.Root); + + await firstHost.StartAsync(); + var firstClient = firstHost.Services.GetRequiredService(); + + var pausedResult = await firstClient.ExecuteAsync(request, CancellationToken.None); + + pausedResult.IsSuccess.Should().BeTrue(); + pausedResult.Value!.NextPhase.Should().Be(SessionPhase.Paused); + pausedResult.Value.ApprovalState.Should().Be(ApprovalState.Pending); + } + + { + using var secondHost = CreateHost(runtimeDirectory.Root); + + await secondHost.StartAsync(); + var secondClient = secondHost.Services.GetRequiredService(); + + var archiveBeforeResume = await secondClient.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); + var resumedResult = await secondClient.ResumeAsync( + new AgentTurnResumeRequest(request.SessionId, ApprovalState.Approved, ApprovedResumeSummary), + CancellationToken.None); + var archiveAfterResume = await secondClient.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); + var grainFactory = secondHost.Services.GetRequiredService(); + + archiveBeforeResume.IsSuccess.Should().BeTrue(); + archiveBeforeResume.Value!.CheckpointId.Should().NotBeNullOrWhiteSpace(); + resumedResult.IsSuccess.Should().BeTrue(); + resumedResult.Value!.NextPhase.Should().Be(SessionPhase.Execute); + resumedResult.Value.ApprovalState.Should().Be(ApprovalState.Approved); + archiveAfterResume.IsSuccess.Should().BeTrue(); + archiveAfterResume.Value!.Replay.Select(entry => entry.Kind).Should().Contain(["approval-pending", "run-resumed", "run-completed"]); + (await grainFactory.GetGrain(request.SessionId.ToString()).GetAsync())!.Phase.Should().Be(SessionPhase.Execute); + } + } + + [Test] + public async Task ResumeAsyncPersistsRejectedApprovalAsFailedReplay() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + using var host = CreateHost(runtimeDirectory.Root); + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + var request = CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute); + + _ = await client.ExecuteAsync(request, CancellationToken.None); + var rejectedResult = await client.ResumeAsync( + new AgentTurnResumeRequest(request.SessionId, ApprovalState.Rejected, RejectedResumeSummary), + CancellationToken.None); + var archiveResult = await client.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); + + rejectedResult.IsSuccess.Should().BeTrue(); + rejectedResult.Value!.NextPhase.Should().Be(SessionPhase.Failed); + rejectedResult.Value.ApprovalState.Should().Be(ApprovalState.Rejected); + archiveResult.IsSuccess.Should().BeTrue(); + archiveResult.Value!.Phase.Should().Be(SessionPhase.Failed); + archiveResult.Value.Replay.Should().Contain(entry => entry.Kind == ResumeRejectedKind && entry.Phase == SessionPhase.Failed); + archiveResult.Value.Replay.Should().Contain(entry => entry.Kind == "run-completed" && entry.Phase == SessionPhase.Failed); + } + + [Test] + public async Task ResumeAsyncRejectsArchivedSessionsThatAreNoLongerPausedForApproval() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var request = CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute); + + { + using var firstHost = CreateHost(runtimeDirectory.Root); + await firstHost.StartAsync(); + var firstClient = firstHost.Services.GetRequiredService(); + _ = await firstClient.ExecuteAsync(request, CancellationToken.None); + _ = await firstClient.ResumeAsync( + new AgentTurnResumeRequest(request.SessionId, ApprovalState.Approved, ApprovedResumeSummary), + CancellationToken.None); + } + + { + using var secondHost = CreateHost(runtimeDirectory.Root); + await secondHost.StartAsync(); + var secondClient = secondHost.Services.GetRequiredService(); + + var result = await secondClient.ResumeAsync( + new AgentTurnResumeRequest(request.SessionId, ApprovalState.Approved, ApprovedResumeSummary), + CancellationToken.None); + + result.IsFailed.Should().BeTrue(); + result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.ResumeCheckpointMissing).Should().BeTrue(); + result.Problem.Detail.Should().Contain("cannot be resumed"); + } + } + + [Test] + public async Task GetSessionArchiveAsyncReturnsMissingProblemWhenNothingWasPersisted() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + using var host = CreateHost(runtimeDirectory.Root); + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + var missingSessionId = SessionId.New(); + + var result = await client.GetSessionArchiveAsync(missingSessionId, CancellationToken.None); + + result.IsFailed.Should().BeTrue(); + result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveMissing).Should().BeTrue(); + } + + [Test] + public async Task GetSessionArchiveAsyncReturnsCorruptionProblemForInvalidArchivePayload() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var sessionId = SessionId.New(); + var sessionDirectory = Path.Combine(runtimeDirectory.Root, sessionId.ToString()); + Directory.CreateDirectory(sessionDirectory); + await File.WriteAllTextAsync(Path.Combine(sessionDirectory, ArchiveFileName), "{ invalid json", CancellationToken.None); + + using var host = CreateHost(runtimeDirectory.Root); + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + + var result = await client.GetSessionArchiveAsync(sessionId, CancellationToken.None); + + result.IsFailed.Should().BeTrue(); + result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveCorrupted).Should().BeTrue(); + } + + [Test] + public async Task ResumeAsyncReturnsCorruptionProblemForInvalidArchivePayload() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var sessionId = SessionId.New(); + var sessionDirectory = Path.Combine(runtimeDirectory.Root, sessionId.ToString()); + Directory.CreateDirectory(sessionDirectory); + await File.WriteAllTextAsync(Path.Combine(sessionDirectory, ArchiveFileName), "{ invalid json", CancellationToken.None); + + using var host = CreateHost(runtimeDirectory.Root); + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + + var result = await client.ResumeAsync( + new AgentTurnResumeRequest(sessionId, ApprovalState.Approved, ApprovedResumeSummary), + CancellationToken.None); + + result.IsFailed.Should().BeTrue(); + result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveCorrupted).Should().BeTrue(); + } + + [Test] + public async Task AgentFrameworkRuntimeClientUsesTheInjectedTimeProviderForReplayArchiveAndSessionTimestamps() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + using var host = CreateHost(runtimeDirectory.Root); + await host.StartAsync(); + var client = CreateClient(host.Services, runtimeDirectory.Root, new FixedTimeProvider(FixedTimestamp)); + var request = CreateRequest(PlanPrompt, AgentExecutionMode.Plan); + + var result = await client.ExecuteAsync(request, CancellationToken.None); + var archiveResult = await client.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); + var session = await host.Services + .GetRequiredService() + .GetGrain(request.SessionId.ToString()) + .GetAsync(); + + result.IsSuccess.Should().BeTrue(); + archiveResult.IsSuccess.Should().BeTrue(); + archiveResult.Value!.UpdatedAt.Should().Be(FixedTimestamp); + archiveResult.Value.Replay.Should().OnlyContain(entry => entry.RecordedAt == FixedTimestamp); + session.Should().NotBeNull(); + session!.CreatedAt.Should().Be(FixedTimestamp); + session.UpdatedAt.Should().Be(FixedTimestamp); + } + + [Test] + public async Task ExtractCheckpointReturnsNullWhenRunHasNoCheckpointData() + { + var workflow = CreateNoCheckpointWorkflow(); + + await using var run = await Microsoft.Agents.AI.Workflows.InProcessExecution.RunAsync( + workflow, + "no-checkpoint-input", + SessionId.New().ToString(), + CancellationToken.None); + + var checkpoint = InvokePrivateStatic("ExtractCheckpoint", run); + + checkpoint.Should().BeNull(); + } + + [Test] + public void TryCreateCheckpointInfoReturnsNullWhenTheCheckpointFilePrefixDoesNotMatchTheWorkflowSession() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var file = CreateCheckpointFile(runtimeDirectory.Root, "different-session_checkpoint-001.json"); + + var checkpoint = InvokePrivateStatic("TryCreateCheckpointInfo", "expected-session", file); + + checkpoint.Should().BeNull(); + } + + [Test] + public void TryCreateCheckpointInfoReturnsNullWhenTheCheckpointFileHasNoIdentifierSuffix() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var file = CreateCheckpointFile(runtimeDirectory.Root, "expected-session_.json"); + + var checkpoint = InvokePrivateStatic("TryCreateCheckpointInfo", "expected-session", file); + + checkpoint.Should().BeNull(); + } + + [Test] + public void TryCreateCheckpointInfoReturnsCheckpointMetadataForMatchingCheckpointFileNames() + { + using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); + var file = CreateCheckpointFile(runtimeDirectory.Root, "expected-session_checkpoint-001.json"); + + var checkpoint = InvokePrivateStatic("TryCreateCheckpointInfo", "expected-session", file); + + checkpoint.Should().NotBeNull(); + checkpoint!.SessionId.Should().Be("expected-session"); + checkpoint.CheckpointId.Should().Be("checkpoint-001"); + } + + private static Microsoft.Extensions.Hosting.IHost CreateHost(string rootDirectory) + { + var options = CreateHostOptions(); + return Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .UseDotPilotEmbeddedRuntime(options) + .ConfigureServices((_, services) => services.AddDesktopRuntimeFoundation(new RuntimePersistenceOptions + { + RootDirectoryPath = rootDirectory, + })) + .Build(); + } + + private static EmbeddedRuntimeHostOptions CreateHostOptions() + { + return new EmbeddedRuntimeHostOptions + { + ClusterId = $"dotpilot-runtime-{Guid.NewGuid():N}", + ServiceId = $"dotpilot-runtime-service-{Guid.NewGuid():N}", + SiloPort = GetFreeTcpPort(), + GatewayPort = GetFreeTcpPort(), + }; + } + + private static AgentTurnRequest CreateRequest(string prompt, AgentExecutionMode mode) + { + return new AgentTurnRequest(SessionId.New(), AgentProfileId.New(), prompt, mode, ProviderConnectionStatus.Available); + } + + private static AgentFrameworkRuntimeClient CreateClient(IServiceProvider services, string rootDirectory, TimeProvider timeProvider) + { + return (AgentFrameworkRuntimeClient)Activator.CreateInstance( + typeof(AgentFrameworkRuntimeClient), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: + [ + services.GetRequiredService(), + new RuntimeSessionArchiveStore(new RuntimePersistenceOptions + { + RootDirectoryPath = rootDirectory, + }), + timeProvider, + ], + culture: null)!; + } + + private static Microsoft.Agents.AI.Workflows.Workflow CreateNoCheckpointWorkflow() + { + var executor = new Microsoft.Agents.AI.Workflows.FunctionExecutor( + "no-checkpoint-executor", + static async (input, context, cancellationToken) => + { + ArgumentException.ThrowIfNullOrWhiteSpace(input); + cancellationToken.ThrowIfCancellationRequested(); + await context.RequestHaltAsync(); + }, + declareCrossRunShareable: true); + return new Microsoft.Agents.AI.Workflows.WorkflowBuilder(executor).Build(); + } + + private static FileInfo CreateCheckpointFile(string rootDirectory, string fileName) + { + var filePath = Path.Combine(rootDirectory, fileName); + File.WriteAllText(filePath, "{}"); + return new FileInfo(filePath); + } + + private static T? InvokePrivateStatic(string methodName, params object[] arguments) + { + var method = typeof(AgentFrameworkRuntimeClient).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + return (T?)method!.Invoke(null, arguments); + } + + private static int GetFreeTcpPort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } +} + +internal sealed class FixedTimeProvider(DateTimeOffset timestamp) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => timestamp; +} + +internal sealed class TemporaryRuntimePersistenceDirectory : IDisposable +{ + public TemporaryRuntimePersistenceDirectory() + { + Root = Path.Combine(Path.GetTempPath(), "dotpilot-runtime-tests", Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(Root); + } + + public string Root { get; } + + public void Dispose() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } +} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs new file mode 100644 index 0000000..45b6f45 --- /dev/null +++ b/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace DotPilot.Tests.Features.RuntimeFoundation; + +public sealed class EmbeddedRuntimeTrafficPolicyCatalogTests +{ + [Test] + public void TrafficPolicyCatalogExposesExplicitTransitionsAndMermaidDiagram() + { + using var host = CreateHost(); + + var snapshot = host.Services.GetRequiredService().GetSnapshot(); + + snapshot.IssueNumber.Should().Be(RuntimeFoundationIssues.GrainTrafficPolicy); + snapshot.AllowedTransitions.Should().Contain(transition => + transition.Source == "Session" && + transition.Target == "Artifact" && + transition.SourceMethods.Contains(nameof(ISessionGrain.UpsertAsync)) && + transition.TargetMethods.Contains(nameof(IArtifactGrain.UpsertAsync))); + snapshot.MermaidDiagram.Should().Contain("flowchart LR"); + snapshot.MermaidDiagram.Should().Contain("Session --> Artifact"); + } + + [Test] + public void TrafficPolicyCatalogAllowsConfiguredTransitionsAndRejectsUnsupportedHops() + { + using var host = CreateHost(); + var catalog = host.Services.GetRequiredService(); + + var allowedDecision = catalog.Evaluate( + new EmbeddedRuntimeTrafficProbe( + typeof(ISessionGrain), + nameof(ISessionGrain.UpsertAsync), + typeof(IArtifactGrain), + nameof(IArtifactGrain.UpsertAsync))); + var deniedDecision = catalog.Evaluate( + new EmbeddedRuntimeTrafficProbe( + typeof(IPolicyGrain), + nameof(IPolicyGrain.UpsertAsync), + typeof(ISessionGrain), + nameof(ISessionGrain.GetAsync))); + + allowedDecision.IsAllowed.Should().BeTrue(); + allowedDecision.MermaidDiagram.Should().Contain("Session ==> Artifact"); + deniedDecision.IsAllowed.Should().BeFalse(); + deniedDecision.MermaidDiagram.Should().Contain("Policy"); + } + + private static IHost CreateHost() + { + return Host.CreateDefaultBuilder() + .UseDotPilotEmbeddedRuntime(new EmbeddedRuntimeHostOptions + { + ClusterId = $"dotpilot-traffic-{Guid.NewGuid():N}", + ServiceId = $"dotpilot-traffic-service-{Guid.NewGuid():N}", + SiloPort = GetFreeTcpPort(), + GatewayPort = GetFreeTcpPort(), + }) + .Build(); + } + + private static int GetFreeTcpPort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs index c2ff457..891c8ea 100644 --- a/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs +++ b/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs @@ -9,20 +9,33 @@ public class RuntimeFoundationCatalogTests private static readonly DateTimeOffset DeterministicArtifactCreatedAt = new(2026, 3, 13, 0, 0, 0, TimeSpan.Zero); [Test] - public void CatalogGroupsEpicTwelveIntoFourSequencedSlices() + public void CatalogGroupsEpicTwelveIntoSixSequencedSlices() { var catalog = CreateCatalog(); var snapshot = catalog.GetSnapshot(); snapshot.EpicLabel.Should().Be(RuntimeEpicLabel); - snapshot.Slices.Should().HaveCount(4); - snapshot.Slices.Select(slice => slice.IssueLabel).Should().ContainInOrder("DOMAIN", "CONTRACTS", "HOST", "ORCHESTRATION"); + snapshot.Slices.Should().HaveCount(6); + snapshot.Slices.Select(slice => slice.IssueLabel).Should().ContainInOrder( + "DOMAIN", + "CONTRACTS", + "HOST", + "ORCHESTRATION", + RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.GrainTrafficPolicy), + RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.SessionPersistence)); snapshot.Slices.Select(slice => slice.IssueNumber).Should().ContainInOrder( RuntimeFoundationIssues.DomainModel, RuntimeFoundationIssues.CommunicationContracts, RuntimeFoundationIssues.EmbeddedOrleansHost, - RuntimeFoundationIssues.AgentFrameworkRuntime); + RuntimeFoundationIssues.AgentFrameworkRuntime, + RuntimeFoundationIssues.GrainTrafficPolicy, + RuntimeFoundationIssues.SessionPersistence); + snapshot.Slices.Single(slice => slice.IssueNumber == RuntimeFoundationIssues.GrainTrafficPolicy) + .Summary + .Should() + .Contain("Mermaid") + .And.NotContain("Orleans.Graph"); } [Test] @@ -168,6 +181,31 @@ public async Task DeterministicClientReturnsProviderUnavailableProblemWhenProvid problem.Detail.Should().Contain(snapshot.DeterministicClientName); } + [Test] + public async Task DeterministicClientReturnsOrchestrationUnavailableForResume() + { + var client = new DeterministicAgentRuntimeClient(); + + var result = await client.ResumeAsync( + new AgentTurnResumeRequest(SessionId.New(), ApprovalState.Approved, "Approved."), + CancellationToken.None); + + result.IsFailed.Should().BeTrue(); + result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.OrchestrationUnavailable).Should().BeTrue(); + } + + [Test] + public async Task DeterministicClientReturnsMissingArchiveProblemForArchiveQueries() + { + var client = new DeterministicAgentRuntimeClient(); + var sessionId = SessionId.New(); + + var result = await client.GetSessionArchiveAsync(sessionId, CancellationToken.None); + + result.IsFailed.Should().BeTrue(); + result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveMissing).Should().BeTrue(); + } + [Test] public void DeterministicClientRejectsUnexpectedExecutionModes() { diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index f695ecf..bf621be 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using DotPilot.Runtime.Features.RuntimeFoundation; #if !__WASM__ using DotPilot.Runtime.Host.Features.RuntimeFoundation; #endif @@ -110,14 +111,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) .AddSingleton< DotPilot.Core.Features.Workbench.IWorkbenchCatalog, DotPilot.Runtime.Features.Workbench.WorkbenchCatalog>(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddSingleton< - DotPilot.Core.Features.RuntimeFoundation.IAgentRuntimeClient, - DotPilot.Runtime.Features.RuntimeFoundation.DeterministicAgentRuntimeClient>(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddSingleton< - DotPilot.Core.Features.RuntimeFoundation.IRuntimeFoundationCatalog, - DotPilot.Runtime.Features.RuntimeFoundation.RuntimeFoundationCatalog>(services); +#if !__WASM__ + services.AddDesktopRuntimeFoundation(); +#else + services.AddBrowserRuntimeFoundation(); +#endif Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions .AddSingleton< DotPilot.Core.Features.ToolchainCenter.IToolchainCenterCatalog, diff --git a/docs/Architecture.md b/docs/Architecture.md index 4928b8f..6da7703 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,6 +1,6 @@ # Architecture Overview -Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, the workbench foundation for epic `#13`, the Toolchain Center for epic `#14`, and the vertical-slice runtime foundation that starts epic `#12`. +Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, the workbench foundation for epic `#13`, the Toolchain Center for epic `#14`, and the local-first runtime foundation for epic `#12`. This file is the required start-here architecture map for non-trivial tasks. @@ -10,16 +10,16 @@ This file is the required start-here architecture map for non-trivial tasks. - **Presentation boundary:** [../DotPilot/](../DotPilot/) is now the presentation host only. It owns XAML, routing, desktop startup, and UI composition, while non-UI feature logic moves into separate DLLs. - **Workbench boundary:** epic [#13](https://github.com/managedcode/dotPilot/issues/13) is landing as a `Workbench` slice that will provide repository navigation, file inspection, artifact and log inspection, and a unified settings shell without moving that behavior into page code-behind. - **Toolchain Center boundary:** epic [#14](https://github.com/managedcode/dotPilot/issues/14) now lives as a `ToolchainCenter` slice. [../DotPilot.Core/Features/ToolchainCenter](../DotPilot.Core/Features/ToolchainCenter) defines the readiness, diagnostics, configuration, action, and polling contracts; [../DotPilot.Runtime/Features/ToolchainCenter](../DotPilot.Runtime/Features/ToolchainCenter) probes local provider CLIs for `Codex`, `Claude Code`, and `GitHub Copilot`; the Uno app surfaces the slice through the settings shell. -- **Runtime foundation boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns issue-aligned contracts, typed identifiers, grain interfaces, and public slice interfaces; [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider-independent runtime implementations such as the deterministic test client and toolchain probing; [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) owns the embedded Orleans host and initial grain implementations for desktop targets. +- **Runtime foundation boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns issue-aligned contracts, typed identifiers, grain interfaces, traffic-policy snapshots, and session-archive contracts; [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider-independent runtime implementations such as the deterministic turn engine, `Microsoft Agent Framework` orchestration client, and local archive persistence; [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) owns the embedded Orleans host, explicit grain traffic policy, and initial grain implementations for desktop targets. - **Domain slice boundary:** issue [#22](https://github.com/managedcode/dotPilot/issues/22) now lives in `DotPilot.Core/Features/ControlPlaneDomain`, which defines the shared agent, session, fleet, provider, runtime, approval, artifact, telemetry, and evaluation model that later slices reuse. - **Communication slice boundary:** issue [#23](https://github.com/managedcode/dotPilot/issues/23) lives in `DotPilot.Core/Features/RuntimeCommunication`, which defines the shared `ManagedCode.Communication` result/problem language for runtime public boundaries. -- **First implementation slice:** epic [#12](https://github.com/managedcode/dotPilot/issues/12) is represented locally through the `RuntimeFoundation` slice, which sequences issues `#22`, `#23`, `#24`, and `#25` behind a stable contract surface instead of mixing runtime work into the Uno app. +- **First implementation slice:** epic [#12](https://github.com/managedcode/dotPilot/issues/12) is represented locally through the `RuntimeFoundation` slice, which now sequences issues `#22`, `#23`, `#24`, `#25`, `#26`, and `#27` behind a stable contract surface instead of mixing runtime work into the Uno app. - **Automated verification:** [../DotPilot.Tests/](../DotPilot.Tests/) covers API-style and contract flows through the new DLL boundaries; [../DotPilot.UITests/](../DotPilot.UITests/) covers the visible workbench flow, Toolchain Center, and runtime-foundation UI surface. Provider-independent flows must pass in CI through deterministic or environment-agnostic checks, while provider-specific checks can run only when the matching toolchain is available. ## Scoping - **In scope for the current repository state:** the Uno workbench shell, the `DotPilot.Core`, `DotPilot.Runtime`, and `DotPilot.Runtime.Host` libraries, the embedded Orleans host for local desktop runtime state, and the automated validation boundaries around them. -- **In scope for future implementation:** `Microsoft Agent Framework`, provider adapters, durable persistence beyond in-memory runtime state, telemetry, evaluation, Git tooling, and local runtimes. +- **In scope for future implementation:** provider adapters, durable persistence beyond the current local session archive, telemetry, evaluation, Git tooling, and local runtimes. - **Out of scope in the current slice:** remote workers, remote clustering, external durable storage providers, and cloud-only control-plane services. ## Diagrams @@ -135,6 +135,8 @@ flowchart TD Comm["#23 Communication contracts"] Host["#24 Embedded Orleans host"] MAF["#25 Agent Framework runtime"] + Policy["#26 Grain traffic policy"] + Sessions["#27 Session persistence and resume"] DomainSlice["DotPilot.Core/Features/ControlPlaneDomain"] CommunicationSlice["DotPilot.Core/Features/RuntimeCommunication"] CoreSlice["DotPilot.Core/Features/RuntimeFoundation"] @@ -146,13 +148,19 @@ flowchart TD Epic --> Comm Epic --> Host Epic --> MAF + Epic --> Policy + Epic --> Sessions Domain --> DomainSlice DomainSlice --> CommunicationSlice CommunicationSlice --> CoreSlice Comm --> CommunicationSlice Host --> HostSlice + Policy --> HostSlice + Policy --> CoreSlice HostSlice --> CoreSlice MAF --> RuntimeSlice + Sessions --> RuntimeSlice + Sessions --> CoreSlice RuntimeSlice --> HostSlice CoreSlice --> UiSlice HostSlice --> UiSlice @@ -168,24 +176,31 @@ flowchart LR ViewModels["MainViewModel + SecondViewModel + SettingsViewModel"] Catalog["RuntimeFoundationCatalog"] Toolchains["ToolchainCenterCatalog"] - TestClient["DeterministicAgentRuntimeClient"] + BrowserClient["DeterministicAgentRuntimeClient"] + DesktopClient["AgentFrameworkRuntimeClient"] + Archive["RuntimeSessionArchiveStore"] + Traffic["EmbeddedRuntimeTrafficPolicyCatalog"] ToolchainProbe["ToolchainCommandProbe + provider profiles"] EmbeddedHost["UseDotPilotEmbeddedRuntime + Orleans silo"] Contracts["Typed IDs + contracts"] - Future["Future Orleans + Agent Framework integrations"] + Grains["Session / Workspace / Fleet / Policy / Artifact grains"] App --> ViewModels Views --> ViewModels ViewModels --> Catalog ViewModels --> Toolchains - Catalog --> TestClient + Catalog --> BrowserClient + Catalog --> DesktopClient Catalog --> Contracts Toolchains --> ToolchainProbe Toolchains --> Contracts App --> EmbeddedHost + DesktopClient --> Archive + DesktopClient --> EmbeddedHost + EmbeddedHost --> Traffic + EmbeddedHost --> Grains EmbeddedHost --> Contracts - Future --> Contracts - Future --> EmbeddedHost + Traffic --> Contracts ``` ## Navigation Index @@ -193,6 +208,7 @@ flowchart LR ### Planning and decision docs - `Solution governance` — [../AGENTS.md](../AGENTS.md) +- `Task plan` — [../epic-12-embedded-runtime.plan.md](../epic-12-embedded-runtime.plan.md) - `Primary architecture decision` — [ADR-0001](./ADR/ADR-0001-agent-control-plane-architecture.md) - `Vertical-slice solution decision` — [ADR-0003](./ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - `Feature spec` — [Agent Control Plane Experience](./Features/agent-control-plane-experience.md) @@ -201,6 +217,7 @@ flowchart LR - `Issue #22 feature doc` — [Control Plane Domain Model](./Features/control-plane-domain-model.md) - `Issue #23 feature doc` — [Runtime Communication Contracts](./Features/runtime-communication-contracts.md) - `Issue #24 feature doc` — [Embedded Orleans Host](./Features/embedded-orleans-host.md) +- `Issues #25-#27 feature doc` — [Embedded Runtime Orchestration](./Features/embedded-runtime-orchestration.md) ### Modules @@ -225,6 +242,8 @@ flowchart LR - `Shell configuration contract` — [../DotPilot.Core/Features/ApplicationShell/AppConfig.cs](../DotPilot.Core/Features/ApplicationShell/AppConfig.cs) - `Runtime foundation contracts` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs) - `Embedded runtime host contracts` — [../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs) +- `Traffic policy contracts` — [../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs) +- `Session archive contracts` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs) - `Runtime communication problems` — [../DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs](../DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs) - `Control-plane domain contracts` — [../DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs](../DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs) - `Provider and tool contracts` — [../DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs](../DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs) @@ -233,8 +252,13 @@ flowchart LR - `Toolchain snapshot factory` — [../DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs](../DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs) - `Runtime catalog implementation` — [../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs](../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs) - `Deterministic test client` — [../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs](../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs) +- `Agent Framework client` — [../DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs](../DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs) +- `Deterministic turn engine` — [../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs](../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs) +- `Session archive store` — [../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs](../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs) - `Provider toolchain probing` — [../DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs](../DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs) - `Embedded host builder` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs) +- `Embedded traffic policy` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs) +- `Embedded traffic-policy catalog` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs) - `Initial Orleans grains` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs) ## Dependency Rules @@ -251,6 +275,8 @@ flowchart LR - The Uno app must remain a presentation-only host instead of becoming a dump for runtime logic. - Feature work should land as vertical slices with isolated contracts and implementations, not as shared horizontal folders. - Epic `#12` now has a first local-first Orleans host cut in `DotPilot.Runtime.Host`, and it intentionally uses localhost clustering plus in-memory storage/reminders before any remote or durable runtime topology is introduced. +- The desktop runtime path now uses `Microsoft Agent Framework` for orchestration, while the browser path keeps the deterministic in-repo client for CI-safe coverage. +- `#26` currently uses an explicit traffic-policy catalog plus Mermaid graph output instead of `ManagedCode.Orleans.Graph`, because the public `ManagedCode.Orleans.Graph` package is pinned to Orleans `9.x` and is not compatible with this repository's Orleans `10.0.1` baseline. - Epic `#14` makes external-provider toolchain readiness explicit before session creation, so install, auth, diagnostics, and configuration state stays visible instead of being inferred later. - CI must stay meaningful without external provider CLIs by using the in-repo deterministic runtime client. - Real provider checks may run only when the corresponding toolchain is present and discoverable. diff --git a/docs/Features/embedded-orleans-host.md b/docs/Features/embedded-orleans-host.md index 5be101e..76ae500 100644 --- a/docs/Features/embedded-orleans-host.md +++ b/docs/Features/embedded-orleans-host.md @@ -18,7 +18,7 @@ Issue [#24](https://github.com/managedcode/dotPilot/issues/24) embeds the first - remote clusters - external durable storage providers -- Agent Framework orchestration +- Agent Framework orchestration and session-archive flows beyond the host boundary - UI redesign around the runtime host ## Flow @@ -47,6 +47,7 @@ flowchart LR - Orleans host configuration - host lifecycle catalog state - grain implementations +- Agent Framework orchestration, replay archives, and resume logic live in the sibling runtime slice document: [Embedded Runtime Orchestration](./embedded-runtime-orchestration.md). - The initial cluster configuration is intentionally local: - `UseLocalhostClustering` - named in-memory grain storage @@ -63,6 +64,7 @@ flowchart LR ## References - [Architecture Overview](../Architecture.md) +- [Embedded Runtime Orchestration](./embedded-runtime-orchestration.md) - [ADR-0003: Keep the Uno App Presentation-Only and Move Feature Work into Vertical-Slice Class Libraries](../ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - [Local development configuration](https://learn.microsoft.com/dotnet/orleans/host/configuration-guide/local-development-configuration) - [Quickstart: Build your first Orleans app with ASP.NET Core](https://learn.microsoft.com/dotnet/orleans/quickstarts/build-your-first-orleans-app) diff --git a/docs/Features/embedded-runtime-orchestration.md b/docs/Features/embedded-runtime-orchestration.md new file mode 100644 index 0000000..a2976ae --- /dev/null +++ b/docs/Features/embedded-runtime-orchestration.md @@ -0,0 +1,99 @@ +# Embedded Runtime Orchestration + +## Summary + +Issues [#25](https://github.com/managedcode/dotPilot/issues/25), [#26](https://github.com/managedcode/dotPilot/issues/26), and [#27](https://github.com/managedcode/dotPilot/issues/27) land as one local-first runtime slice on top of the embedded Orleans desktop host. `DotPilot.Runtime` owns the orchestration client, session archive store, and deterministic turn engine; `DotPilot.Runtime.Host` owns the Orleans grains and the explicit traffic-policy catalog. + +## Scope + +### In Scope + +- `Microsoft Agent Framework` as the preferred local orchestration engine for desktop runtime turns +- explicit traffic-policy visibility for session, workspace, fleet, policy, and artifact grains +- local-first session archive persistence with replay markdown, checkpoint files, and restart-safe resume +- deterministic execution and approval-gated flows that stay testable in CI without external providers + +### Out Of Scope + +- remote Orleans clustering or durable Orleans storage providers +- provider-specific orchestration adapters +- hiding runtime state inside the Uno app project + +## Flow + +```mermaid +flowchart LR + Ui["DotPilot desktop shell"] + Client["AgentFrameworkRuntimeClient"] + Workflow["Microsoft Agent Framework workflow"] + Engine["DeterministicAgentTurnEngine"] + Archive["RuntimeSessionArchiveStore"] + Checkpoints["Checkpoint files + index"] + Host["Embedded Orleans host"] + Policy["EmbeddedRuntimeTrafficPolicyCatalog"] + Grains["Session / Workspace / Fleet / Policy / Artifact grains"] + + Ui --> Client + Client --> Workflow + Workflow --> Engine + Workflow --> Checkpoints + Client --> Archive + Client --> Host + Host --> Policy + Policy --> Grains + Host --> Grains + Archive --> Checkpoints +``` + +## Session Resume Flow + +```mermaid +sequenceDiagram + participant Operator + participant UI as DotPilot UI + participant Runtime as AgentFrameworkRuntimeClient + participant Store as RuntimeSessionArchiveStore + participant MAF as Agent Framework + participant Host as Orleans grains + + Operator->>UI: Start execution with approval-gated prompt + UI->>Runtime: ExecuteAsync(request) + Runtime->>MAF: RunAsync(start signal) + MAF-->>Runtime: Paused result + checkpoint files + Runtime->>Store: Save archive.json + replay.md + checkpoint id + Runtime->>Host: Upsert session + artifacts + Operator->>UI: Resume after restart + UI->>Runtime: ResumeAsync(resume request) + Runtime->>Store: Load archive + checkpoint id + Runtime->>MAF: ResumeAsync(checkpoint) + MAF-->>Runtime: Final result + Runtime->>Store: Persist updated replay and archive state + Runtime->>Host: Upsert final session + artifacts +``` + +## Design Notes + +- The orchestration boundary stays in `DotPilot.Runtime`, not in the Uno app, so desktop startup remains presentation-only. +- `AgentFrameworkRuntimeClient` uses `Microsoft.Agents.AI.Workflows` for run orchestration, checkpoint storage, and resume semantics. +- `RuntimeSessionArchiveStore` persists three operator-facing artifacts per session: + - `archive.json` + - `replay.md` + - checkpoint files under `checkpoints/` +- The implementation explicitly waits for checkpoint materialization before archiving paused sessions because the workflow run halts before checkpoint files are always observable from `Run.LastCheckpoint`. +- `#26` asked for `ManagedCode.Orleans.Graph`, but the current public package targets Orleans `9.x` while this repository is pinned to Orleans `10.0.1`. The runtime therefore exposes an explicit `EmbeddedRuntimeTrafficPolicyCatalog` plus Mermaid graph output now, while keeping the policy boundary ready for a future package-compatible graph implementation. +- Browser and deterministic paths stay available, so CI can validate the runtime slice without external CLI providers or auth. + +## Verification + +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~RuntimeFoundation` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` +- `dotnet test DotPilot.slnx` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` + +## References + +- [Architecture Overview](../Architecture.md) +- [Embedded Orleans Host](./embedded-orleans-host.md) +- [ADR-0001: Agent Control Plane Architecture](../ADR/ADR-0001-agent-control-plane-architecture.md) +- [ADR-0003: Keep the Uno App Presentation-Only and Move Feature Work into Vertical-Slice Class Libraries](../ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) diff --git a/epic-12-embedded-runtime.plan.md b/epic-12-embedded-runtime.plan.md new file mode 100644 index 0000000..f7c8ff4 --- /dev/null +++ b/epic-12-embedded-runtime.plan.md @@ -0,0 +1,105 @@ +## Goal + +Implement epic `#12` on one delivery branch by covering its direct child issues `#24`, `#25`, `#26`, and `#27` in a single tested runtime slice, while keeping the Uno app presentation-only and keeping the first Orleans host cut on localhost clustering with in-memory Orleans storage/reminders. + +## Scope + +In scope: +- issue `#24`: embedded Orleans silo inside the desktop host with the initial core grains +- issue `#25`: Microsoft Agent Framework integration as the orchestration runtime on top of the embedded host +- issue `#26`: explicit grain traffic policy and visibility, with package-compatible graphing kept honest +- issue `#27`: session persistence, replay, checkpointing, and resume for local-first runtime flows +- runtime-facing contracts, deterministic orchestration seams, docs, and tests needed to prove the full epic behavior + +Out of scope: +- related but non-child issues such as `#50`, `#69`, and `#77` +- provider-specific live adapters beyond the existing deterministic or environment-gated paths +- remote Orleans clustering or external durable storage providers +- replacing the current Uno shell with a different UI model + +## Constraints And Risks + +- The app project must stay presentation-only; runtime hosting, orchestration, graph policy, and persistence logic belong in separate DLLs. +- The first Orleans host cut must use `UseLocalhostClustering`, in-memory grain storage, and in-memory reminders. +- Durable session replay and resume for `#27` must not force a remote or durable Orleans cluster; if needed, it must persist serialized session/checkpoint data outside Orleans storage. +- All added behavior must be covered by automated tests and the full repo validation sequence must stay green. +- Any new dependencies must be the minimum official set needed for the runtime slice and must remain compatible with the pinned SDK and current `LangVersion`. +- `ManagedCode.Orleans.Graph` currently targets Orleans `9.x`; this branch must not lie about graph enforcement if the package cannot coexist with Orleans `10.0.1`. + +## Testing Methodology + +- Cover host lifecycle, grain registration, traffic policy, orchestration execution, session serialization, checkpoint persistence, replay, and resume through real runtime boundaries. +- Keep deterministic in-repo orchestration available for CI so the epic remains testable without external provider CLIs or auth. +- Add regression tests for both happy-path and negative-path flows: + - invalid runtime requests + - traffic-policy violations + - missing or corrupt persisted session state + - restart/resume behavior +- Keep `DotPilot.UITests` in the final pass because browser and app composition must remain green even when runtime hosting expands. +- Require every direct child issue in scope to map to at least one explicit automated test flow. + +## Ordered Plan + +- [x] Confirm the exact direct-child issue set for epic `#12` and keep unrelated issues out of the PR scope. +- [x] Add or restore the embedded Orleans host slice from the cleanest available implementation path for issue `#24`. +- [x] Add the minimum runtime dependencies and contracts for Microsoft Agent Framework orchestration for issue `#25`. +- [x] Implement the first orchestration runtime path on top of the deterministic runtime flow and Orleans-backed runtime boundaries. +- [x] Add explicit grain traffic policy modeling and enforcement for issue `#26`, including runtime-visible policy information and denial behavior. +- [x] Add local-first session persistence, replay, checkpointing, and resume for issue `#27` without changing Orleans clustering/storage topology. +- [x] Update runtime docs, feature docs, ADR references, and architecture diagrams so the epic boundaries and flows are explicit. +- [x] Add or update automated tests for every covered issue: + - host lifecycle and grain registration + - orchestration execution and session serialization + - traffic-policy allow and deny flows + - checkpoint persistence, replay, and resume +- [x] Run the full repo validation sequence: + - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` + - `dotnet test DotPilot.slnx` + - `dotnet format DotPilot.slnx --verify-no-changes` + - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` +- [x] Commit the epic branch implementation and open one PR that closes epic `#12` and its covered child issues correctly. + +## Full-Test Baseline + +- [x] `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` + - Passed with `0` warnings and `0` errors. +- [x] `dotnet test DotPilot.slnx` + - Passed with `52` unit tests and `22` UI tests. + +## Tracked Failing Tests + +- [x] No baseline failures before epic implementation. +- [x] `ExecuteAsyncPausesForApprovalAndResumeAsyncCompletesAfterHostRestart` + - Failure symptom: paused archive was persisted before Agent Framework checkpoint files had materialized, so `CheckpointId` was null. + - Root cause: `RunAsync()` returns on `RequestHaltEvent` before checkpoint metadata is always observable through `Run.LastCheckpoint`. + - Fix path: wait for checkpoint materialization and resolve checkpoint metadata from run state plus persisted checkpoint files. +- [x] `ResumeAsyncPersistsRejectedApprovalAsFailedReplay` + - Failure symptom: resume on the same runtime client threw workflow ownership errors. + - Root cause: `Run` handles were not disposed, so the workflow remained owned by the previous runner. + - Fix path: dispose Agent Framework `Run` handles with `await using`. + +## Done Criteria + +- The branch covers direct child issues `#24`, `#25`, `#26`, and `#27` with real implementation, not only planning artifacts. +- The Uno app remains presentation-only and browser-safe. +- Orleans stays on localhost clustering and in-memory storage/reminders. +- Orchestration, traffic policy, and session persistence flows are automated and green. +- The final PR references the epic and child issues with correct GitHub closing semantics. + +## Final Validation Results + +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` + - Passed with `0` warnings and `0` errors. +- `dotnet test DotPilot.slnx` + - Passed with `72` unit tests and `22` UI tests. +- `dotnet format DotPilot.slnx --verify-no-changes` + - Passed with no formatting drift. +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` + - Passed with overall coverage `84.26%` line and `50.93%` branch. + - Changed runtime files met the repo bar: + - `AgentFrameworkRuntimeClient`: `100.00%` line / `90.00%` branch + - `RuntimeSessionArchiveStore`: `100.00%` line / `100.00%` branch + - `EmbeddedRuntimeTrafficPolicy`: `100.00%` line / `83.33%` branch + - `EmbeddedRuntimeTrafficPolicyCatalog`: `100.00%` line / `100.00%` branch +- Pull request + - Opened [PR #81](https://github.com/managedcode/dotPilot/pull/81) from `codex/epic-12-embedded-runtime` to `main` with `Closes #12`, `Closes #24`, `Closes #25`, `Closes #26`, and `Closes #27`.