From f50f3c870602fcdeae5867a5566c6df3ab007bd9 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 00:27:40 +0100 Subject: [PATCH 1/5] Plan epic 12 embedded runtime --- AGENTS.md | 3 ++ epic-12-embedded-runtime.plan.md | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 epic-12-embedded-runtime.plan.md diff --git a/AGENTS.md b/AGENTS.md index e258b64..8816672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,9 @@ 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 +- the first embedded Orleans runtime cut must use `UseLocalhostClustering` together with in-memory Orleans grain storage and in-memory reminders; durable resume and replay can persist session data outside Orleans storage until a later issue explicitly upgrades the cluster topology - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication - meaningful GitHub review comments must be evaluated and fixed when they still apply even if the original PR was closed; closed review threads are not a reason to ignore valid engineering feedback diff --git a/epic-12-embedded-runtime.plan.md b/epic-12-embedded-runtime.plan.md new file mode 100644 index 0000000..b002c19 --- /dev/null +++ b/epic-12-embedded-runtime.plan.md @@ -0,0 +1,78 @@ +## 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 using `ManagedCode.Orleans.Graph` +- 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`. + +## 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 + +- [ ] Confirm the exact direct-child issue set for epic `#12` and keep unrelated issues out of the PR scope. +- [ ] Add or restore the embedded Orleans host slice from the cleanest available implementation path for issue `#24`. +- [ ] Add the minimum runtime dependencies and contracts for Microsoft Agent Framework orchestration for issue `#25`. +- [ ] Implement the first orchestration runtime path on top of the deterministic runtime flow and Orleans-backed runtime boundaries. +- [ ] Add explicit grain traffic policy modeling and enforcement for issue `#26`, including runtime-visible policy information and denial behavior. +- [ ] Add local-first session persistence, replay, checkpointing, and resume for issue `#27` without changing Orleans clustering/storage topology. +- [ ] Update runtime docs, feature docs, ADR references, and architecture diagrams so the epic boundaries and flows are explicit. +- [ ] 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 +- [ ] 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"` +- [ ] 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. + +## 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. From 1838661f477cabe13e9db985004c740304dc7362 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 00:19:00 +0100 Subject: [PATCH 2/5] Implement embedded Orleans localhost host --- AGENTS.md | 7 +- Directory.Packages.props | 4 + DotPilot.Core/DotPilot.Core.csproj | 4 + .../ControlPlaneIdentifiers.cs | 41 ++- .../ParticipantContracts.cs | 24 +- .../ControlPlaneDomain/PolicyContracts.cs | 23 ++ .../ProviderAndToolContracts.cs | 29 +- .../SessionExecutionContracts.cs | 45 ++- .../EmbeddedRuntimeHostContracts.cs | 75 +++++ DotPilot.Runtime.Host/AGENTS.md | 39 +++ .../DotPilot.Runtime.Host.csproj | 19 ++ .../RuntimeFoundation/ArtifactGrain.cs | 22 ++ .../EmbeddedRuntimeGrainGuards.cs | 16 ++ .../EmbeddedRuntimeHostBuilderExtensions.cs | 49 ++++ .../EmbeddedRuntimeHostCatalog.cs | 39 +++ .../EmbeddedRuntimeHostLifecycleService.cs | 18 ++ .../EmbeddedRuntimeHostNames.cs | 26 ++ .../EmbeddedRuntimeHostOptions.cs | 12 + .../Features/RuntimeFoundation/FleetGrain.cs | 22 ++ .../Features/RuntimeFoundation/PolicyGrain.cs | 22 ++ .../RuntimeFoundation/SessionGrain.cs | 22 ++ .../RuntimeFoundation/WorkspaceGrain.cs | 22 ++ DotPilot.Runtime/AGENTS.md | 1 + .../ControlPlaneDomainContractsTests.cs | 13 + DotPilot.Tests/DotPilot.Tests.csproj | 1 + .../EmbeddedRuntimeHostTests.cs | 269 ++++++++++++++++++ DotPilot.Tests/GlobalUsings.cs | 1 + DotPilot.Tests/coverlet.runsettings | 2 +- DotPilot.slnx | 1 + DotPilot/App.xaml.cs | 6 + DotPilot/DotPilot.csproj | 4 + ...003-vertical-slices-and-ui-only-uno-app.md | 8 +- docs/Architecture.md | 35 ++- docs/Features/embedded-orleans-host.md | 68 +++++ issue-24-embedded-orleans-host.plan.md | 99 +++++++ 35 files changed, 1059 insertions(+), 29 deletions(-) create mode 100644 DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs create mode 100644 DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs create mode 100644 DotPilot.Runtime.Host/AGENTS.md create mode 100644 DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs create mode 100644 DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs create mode 100644 docs/Features/embedded-orleans-host.md create mode 100644 issue-24-embedded-orleans-host.plan.md diff --git a/AGENTS.md b/AGENTS.md index 8816672..5321f18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ This file defines how AI agents work in this solution. - `DotPilot` - `DotPilot.Core` - `DotPilot.Runtime` + - `DotPilot.Runtime.Host` - `DotPilot.ReleaseTool` - `DotPilot.Tests` - `DotPilot.UITests` @@ -152,7 +153,11 @@ For this app: - 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 -- the first embedded Orleans runtime cut must use `UseLocalhostClustering` together with in-memory Orleans grain storage and in-memory reminders; durable resume and replay can persist session data outside Orleans storage until a later issue explicitly upgrades the cluster topology +- 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 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 +- 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 +- Do not invent a repo-specific product framing such as "workbench" unless the active issue or feature spec explicitly uses it; implement the app features described in the backlog instead of turning internal implementation language into the product narrative - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication - meaningful GitHub review comments must be evaluated and fixed when they still apply even if the original PR was closed; closed review threads are not a reason to ignore valid engineering feedback diff --git a/Directory.Packages.props b/Directory.Packages.props index fc35c2a..6f6b460 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,10 @@ + + + + diff --git a/DotPilot.Core/DotPilot.Core.csproj b/DotPilot.Core/DotPilot.Core.csproj index 200a482..af712af 100644 --- a/DotPilot.Core/DotPilot.Core.csproj +++ b/DotPilot.Core/DotPilot.Core.csproj @@ -6,4 +6,8 @@ $(NoWarn);CS1591 + + + + diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs b/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs index 8281823..3799537 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs +++ b/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs @@ -2,77 +2,96 @@ namespace DotPilot.Core.Features.ControlPlaneDomain; -public readonly record struct WorkspaceId(Guid Value) +[GenerateSerializer] +public readonly record struct WorkspaceId([property: Id(0)] Guid Value) { public static WorkspaceId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct AgentProfileId(Guid Value) +[GenerateSerializer] +public readonly record struct AgentProfileId([property: Id(0)] Guid Value) { public static AgentProfileId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct SessionId(Guid Value) +[GenerateSerializer] +public readonly record struct SessionId([property: Id(0)] Guid Value) { public static SessionId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct FleetId(Guid Value) +[GenerateSerializer] +public readonly record struct FleetId([property: Id(0)] Guid Value) { public static FleetId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct ProviderId(Guid Value) +[GenerateSerializer] +public readonly record struct PolicyId([property: Id(0)] Guid Value) +{ + public static PolicyId New() => new(ControlPlaneIdentifier.NewValue()); + + public override string ToString() => ControlPlaneIdentifier.Format(Value); +} + +[GenerateSerializer] +public readonly record struct ProviderId([property: Id(0)] Guid Value) { public static ProviderId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct ModelRuntimeId(Guid Value) +[GenerateSerializer] +public readonly record struct ModelRuntimeId([property: Id(0)] Guid Value) { public static ModelRuntimeId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct ToolCapabilityId(Guid Value) +[GenerateSerializer] +public readonly record struct ToolCapabilityId([property: Id(0)] Guid Value) { public static ToolCapabilityId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct ApprovalId(Guid Value) +[GenerateSerializer] +public readonly record struct ApprovalId([property: Id(0)] Guid Value) { public static ApprovalId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct ArtifactId(Guid Value) +[GenerateSerializer] +public readonly record struct ArtifactId([property: Id(0)] Guid Value) { public static ArtifactId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct TelemetryRecordId(Guid Value) +[GenerateSerializer] +public readonly record struct TelemetryRecordId([property: Id(0)] Guid Value) { public static TelemetryRecordId New() => new(ControlPlaneIdentifier.NewValue()); public override string ToString() => ControlPlaneIdentifier.Format(Value); } -public readonly record struct EvaluationId(Guid Value) +[GenerateSerializer] +public readonly record struct EvaluationId([property: Id(0)] Guid Value) { public static EvaluationId New() => new(ControlPlaneIdentifier.NewValue()); diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs b/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs index 43b63e3..4bb177c 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs +++ b/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs @@ -1,40 +1,58 @@ namespace DotPilot.Core.Features.ControlPlaneDomain; +[GenerateSerializer] public sealed record WorkspaceDescriptor { + [Id(0)] public WorkspaceId Id { get; init; } + [Id(1)] public string Name { get; init; } = string.Empty; + [Id(2)] public string RootPath { get; init; } = string.Empty; + [Id(3)] public string BranchName { get; init; } = string.Empty; } +[GenerateSerializer] public sealed record AgentProfileDescriptor { + [Id(0)] public AgentProfileId Id { get; init; } + [Id(1)] public string Name { get; init; } = string.Empty; + [Id(2)] public AgentRoleKind Role { get; init; } + [Id(3)] public ProviderId? ProviderId { get; init; } + [Id(4)] public ModelRuntimeId? ModelRuntimeId { get; init; } - public IReadOnlyList ToolCapabilityIds { get; init; } = []; + [Id(5)] + public ToolCapabilityId[] ToolCapabilityIds { get; init; } = []; - public IReadOnlyList Tags { get; init; } = []; + [Id(6)] + public string[] Tags { get; init; } = []; } +[GenerateSerializer] public sealed record FleetDescriptor { + [Id(0)] public FleetId Id { get; init; } + [Id(1)] public string Name { get; init; } = string.Empty; + [Id(2)] public FleetExecutionMode ExecutionMode { get; init; } = FleetExecutionMode.SingleAgent; - public IReadOnlyList AgentProfileIds { get; init; } = []; + [Id(3)] + public AgentProfileId[] AgentProfileIds { get; init; } = []; } diff --git a/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs b/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs new file mode 100644 index 0000000..c66747e --- /dev/null +++ b/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs @@ -0,0 +1,23 @@ +namespace DotPilot.Core.Features.ControlPlaneDomain; + +[GenerateSerializer] +public sealed record PolicyDescriptor +{ + [Id(0)] + public PolicyId Id { get; init; } + + [Id(1)] + public string Name { get; init; } = string.Empty; + + [Id(2)] + public ApprovalState DefaultApprovalState { get; init; } = ApprovalState.NotRequired; + + [Id(3)] + public bool AllowsNetworkAccess { get; init; } + + [Id(4)] + public bool AllowsFileSystemWrites { get; init; } + + [Id(5)] + public ApprovalScope[] ProtectedScopes { get; init; } = []; +} diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs b/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs index 127a62e..8aa0232 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs +++ b/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs @@ -1,50 +1,73 @@ namespace DotPilot.Core.Features.ControlPlaneDomain; +[GenerateSerializer] public sealed record ToolCapabilityDescriptor { + [Id(0)] public ToolCapabilityId Id { get; init; } + [Id(1)] public string Name { get; init; } = string.Empty; + [Id(2)] public string DisplayName { get; init; } = string.Empty; + [Id(3)] public ToolCapabilityKind Kind { get; init; } + [Id(4)] public bool RequiresApproval { get; init; } + [Id(5)] public bool IsEnabledByDefault { get; init; } - public IReadOnlyList Tags { get; init; } = []; + [Id(6)] + public string[] Tags { get; init; } = []; } +[GenerateSerializer] public sealed record ProviderDescriptor { + [Id(0)] public ProviderId Id { get; init; } + [Id(1)] public string DisplayName { get; init; } = string.Empty; + [Id(2)] public string CommandName { get; init; } = string.Empty; + [Id(3)] public ProviderConnectionStatus Status { get; init; } = ProviderConnectionStatus.Unavailable; + [Id(4)] public string StatusSummary { get; init; } = string.Empty; + [Id(5)] public bool RequiresExternalToolchain { get; init; } - public IReadOnlyList SupportedToolIds { get; init; } = []; + [Id(6)] + public ToolCapabilityId[] SupportedToolIds { get; init; } = []; } +[GenerateSerializer] public sealed record ModelRuntimeDescriptor { + [Id(0)] public ModelRuntimeId Id { get; init; } + [Id(1)] public string DisplayName { get; init; } = string.Empty; + [Id(2)] public string EngineName { get; init; } = string.Empty; + [Id(3)] public RuntimeKind RuntimeKind { get; init; } + [Id(4)] public ProviderConnectionStatus Status { get; init; } = ProviderConnectionStatus.Unavailable; - public IReadOnlyList SupportedModelFamilies { get; init; } = []; + [Id(5)] + public string[] SupportedModelFamilies { get; init; } = []; } diff --git a/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs b/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs index 30488e8..6a3e83d 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs +++ b/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs @@ -1,92 +1,135 @@ namespace DotPilot.Core.Features.ControlPlaneDomain; +[GenerateSerializer] public sealed record SessionDescriptor { + [Id(0)] public SessionId Id { get; init; } + [Id(1)] public WorkspaceId WorkspaceId { get; init; } + [Id(2)] public string Title { get; init; } = string.Empty; + [Id(3)] public SessionPhase Phase { get; init; } = SessionPhase.Plan; + [Id(4)] public ApprovalState ApprovalState { get; init; } = ApprovalState.NotRequired; + [Id(5)] public FleetId? FleetId { get; init; } - public IReadOnlyList AgentProfileIds { get; init; } = []; + [Id(6)] + public AgentProfileId[] AgentProfileIds { get; init; } = []; + [Id(7)] public DateTimeOffset CreatedAt { get; init; } + [Id(8)] public DateTimeOffset UpdatedAt { get; init; } } +[GenerateSerializer] public sealed record SessionApprovalRecord { + [Id(0)] public ApprovalId Id { get; init; } + [Id(1)] public SessionId SessionId { get; init; } + [Id(2)] public ApprovalScope Scope { get; init; } + [Id(3)] public ApprovalState State { get; init; } = ApprovalState.Pending; + [Id(4)] public string RequestedAction { get; init; } = string.Empty; + [Id(5)] public string RequestedBy { get; init; } = string.Empty; + [Id(6)] public DateTimeOffset RequestedAt { get; init; } + [Id(7)] public DateTimeOffset? ResolvedAt { get; init; } } +[GenerateSerializer] public sealed record ArtifactDescriptor { + [Id(0)] public ArtifactId Id { get; init; } + [Id(1)] public SessionId SessionId { get; init; } + [Id(2)] public AgentProfileId? AgentProfileId { get; init; } + [Id(3)] public string Name { get; init; } = string.Empty; + [Id(4)] public ArtifactKind Kind { get; init; } + [Id(5)] public string RelativePath { get; init; } = string.Empty; + [Id(6)] public DateTimeOffset CreatedAt { get; init; } } +[GenerateSerializer] public sealed record TelemetryRecord { + [Id(0)] public TelemetryRecordId Id { get; init; } + [Id(1)] public SessionId SessionId { get; init; } + [Id(2)] public TelemetrySignalKind Kind { get; init; } + [Id(3)] public string Name { get; init; } = string.Empty; + [Id(4)] public string Summary { get; init; } = string.Empty; + [Id(5)] public DateTimeOffset RecordedAt { get; init; } } +[GenerateSerializer] public sealed record EvaluationRecord { + [Id(0)] public EvaluationId Id { get; init; } + [Id(1)] public SessionId SessionId { get; init; } + [Id(2)] public ArtifactId? ArtifactId { get; init; } + [Id(3)] public EvaluationMetricKind Metric { get; init; } + [Id(4)] public decimal Score { get; init; } + [Id(5)] public EvaluationOutcome Outcome { get; init; } = EvaluationOutcome.NeedsReview; + [Id(6)] public string Summary { get; init; } = string.Empty; + [Id(7)] public DateTimeOffset EvaluatedAt { get; init; } } diff --git a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs new file mode 100644 index 0000000..a3f14f2 --- /dev/null +++ b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs @@ -0,0 +1,75 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.RuntimeFoundation; + +public enum EmbeddedRuntimeHostState +{ + Stopped, + Starting, + Running, +} + +public enum EmbeddedRuntimeClusteringMode +{ + Localhost, +} + +public enum EmbeddedRuntimeStorageMode +{ + InMemory, +} + +public sealed record EmbeddedRuntimeGrainDescriptor( + string Name, + string Summary); + +public sealed record EmbeddedRuntimeHostSnapshot( + EmbeddedRuntimeHostState State, + EmbeddedRuntimeClusteringMode ClusteringMode, + EmbeddedRuntimeStorageMode GrainStorageMode, + EmbeddedRuntimeStorageMode ReminderStorageMode, + string ClusterId, + string ServiceId, + int SiloPort, + int GatewayPort, + IReadOnlyList Grains); + +public interface IEmbeddedRuntimeHostCatalog +{ + EmbeddedRuntimeHostSnapshot GetSnapshot(); +} + +public interface ISessionGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(SessionDescriptor session); +} + +public interface IWorkspaceGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(WorkspaceDescriptor workspace); +} + +public interface IFleetGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(FleetDescriptor fleet); +} + +public interface IPolicyGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(PolicyDescriptor policy); +} + +public interface IArtifactGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(ArtifactDescriptor artifact); +} diff --git a/DotPilot.Runtime.Host/AGENTS.md b/DotPilot.Runtime.Host/AGENTS.md new file mode 100644 index 0000000..63971a2 --- /dev/null +++ b/DotPilot.Runtime.Host/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md + +Project: `DotPilot.Runtime.Host` +Stack: `.NET 10`, class library, embedded Orleans host and local runtime-host services + +## Purpose + +- This project owns the desktop-embedded Orleans host for `dotPilot`. +- It keeps cluster hosting, grain registration, and host lifecycle code out of the Uno app and away from browser-targeted runtime libraries. + +## Entry Points + +- `DotPilot.Runtime.Host.csproj` +- `Features/RuntimeFoundation/*` + +## Boundaries + +- Keep this project free of `Uno Platform`, XAML, and page/view-model logic. +- Keep it focused on local embedded host concerns: silo configuration, grain registration, and host lifecycle. +- Use `UseLocalhostClustering` plus in-memory storage/reminders for the first runtime-host cut. +- Do not add remote clustering, external durable stores, or provider-specific orchestration here unless a later issue explicitly requires them. + +## Local Commands + +- `build-host`: `dotnet build DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj` +- `test-runtime-host`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~EmbeddedRuntimeHost` + +## Applicable Skills + +- `mcaf-dotnet` +- `mcaf-dotnet-features` +- `mcaf-testing` +- `mcaf-solid-maintainability` +- `mcaf-architecture-overview` + +## Local Risks Or Protected Areas + +- This project must remain invisible to the browserwasm path; keep app references conditional so UI tests stay green. +- Grain contracts belong in `DotPilot.Core`; do not let this project become the source of truth for shared runtime abstractions. diff --git a/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj b/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj new file mode 100644 index 0000000..6f8f53a --- /dev/null +++ b/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + true + $(NoWarn);CS1591 + + + + + + + + + + + + + diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs new file mode 100644 index 0000000..9cb8971 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs @@ -0,0 +1,22 @@ +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public sealed class ArtifactGrain( + [PersistentState(EmbeddedRuntimeHostNames.ArtifactStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] + IPersistentState artifactState) : Grain, IArtifactGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(artifactState.RecordExists ? artifactState.State : null); + } + + public async ValueTask UpsertAsync(ArtifactDescriptor artifact) + { + EmbeddedRuntimeGrainGuards.EnsureMatchingKey(artifact.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.ArtifactGrainName); + artifactState.State = artifact; + await artifactState.WriteStateAsync(); + return artifactState.State; + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs new file mode 100644 index 0000000..c25ef36 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs @@ -0,0 +1,16 @@ +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +internal static class EmbeddedRuntimeGrainGuards +{ + public static void EnsureMatchingKey(string descriptorId, string grainKey, string grainName) + { + if (string.Equals(descriptorId, grainKey, StringComparison.Ordinal)) + { + return; + } + + throw new ArgumentException( + string.Concat(EmbeddedRuntimeHostNames.MismatchedPrimaryKeyPrefix, grainName), + nameof(descriptorId)); + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs new file mode 100644 index 0000000..213b888 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs @@ -0,0 +1,49 @@ +using DotPilot.Core.Features.RuntimeFoundation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Orleans.Configuration; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public static class EmbeddedRuntimeHostBuilderExtensions +{ + public static IHostBuilder UseDotPilotEmbeddedRuntime( + this IHostBuilder builder, + EmbeddedRuntimeHostOptions? options = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var resolvedOptions = options ?? new EmbeddedRuntimeHostOptions(); + + builder.ConfigureServices(services => + { + services.AddSingleton(resolvedOptions); + services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddHostedService(); + }); + + builder.UseOrleans((context, siloBuilder) => + { + _ = context; + ConfigureSilo(siloBuilder, resolvedOptions); + }); + + return builder; + } + + internal static void ConfigureSilo(ISiloBuilder siloBuilder, EmbeddedRuntimeHostOptions options) + { + ArgumentNullException.ThrowIfNull(siloBuilder); + ArgumentNullException.ThrowIfNull(options); + + siloBuilder.UseLocalhostClustering(options.SiloPort, options.GatewayPort); + siloBuilder.Configure(cluster => + { + cluster.ClusterId = options.ClusterId; + cluster.ServiceId = options.ServiceId; + }); + siloBuilder.AddMemoryGrainStorage(EmbeddedRuntimeHostNames.GrainStorageProviderName); + siloBuilder.UseInMemoryReminderService(); + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs new file mode 100644 index 0000000..241106a --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs @@ -0,0 +1,39 @@ +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +internal sealed class EmbeddedRuntimeHostCatalog(EmbeddedRuntimeHostOptions options) : IEmbeddedRuntimeHostCatalog +{ + private int _state = (int)EmbeddedRuntimeHostState.Stopped; + + public EmbeddedRuntimeHostSnapshot GetSnapshot() + { + return new( + (EmbeddedRuntimeHostState)Volatile.Read(ref _state), + EmbeddedRuntimeClusteringMode.Localhost, + EmbeddedRuntimeStorageMode.InMemory, + EmbeddedRuntimeStorageMode.InMemory, + options.ClusterId, + options.ServiceId, + options.SiloPort, + options.GatewayPort, + CreateGrains()); + } + + public void SetState(EmbeddedRuntimeHostState state) + { + Volatile.Write(ref _state, (int)state); + } + + private static IReadOnlyList CreateGrains() + { + return + [ + new(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.SessionGrainSummary), + new(EmbeddedRuntimeHostNames.WorkspaceGrainName, EmbeddedRuntimeHostNames.WorkspaceGrainSummary), + new(EmbeddedRuntimeHostNames.FleetGrainName, EmbeddedRuntimeHostNames.FleetGrainSummary), + new(EmbeddedRuntimeHostNames.PolicyGrainName, EmbeddedRuntimeHostNames.PolicyGrainSummary), + new(EmbeddedRuntimeHostNames.ArtifactGrainName, EmbeddedRuntimeHostNames.ArtifactGrainSummary), + ]; + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs new file mode 100644 index 0000000..ff298e1 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Hosting; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +internal sealed class EmbeddedRuntimeHostLifecycleService(EmbeddedRuntimeHostCatalog catalog) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + catalog.SetState(DotPilot.Core.Features.RuntimeFoundation.EmbeddedRuntimeHostState.Running); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + catalog.SetState(DotPilot.Core.Features.RuntimeFoundation.EmbeddedRuntimeHostState.Stopped); + return Task.CompletedTask; + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs new file mode 100644 index 0000000..bf4dab1 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs @@ -0,0 +1,26 @@ +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +internal static class EmbeddedRuntimeHostNames +{ + public const string DefaultClusterId = "dotpilot-local"; + public const string DefaultServiceId = "dotpilot-desktop"; + public const int DefaultSiloPort = 11_111; + public const int DefaultGatewayPort = 30_000; + public const string GrainStorageProviderName = "runtime-foundation-memory"; + public const string SessionStateName = "session"; + public const string WorkspaceStateName = "workspace"; + public const string FleetStateName = "fleet"; + public const string PolicyStateName = "policy"; + public const string ArtifactStateName = "artifact"; + public const string SessionGrainName = "Session"; + public const string WorkspaceGrainName = "Workspace"; + public const string FleetGrainName = "Fleet"; + public const string PolicyGrainName = "Policy"; + public const string ArtifactGrainName = "Artifact"; + public const string SessionGrainSummary = "Stores local session state in the embedded runtime host."; + public const string WorkspaceGrainSummary = "Stores local workspace descriptors for the embedded runtime host."; + public const string FleetGrainSummary = "Stores participating agent fleet descriptors for local orchestration."; + public const string PolicyGrainSummary = "Stores local approval and execution policy defaults."; + public const string ArtifactGrainSummary = "Stores artifact metadata for the local embedded runtime."; + public const string MismatchedPrimaryKeyPrefix = "Descriptor id does not match the grain primary key for "; +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs new file mode 100644 index 0000000..28fc644 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs @@ -0,0 +1,12 @@ +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public sealed class EmbeddedRuntimeHostOptions +{ + public string ClusterId { get; init; } = EmbeddedRuntimeHostNames.DefaultClusterId; + + public string ServiceId { get; init; } = EmbeddedRuntimeHostNames.DefaultServiceId; + + public int SiloPort { get; init; } = EmbeddedRuntimeHostNames.DefaultSiloPort; + + public int GatewayPort { get; init; } = EmbeddedRuntimeHostNames.DefaultGatewayPort; +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs new file mode 100644 index 0000000..59e8a55 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs @@ -0,0 +1,22 @@ +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public sealed class FleetGrain( + [PersistentState(EmbeddedRuntimeHostNames.FleetStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] + IPersistentState fleetState) : Grain, IFleetGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(fleetState.RecordExists ? fleetState.State : null); + } + + public async ValueTask UpsertAsync(FleetDescriptor fleet) + { + EmbeddedRuntimeGrainGuards.EnsureMatchingKey(fleet.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.FleetGrainName); + fleetState.State = fleet; + await fleetState.WriteStateAsync(); + return fleetState.State; + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs new file mode 100644 index 0000000..457d41c --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs @@ -0,0 +1,22 @@ +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public sealed class PolicyGrain( + [PersistentState(EmbeddedRuntimeHostNames.PolicyStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] + IPersistentState policyState) : Grain, IPolicyGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(policyState.RecordExists ? policyState.State : null); + } + + public async ValueTask UpsertAsync(PolicyDescriptor policy) + { + EmbeddedRuntimeGrainGuards.EnsureMatchingKey(policy.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.PolicyGrainName); + policyState.State = policy; + await policyState.WriteStateAsync(); + return policyState.State; + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs new file mode 100644 index 0000000..1a7769e --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs @@ -0,0 +1,22 @@ +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public sealed class SessionGrain( + [PersistentState(EmbeddedRuntimeHostNames.SessionStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] + IPersistentState sessionState) : Grain, ISessionGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(sessionState.RecordExists ? sessionState.State : null); + } + + public async ValueTask UpsertAsync(SessionDescriptor session) + { + EmbeddedRuntimeGrainGuards.EnsureMatchingKey(session.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.SessionGrainName); + sessionState.State = session; + await sessionState.WriteStateAsync(); + return sessionState.State; + } +} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs new file mode 100644 index 0000000..0c21710 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs @@ -0,0 +1,22 @@ +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; + +public sealed class WorkspaceGrain( + [PersistentState(EmbeddedRuntimeHostNames.WorkspaceStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] + IPersistentState workspaceState) : Grain, IWorkspaceGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(workspaceState.RecordExists ? workspaceState.State : null); + } + + public async ValueTask UpsertAsync(WorkspaceDescriptor workspace) + { + EmbeddedRuntimeGrainGuards.EnsureMatchingKey(workspace.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.WorkspaceGrainName); + workspaceState.State = workspace; + await workspaceState.WriteStateAsync(); + return workspaceState.State; + } +} diff --git a/DotPilot.Runtime/AGENTS.md b/DotPilot.Runtime/AGENTS.md index 3676ff5..c53f5f1 100644 --- a/DotPilot.Runtime/AGENTS.md +++ b/DotPilot.Runtime/AGENTS.md @@ -20,6 +20,7 @@ Stack: `.NET 10`, class library, provider-independent runtime services and diagn - Implement feature slices against `DotPilot.Core` contracts instead of reaching back into the app project. - Prefer deterministic runtime behavior and environment probing here so tests can exercise real flows without mocks. - Keep external-provider assumptions soft: absence of Codex, Claude Code, or GitHub Copilot in CI must not break the provider-independent baseline. +- For the first embedded Orleans host implementation, stay local-first with `UseLocalhostClustering` and in-memory storage/reminders so the desktop runtime remains self-contained. ## Local Commands diff --git a/DotPilot.Tests/ControlPlaneDomainContractsTests.cs b/DotPilot.Tests/ControlPlaneDomainContractsTests.cs index 1593b0d..6cb3769 100644 --- a/DotPilot.Tests/ControlPlaneDomainContractsTests.cs +++ b/DotPilot.Tests/ControlPlaneDomainContractsTests.cs @@ -17,6 +17,7 @@ public void ControlPlaneIdentifiersProduceStableNonEmptyRepresentations() AgentProfileId.New().ToString(), SessionId.New().ToString(), FleetId.New().ToString(), + PolicyId.New().ToString(), ProviderId.New().ToString(), ModelRuntimeId.New().ToString(), ToolCapabilityId.New().ToString(), @@ -53,6 +54,7 @@ public void ControlPlaneContractsModelMixedProviderAndLocalRuntimeSessions() envelope.CodingAgent.ProviderId.Should().Be(envelope.Provider.Id); envelope.ReviewerAgent.ModelRuntimeId.Should().Be(envelope.LocalRuntime.Id); envelope.Fleet.ExecutionMode.Should().Be(FleetExecutionMode.Orchestrated); + envelope.Policy.DefaultApprovalState.Should().Be(ApprovalState.Pending); envelope.Approval.Scope.Should().Be(ApprovalScope.CommandExecution); envelope.Artifact.Kind.Should().Be(ArtifactKind.Snapshot); envelope.Telemetry.Kind.Should().Be(TelemetrySignalKind.Trace); @@ -195,6 +197,15 @@ private static ControlPlaneDomainEnvelope CreateEnvelope() CodingAgent = codingAgent, ReviewerAgent = reviewerAgent, Fleet = fleet, + Policy = new PolicyDescriptor + { + Id = PolicyId.New(), + Name = "Desktop Local Policy", + DefaultApprovalState = ApprovalState.Pending, + AllowsNetworkAccess = false, + AllowsFileSystemWrites = true, + ProtectedScopes = [ApprovalScope.FileWrite, ApprovalScope.CommandExecution], + }, Session = session, Approval = approval, Artifact = artifact, @@ -219,6 +230,8 @@ private sealed record ControlPlaneDomainEnvelope public FleetDescriptor Fleet { get; init; } = new(); + public PolicyDescriptor Policy { get; init; } = new(); + public SessionDescriptor Session { get; init; } = new(); public SessionApprovalRecord Approval { get; init; } = new(); diff --git a/DotPilot.Tests/DotPilot.Tests.csproj b/DotPilot.Tests/DotPilot.Tests.csproj index 3ff71c7..22f9d44 100644 --- a/DotPilot.Tests/DotPilot.Tests.csproj +++ b/DotPilot.Tests/DotPilot.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs new file mode 100644 index 0000000..b4dbe73 --- /dev/null +++ b/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs @@ -0,0 +1,269 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace DotPilot.Tests.Features.RuntimeFoundation; + +public class EmbeddedRuntimeHostTests +{ + private static readonly DateTimeOffset Timestamp = new(2026, 3, 13, 12, 0, 0, TimeSpan.Zero); + + [Test] + public void CatalogStartsInStoppedStateBeforeTheHostRuns() + { + var options = CreateOptions(); + using var host = CreateHost(options); + + var snapshot = host.Services.GetRequiredService().GetSnapshot(); + + snapshot.State.Should().Be(EmbeddedRuntimeHostState.Stopped); + snapshot.ClusteringMode.Should().Be(EmbeddedRuntimeClusteringMode.Localhost); + snapshot.GrainStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); + snapshot.ReminderStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); + snapshot.ClusterId.Should().Be(options.ClusterId); + snapshot.ServiceId.Should().Be(options.ServiceId); + snapshot.SiloPort.Should().Be(options.SiloPort); + snapshot.GatewayPort.Should().Be(options.GatewayPort); + snapshot.Grains.Select(grain => grain.Name).Should().ContainInOrder("Session", "Workspace", "Fleet", "Policy", "Artifact"); + } + + [Test] + public void CatalogUsesDefaultLocalhostOptionsWhenTheCallerDoesNotProvideOverrides() + { + var defaults = new EmbeddedRuntimeHostOptions(); + using var host = Host.CreateDefaultBuilder() + .UseDotPilotEmbeddedRuntime() + .Build(); + + var snapshot = host.Services.GetRequiredService().GetSnapshot(); + + snapshot.State.Should().Be(EmbeddedRuntimeHostState.Stopped); + snapshot.ClusteringMode.Should().Be(EmbeddedRuntimeClusteringMode.Localhost); + snapshot.GrainStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); + snapshot.ReminderStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); + snapshot.ClusterId.Should().Be(defaults.ClusterId); + snapshot.ServiceId.Should().Be(defaults.ServiceId); + snapshot.SiloPort.Should().Be(defaults.SiloPort); + snapshot.GatewayPort.Should().Be(defaults.GatewayPort); + } + + [Test] + public async Task CatalogTransitionsToRunningStateAfterHostStartAsync() + { + var options = CreateOptions(); + using var host = CreateHost(options); + + await host.StartAsync(); + var snapshot = host.Services.GetRequiredService().GetSnapshot(); + + snapshot.State.Should().Be(EmbeddedRuntimeHostState.Running); + } + + [Test] + public async Task InitialGrainsReturnNullBeforeTheirFirstWrite() + { + var options = CreateOptions(); + await RunWithStartedHostAsync( + options, + async host => + { + var grainFactory = host.Services.GetRequiredService(); + + (await grainFactory.GetGrain(SessionId.New().ToString()).GetAsync()).Should().BeNull(); + (await grainFactory.GetGrain(WorkspaceId.New().ToString()).GetAsync()).Should().BeNull(); + (await grainFactory.GetGrain(FleetId.New().ToString()).GetAsync()).Should().BeNull(); + (await grainFactory.GetGrain(PolicyId.New().ToString()).GetAsync()).Should().BeNull(); + (await grainFactory.GetGrain(ArtifactId.New().ToString()).GetAsync()).Should().BeNull(); + }); + } + + [Test] + public async Task InitialGrainsRoundTripTheirDescriptorState() + { + var workspace = CreateWorkspace(); + var firstAgentId = AgentProfileId.New(); + var secondAgentId = AgentProfileId.New(); + var fleet = CreateFleet(firstAgentId, secondAgentId); + var session = CreateSession(workspace.Id, fleet.Id, firstAgentId, secondAgentId); + var policy = CreatePolicy(); + var artifact = CreateArtifact(session.Id, firstAgentId); + var options = CreateOptions(); + await RunWithStartedHostAsync( + options, + async host => + { + var grainFactory = host.Services.GetRequiredService(); + + (await grainFactory.GetGrain(session.Id.ToString()).UpsertAsync(session)).Should().BeEquivalentTo(session); + (await grainFactory.GetGrain(workspace.Id.ToString()).UpsertAsync(workspace)).Should().BeEquivalentTo(workspace); + (await grainFactory.GetGrain(fleet.Id.ToString()).UpsertAsync(fleet)).Should().BeEquivalentTo(fleet); + (await grainFactory.GetGrain(policy.Id.ToString()).UpsertAsync(policy)).Should().BeEquivalentTo(policy); + (await grainFactory.GetGrain(artifact.Id.ToString()).UpsertAsync(artifact)).Should().BeEquivalentTo(artifact); + + (await grainFactory.GetGrain(session.Id.ToString()).GetAsync()).Should().BeEquivalentTo(session); + (await grainFactory.GetGrain(workspace.Id.ToString()).GetAsync()).Should().BeEquivalentTo(workspace); + (await grainFactory.GetGrain(fleet.Id.ToString()).GetAsync()).Should().BeEquivalentTo(fleet); + (await grainFactory.GetGrain(policy.Id.ToString()).GetAsync()).Should().BeEquivalentTo(policy); + (await grainFactory.GetGrain(artifact.Id.ToString()).GetAsync()).Should().BeEquivalentTo(artifact); + }); + } + + [Test] + public async Task SessionGrainRejectsDescriptorIdsThatDoNotMatchThePrimaryKey() + { + var workspace = CreateWorkspace(); + var firstAgentId = AgentProfileId.New(); + var secondAgentId = AgentProfileId.New(); + var fleet = CreateFleet(firstAgentId, secondAgentId); + var session = CreateSession(workspace.Id, fleet.Id, firstAgentId, secondAgentId); + var options = CreateOptions(); + await RunWithStartedHostAsync( + options, + async host => + { + var grainFactory = host.Services.GetRequiredService(); + var mismatchedGrain = grainFactory.GetGrain(SessionId.New().ToString()); + + var action = async () => await mismatchedGrain.UpsertAsync(session); + + await action.Should().ThrowAsync(); + }); + } + + [Test] + public async Task SessionStateDoesNotSurviveHostRestartWhenUsingInMemoryStorage() + { + var workspace = CreateWorkspace(); + var firstAgentId = AgentProfileId.New(); + var secondAgentId = AgentProfileId.New(); + var fleet = CreateFleet(firstAgentId, secondAgentId); + var session = CreateSession(workspace.Id, fleet.Id, firstAgentId, secondAgentId); + + await RunWithStartedHostAsync( + CreateOptions(), + async firstHost => + { + var firstFactory = firstHost.Services.GetRequiredService(); + await firstFactory.GetGrain(session.Id.ToString()).UpsertAsync(session); + (await firstFactory.GetGrain(session.Id.ToString()).GetAsync()).Should().BeEquivalentTo(session); + }); + + await RunWithStartedHostAsync( + CreateOptions(), + async secondHost => + { + var secondFactory = secondHost.Services.GetRequiredService(); + (await secondFactory.GetGrain(session.Id.ToString()).GetAsync()).Should().BeNull(); + }); + } + + private static IHost CreateHost(EmbeddedRuntimeHostOptions options) + { + return Host.CreateDefaultBuilder() + .UseDotPilotEmbeddedRuntime(options) + .Build(); + } + + private static async Task RunWithStartedHostAsync(EmbeddedRuntimeHostOptions options, Func assertion) + { + using var host = CreateHost(options); + await host.StartAsync(); + + try + { + await assertion(host); + } + finally + { + await host.StopAsync(); + } + } + + private static EmbeddedRuntimeHostOptions CreateOptions() + { + return new EmbeddedRuntimeHostOptions + { + ClusterId = $"dotpilot-local-{Guid.NewGuid():N}", + ServiceId = $"dotpilot-service-{Guid.NewGuid():N}", + SiloPort = GetFreeTcpPort(), + GatewayPort = GetFreeTcpPort(), + }; + } + + private static WorkspaceDescriptor CreateWorkspace() + { + return new WorkspaceDescriptor + { + Id = WorkspaceId.New(), + Name = "dotPilot", + RootPath = "/repo/dotPilot", + BranchName = "codex/issue-24-embedded-orleans-host", + }; + } + + private static FleetDescriptor CreateFleet(AgentProfileId firstAgentId, AgentProfileId secondAgentId) + { + return new FleetDescriptor + { + Id = FleetId.New(), + Name = "Local Runtime Fleet", + ExecutionMode = FleetExecutionMode.Orchestrated, + AgentProfileIds = [firstAgentId, secondAgentId], + }; + } + + private static SessionDescriptor CreateSession( + WorkspaceId workspaceId, + FleetId fleetId, + AgentProfileId firstAgentId, + AgentProfileId secondAgentId) + { + return new SessionDescriptor + { + Id = SessionId.New(), + WorkspaceId = workspaceId, + Title = "Embedded Orleans runtime host test", + Phase = SessionPhase.Execute, + ApprovalState = ApprovalState.Pending, + FleetId = fleetId, + AgentProfileIds = [firstAgentId, secondAgentId], + CreatedAt = Timestamp, + UpdatedAt = Timestamp, + }; + } + + private static PolicyDescriptor CreatePolicy() + { + return new PolicyDescriptor + { + Id = PolicyId.New(), + Name = "Desktop Local Policy", + DefaultApprovalState = ApprovalState.Pending, + AllowsNetworkAccess = false, + AllowsFileSystemWrites = true, + ProtectedScopes = [ApprovalScope.CommandExecution, ApprovalScope.FileWrite], + }; + } + + private static ArtifactDescriptor CreateArtifact(SessionId sessionId, AgentProfileId agentProfileId) + { + return new ArtifactDescriptor + { + Id = ArtifactId.New(), + SessionId = sessionId, + AgentProfileId = agentProfileId, + Name = "runtime-foundation.snapshot.json", + Kind = ArtifactKind.Snapshot, + RelativePath = "artifacts/runtime-foundation.snapshot.json", + CreatedAt = Timestamp, + }; + } + + private static int GetFreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/DotPilot.Tests/GlobalUsings.cs b/DotPilot.Tests/GlobalUsings.cs index d3e404b..62128f3 100644 --- a/DotPilot.Tests/GlobalUsings.cs +++ b/DotPilot.Tests/GlobalUsings.cs @@ -6,5 +6,6 @@ global using DotPilot.Core.Features.Workbench; global using DotPilot.Runtime.Features.RuntimeFoundation; global using DotPilot.Runtime.Features.ToolchainCenter; +global using DotPilot.Runtime.Host.Features.RuntimeFoundation; global using FluentAssertions; global using NUnit.Framework; diff --git a/DotPilot.Tests/coverlet.runsettings b/DotPilot.Tests/coverlet.runsettings index 1326ab7..10519eb 100644 --- a/DotPilot.Tests/coverlet.runsettings +++ b/DotPilot.Tests/coverlet.runsettings @@ -12,7 +12,7 @@ true true true - MissingAny + MissingAll diff --git a/DotPilot.slnx b/DotPilot.slnx index b4da230..da26d5e 100644 --- a/DotPilot.slnx +++ b/DotPilot.slnx @@ -20,6 +20,7 @@ + diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index 23fe182..f695ecf 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -1,6 +1,9 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +#if !__WASM__ +using DotPilot.Runtime.Host.Features.RuntimeFoundation; +#endif namespace DotPilot; @@ -51,6 +54,9 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) #if DEBUG // Switch to Development environment when running in DEBUG .UseEnvironment(Environments.Development) +#endif +#if !__WASM__ + .UseDotPilotEmbeddedRuntime() #endif .UseLogging(configure: (context, logBuilder) => { diff --git a/DotPilot/DotPilot.csproj b/DotPilot/DotPilot.csproj index a005f36..042ac3b 100644 --- a/DotPilot/DotPilot.csproj +++ b/DotPilot/DotPilot.csproj @@ -64,6 +64,10 @@ + + + + diff --git a/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md b/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md index 8d5b7ec..8a24476 100644 --- a/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md +++ b/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md @@ -31,8 +31,9 @@ We will use these architectural defaults for implementation work going forward: 2. Non-UI feature work moves into separate class libraries: - `DotPilot.Core` for contracts, typed identifiers, and public slice interfaces - `DotPilot.Runtime` for provider-independent runtime implementations and future host integration seams + - `DotPilot.Runtime.Host` for the embedded Orleans silo and desktop-only runtime-host lifecycle 3. Feature code must be organized as vertical slices under `Features//...`, not as shared horizontal `Services`, `Models`, or `Helpers` buckets. -4. Epic `#12` starts with a `RuntimeFoundation` slice that sequences issues `#22`, `#23`, `#24`, and `#25` behind a stable contract surface before live Orleans or provider integration. +4. Epic `#12` starts with a `RuntimeFoundation` slice that sequences issues `#22`, `#23`, `#24`, and `#25` behind a stable contract surface. Issue `#24` is implemented through a desktop-only `DotPilot.Runtime.Host` project that uses localhost clustering plus in-memory storage/reminders before any remote or durable topology is introduced. 5. CI-safe agent-flow verification must use a deterministic in-repo runtime client as a first-class implementation of the same public contracts, not a mock or hand-wired test double. 6. Tests that require real `Codex`, `Claude Code`, or `GitHub Copilot` toolchains may run only when the corresponding toolchain is available; their absence must not weaken the provider-independent baseline. @@ -43,16 +44,20 @@ flowchart LR Ui["DotPilot Uno UI host"] Core["DotPilot.Core"] Runtime["DotPilot.Runtime"] + Host["DotPilot.Runtime.Host"] TestClient["Deterministic test client"] ProviderChecks["Conditional provider checks"] Future["Future Orleans + Agent Framework slices"] Ui --> Core Ui --> Runtime + Ui --> Host + Host --> Core Runtime --> TestClient Runtime --> ProviderChecks Future --> Core Future --> Runtime + Future --> Host ``` ## Alternatives Considered @@ -83,6 +88,7 @@ CI does not guarantee those toolchains, so the repo would lose an honest agent-f - Future slices can land without merging unrelated feature logic into shared buckets. - Contracts for `#12` become reusable across UI, runtime, and tests. - CI keeps a real provider-independent verification path through the deterministic runtime client. +- The embedded Orleans host can evolve without leaking server-only dependencies into browserwasm or the presentation project. ### Negative diff --git a/docs/Architecture.md b/docs/Architecture.md index 63a80de..71420fa 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -10,7 +10,7 @@ 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, and public slice interfaces; [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider-independent runtime implementations such as the deterministic test client, toolchain probing, and future embedded-host integration points. +- **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. - **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. @@ -18,9 +18,9 @@ This file is the required start-here architecture map for non-trivial tasks. ## Scoping -- **In scope for the current repository state:** the Uno workbench shell, the new `DotPilot.Core` and `DotPilot.Runtime` libraries, the runtime-foundation slice, and the automated validation boundaries around them. -- **In scope for future implementation:** embedded Orleans hosting, `Microsoft Agent Framework`, provider adapters, persistence, telemetry, evaluation, Git tooling, and local runtimes. -- **Out of scope in the current slice:** full Orleans hosting, live provider execution, remote workers, and cloud-only control-plane services. +- **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. +- **Out of scope in the current slice:** remote workers, remote clustering, external durable storage providers, and cloud-only control-plane services. ## Diagrams @@ -39,6 +39,7 @@ flowchart LR Ui["DotPilot Uno UI host"] Core["DotPilot.Core contracts"] Runtime["DotPilot.Runtime services"] + Host["DotPilot.Runtime.Host Orleans silo"] Unit["DotPilot.Tests"] UiTests["DotPilot.UITests"] @@ -52,13 +53,17 @@ flowchart LR Root --> Ui Root --> Core Root --> Runtime + Root --> Host Root --> Unit Root --> UiTests Ui --> Core Ui --> Runtime + Ui --> Host + Host --> Core Unit --> Ui Unit --> Core Unit --> Runtime + Unit --> Host ``` ### Workbench foundation slice for epic #13 @@ -136,6 +141,7 @@ flowchart TD CommunicationSlice["DotPilot.Core/Features/RuntimeCommunication"] CoreSlice["DotPilot.Core/Features/RuntimeFoundation"] RuntimeSlice["DotPilot.Runtime/Features/RuntimeFoundation"] + HostSlice["DotPilot.Runtime.Host/Features/RuntimeFoundation"] UiSlice["DotPilot runtime panel + banner"] Epic --> Domain @@ -146,9 +152,12 @@ flowchart TD DomainSlice --> CommunicationSlice CommunicationSlice --> CoreSlice Comm --> CommunicationSlice - Host --> RuntimeSlice + Host --> HostSlice + HostSlice --> CoreSlice MAF --> RuntimeSlice + RuntimeSlice --> HostSlice CoreSlice --> UiSlice + HostSlice --> UiSlice RuntimeSlice --> UiSlice ``` @@ -164,6 +173,7 @@ flowchart LR TestClient["DeterministicAgentRuntimeClient"] Probe["ProviderToolchainProbe"] ToolchainProbe["ToolchainCommandProbe + provider profiles"] + EmbeddedHost["UseDotPilotEmbeddedRuntime + Orleans silo"] Contracts["Typed IDs + contracts"] Future["Future Orleans + Agent Framework integrations"] @@ -176,8 +186,10 @@ flowchart LR Catalog --> Contracts Toolchains --> ToolchainProbe Toolchains --> Contracts + App --> EmbeddedHost + EmbeddedHost --> Contracts Future --> Contracts - Future --> Catalog + Future --> EmbeddedHost ``` ## Navigation Index @@ -185,7 +197,7 @@ flowchart LR ### Planning and decision docs - `Solution governance` — [../AGENTS.md](../AGENTS.md) -- `Task plan` — [../vertical-slice-runtime-foundation.plan.md](../vertical-slice-runtime-foundation.plan.md) +- `Task plan` — [../issue-24-embedded-orleans-host.plan.md](../issue-24-embedded-orleans-host.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) @@ -193,12 +205,14 @@ flowchart LR - `Issue #14 feature doc` — [Toolchain Center](./Features/toolchain-center.md) - `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) ### Modules - `Production Uno app` — [../DotPilot/](../DotPilot/) - `Contracts and typed identifiers` — [../DotPilot.Core/](../DotPilot.Core/) - `Provider-independent runtime services` — [../DotPilot.Runtime/](../DotPilot.Runtime/) +- `Embedded Orleans runtime host` — [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) - `Unit and API-style tests` — [../DotPilot.Tests/](../DotPilot.Tests/) - `UI tests` — [../DotPilot.UITests/](../DotPilot.UITests/) - `Shared build and analyzer policy` — [../Directory.Build.props](../Directory.Build.props), [../Directory.Packages.props](../Directory.Packages.props), [../global.json](../global.json), and [../.editorconfig](../.editorconfig) @@ -215,6 +229,7 @@ flowchart LR - `Toolchain Center issue catalog` — [../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs](../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs) - `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) - `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) @@ -224,12 +239,15 @@ flowchart LR - `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) - `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) +- `Initial Orleans grains` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs) ## Dependency Rules - `DotPilot` owns XAML, routing, and startup composition only. - `DotPilot.Core` owns non-UI contracts and typed identifiers arranged by feature slice. - `DotPilot.Runtime` owns provider-independent runtime implementations and future integration seams, but not XAML or page logic. +- `DotPilot.Runtime.Host` owns the embedded Orleans silo, localhost clustering, in-memory runtime state, and initial grain implementations for desktop targets only. - `DotPilot.Tests` validates contracts, composition, deterministic runtime behavior, and conditional provider-availability checks through public boundaries. - `DotPilot.UITests` validates the visible workbench shell, runtime-foundation panel, and agent-builder flow through the browser-hosted UI. @@ -237,7 +255,7 @@ 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` starts with contracts, sequencing, deterministic runtime coverage, and UI exposure before live Orleans or provider integration. +- 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. - 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. @@ -251,5 +269,6 @@ flowchart LR - Editing the Uno app shell: [../DotPilot/AGENTS.md](../DotPilot/AGENTS.md) - Editing contracts: [../DotPilot.Core/AGENTS.md](../DotPilot.Core/AGENTS.md) - Editing runtime services: [../DotPilot.Runtime/AGENTS.md](../DotPilot.Runtime/AGENTS.md) +- Editing the embedded runtime host: [../DotPilot.Runtime.Host/AGENTS.md](../DotPilot.Runtime.Host/AGENTS.md) - Editing unit and API-style tests: [../DotPilot.Tests/AGENTS.md](../DotPilot.Tests/AGENTS.md) - Editing UI tests: [../DotPilot.UITests/AGENTS.md](../DotPilot.UITests/AGENTS.md) diff --git a/docs/Features/embedded-orleans-host.md b/docs/Features/embedded-orleans-host.md new file mode 100644 index 0000000..5be101e --- /dev/null +++ b/docs/Features/embedded-orleans-host.md @@ -0,0 +1,68 @@ +# Embedded Orleans Host + +## Summary + +Issue [#24](https://github.com/managedcode/dotPilot/issues/24) embeds the first Orleans silo into the desktop runtime path without polluting the Uno UI project or the browserwasm build. The first cut is intentionally local-first: `UseLocalhostClustering`, in-memory grain storage, and in-memory reminders only. + +## Scope + +### In Scope + +- a dedicated `DotPilot.Runtime.Host` class library for Orleans hosting +- Orleans grain interfaces and runtime-host contracts in `DotPilot.Core` +- initial Session, Workspace, Fleet, Policy, and Artifact grains +- desktop startup integration through the Uno host builder +- automated tests for lifecycle, grain round-trips, mismatched keys, and in-memory volatility across restarts + +### Out Of Scope + +- remote clusters +- external durable storage providers +- Agent Framework orchestration +- UI redesign around the runtime host + +## Flow + +```mermaid +flowchart LR + App["DotPilot/App.xaml.cs"] + HostExt["UseDotPilotEmbeddedRuntime()"] + Silo["Embedded Orleans silo"] + Store["In-memory grain storage + reminders"] + Grains["Session / Workspace / Fleet / Policy / Artifact grains"] + Contracts["DotPilot.Core runtime-host contracts"] + + App --> HostExt + HostExt --> Silo + Silo --> Store + Silo --> Grains + Grains --> Contracts +``` + +## Design Notes + +- The app references `DotPilot.Runtime.Host` only on non-browser targets so `DotPilot.UITests` and the browserwasm build do not carry the server-only Orleans host. +- `DotPilot.Core` owns the grain interfaces plus the `EmbeddedRuntimeHostSnapshot` contract. +- `DotPilot.Runtime.Host` owns: + - Orleans host configuration + - host lifecycle catalog state + - grain implementations +- The initial cluster configuration is intentionally local: + - `UseLocalhostClustering` + - named in-memory grain storage + - in-memory reminders +- Runtime DTOs used by Orleans grain calls now carry Orleans serializer metadata so the grain contract surface is actually serialization-safe instead of only being plain records. + +## Verification + +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~EmbeddedRuntimeHost` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` +- `dotnet test DotPilot.slnx` + +## References + +- [Architecture Overview](../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) +- [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/issue-24-embedded-orleans-host.plan.md b/issue-24-embedded-orleans-host.plan.md new file mode 100644 index 0000000..b658945 --- /dev/null +++ b/issue-24-embedded-orleans-host.plan.md @@ -0,0 +1,99 @@ +## Goal + +Implement issue `#24` by embedding a local-first Orleans silo into the Uno desktop host, using `UseLocalhostClustering` plus in-memory grain storage and reminders, while keeping the browser/UI-test path isolated from server-only Orleans dependencies. + +## Scope + +In scope: +- Add the minimum Orleans contracts and grain interfaces for the initial runtime host cut +- Add a dedicated runtime host class library for the embedded Orleans implementation +- Register the initial Session, Workspace, Fleet, Policy, and Artifact grains +- Integrate the embedded Orleans silo into the Uno desktop startup path only +- Expose enough runtime-host status to validate startup, shutdown, and configuration through tests and docs +- Update architecture/docs for the new runtime host boundary + +Out of scope: +- Agent Framework orchestration +- Remote clustering +- External durable storage providers +- Full UI work beyond existing runtime/readiness presentation needs + +## Constraints And Risks + +- The first Orleans cut must use `UseLocalhostClustering` and in-memory storage/reminders only. +- The Uno app must remain presentation-only; Orleans implementation must live in a separate DLL. +- Browserwasm and UI-test paths must stay green; server-only Orleans packages must not leak into the browser build. +- All validation must pass with `-warnaserror`. +- No mocks, fakes, or stubs in verification. + +## Testing Methodology + +- Add contract and runtime tests for Orleans host configuration, host lifecycle, and initial grain registration. +- Verify the app composition path through real DI/build boundaries rather than isolated helper tests only. +- Keep `DotPilot.UITests` in the final validation because browser builds must remain unaffected by the Orleans addition. +- Prove the host uses localhost clustering plus in-memory storage/reminders through caller-visible configuration or startup behavior, not just private constants. + +## Ordered Plan + +- [x] Confirm the correct backlog item and architecture boundary for Orleans hosting. +- [x] Record the Orleans local-host policy in governance before implementation. +- [x] Inspect current runtime contracts, startup composition, and test seams for the Orleans host insertion point. +- [x] Add or update the runtime-host feature contracts in `DotPilot.Core`. +- [x] Add a dedicated Orleans runtime host project with the minimum official Orleans package set and a local `AGENTS.md`. +- [x] Implement the embedded Orleans silo configuration with localhost clustering and in-memory storage/reminders. +- [x] Register the initial Session, Workspace, Fleet, Policy, and Artifact grains. +- [x] Integrate the Orleans host into the Uno desktop startup/composition path without affecting browserwasm. +- [x] Add or update automated tests for contracts, lifecycle, and composition. +- [x] Update `docs/Architecture.md` and the relevant feature/runtime docs with Mermaid diagrams and the runtime-host boundary. +- [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"` +- [ ] Commit the implementation and open a PR that uses GitHub closing references for `#24`. + +## 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 `60` unit tests and `22` UI tests. + +## Tracked Failing Tests + +- [x] `InitialGrainsReturnNullBeforeTheirFirstWrite` + - Symptom: Orleans `CodecNotFoundException` for `SessionDescriptor` + - Root cause: control-plane runtime DTOs were not annotated for Orleans serialization/code generation + - Fix status: resolved by adding Orleans serializer metadata to the domain contracts +- [x] `InitialGrainsRoundTripTheirDescriptorState` + - Symptom: Orleans `CodecNotFoundException` for compiler-generated `<>z__ReadOnlyArray` + - Root cause: collection-expression values stored in `IReadOnlyList` produced a compiler-internal runtime type that Orleans could not deep-copy + - Fix status: resolved by changing runtime-bound collection properties to array-backed contract fields +- [x] `SessionGrainRejectsDescriptorIdsThatDoNotMatchThePrimaryKey` + - Symptom: the same collection-copy failure masked the intended `ArgumentException` + - Root cause: serialization failed before the grain method body executed + - Fix status: resolved after the array-backed contract change +- [x] `SessionStateDoesNotSurviveHostRestartWhenUsingInMemoryStorage` + - Symptom: the same collection-copy failure blocked the in-memory restart assertion + - Root cause: serialization failed before persistence behavior could be exercised + - Fix status: resolved after the array-backed contract change + +## Final Validation Notes + +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` + - Passed with `0` warnings and `0` errors after the final regression-test update. +- `dotnet test DotPilot.slnx` + - Passed with `67` 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 a non-zero report after changing `ExcludeAssembliesWithoutSources` from `MissingAny` to `MissingAll`, which keeps mixed-source Orleans-generated assemblies measurable instead of dropping the whole report to zero. + - Latest report: `82.55%` line coverage and `50.39%` branch coverage overall, with `DotPilot.Runtime.Host` at `100%` line and `100%` branch coverage. + +## Done Criteria + +- Orleans hosting is implemented through a dedicated non-UI DLL and integrated into the desktop host. +- The host uses `UseLocalhostClustering` plus in-memory storage/reminders. +- The initial core grains are registered and reachable through real runtime tests. +- Browser/UI-test validation remains green. +- A PR is open with `Closes #24`. From 5be379760c0a4128474a2c1ebf0109b30d145c57 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 01:37:03 +0100 Subject: [PATCH 3/5] Implement embedded runtime epic --- AGENTS.md | 1 + Directory.Packages.props | 1 + .../RuntimeCommunicationProblemCode.cs | 3 + .../RuntimeCommunicationProblems.cs | 30 ++ .../EmbeddedRuntimeTrafficPolicyContracts.cs | 32 ++ .../RuntimeFoundation/IAgentRuntimeClient.cs | 5 + .../RuntimeFoundationIssues.cs | 2 + .../RuntimeSessionArchiveContracts.cs | 25 ++ .../EmbeddedRuntimeHostBuilderExtensions.cs | 1 + .../EmbeddedRuntimeHostNames.cs | 2 + .../EmbeddedRuntimeTrafficPolicy.cs | 128 ++++++ .../EmbeddedRuntimeTrafficPolicyCatalog.cs | 25 ++ DotPilot.Runtime/DotPilot.Runtime.csproj | 5 + .../AgentFrameworkRuntimeClient.cs | 404 ++++++++++++++++++ .../DeterministicAgentRuntimeClient.cs | 87 +--- .../DeterministicAgentTurnEngine.cs | 171 ++++++++ .../RuntimeFoundationCatalog.cs | 22 +- ...meFoundationServiceCollectionExtensions.cs | 29 ++ .../RuntimePersistenceOptions.cs | 19 + .../RuntimeSessionArchiveStore.cs | 123 ++++++ .../AgentFrameworkRuntimeClientTests.cs | 268 ++++++++++++ ...mbeddedRuntimeTrafficPolicyCatalogTests.cs | 69 +++ .../RuntimeFoundationCatalogTests.cs | 31 +- DotPilot/App.xaml.cs | 14 +- docs/Architecture.md | 45 +- docs/Features/embedded-orleans-host.md | 4 +- .../embedded-runtime-orchestration.md | 99 +++++ epic-12-embedded-runtime.plan.md | 45 +- 28 files changed, 1589 insertions(+), 101 deletions(-) create mode 100644 DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs create mode 100644 DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs create mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs create mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs create mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs create mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs create mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs create mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs create mode 100644 DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs create mode 100644 DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs create mode 100644 docs/Features/embedded-runtime-orchestration.md diff --git a/AGENTS.md b/AGENTS.md index 5321f18..fa50403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,7 @@ For this app: - 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 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 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 213b888..69c322f 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..07b5e6a --- /dev/null +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs @@ -0,0 +1,128 @@ +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 = " ==> "; + private const string ClientTargetMethods = "GetAsync, UpsertAsync"; + + 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; + var targetMethods = transition.Target == EmbeddedRuntimeHostNames.ClientSourceName + ? ClientTargetMethods + : string.Join(", ", transition.TargetMethods); + lines.Add( + string.Concat( + transition.Source, + arrow, + transition.Target, + " : ", + string.Join(", ", transition.SourceMethods), + " -> ", + 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..bbcd35d --- /dev/null +++ b/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs @@ -0,0 +1,404 @@ +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 CompletedReplayKind = "run-completed"; + private readonly IGrainFactory _grainFactory; + private readonly RuntimeSessionArchiveStore _archiveStore; + private readonly DeterministicAgentTurnEngine _turnEngine; + private readonly Workflow _workflow; + + public AgentFrameworkRuntimeClient(IGrainFactory grainFactory, RuntimeSessionArchiveStore archiveStore) + : this(grainFactory, archiveStore, TimeProvider.System) + { + } + + internal AgentFrameworkRuntimeClient( + IGrainFactory grainFactory, + RuntimeSessionArchiveStore archiveStore, + TimeProvider timeProvider) + { + _grainFactory = grainFactory; + _archiveStore = archiveStore; + _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(); + + var archive = await _archiveStore.LoadAsync(request.SessionId, cancellationToken); + if (archive is null) + { + return Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(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, + request.ApprovalState is ApprovalState.Approved ? ResumeReplayKind : PauseReplayKind, + 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() ?? []; + replay.Add( + new RuntimeSessionReplayEntry( + replayKind, + result.Summary, + result.NextPhase, + result.ApprovalState, + DateTimeOffset.UtcNow)); + if (result.NextPhase is SessionPhase.Execute or SessionPhase.Review or SessionPhase.Failed) + { + replay.Add( + new RuntimeSessionReplayEntry( + CompletedReplayKind, + result.Summary, + result.NextPhase, + result.ApprovalState, + DateTimeOffset.UtcNow)); + } + + var archive = new StoredRuntimeSessionArchive( + originalRequest.SessionId, + checkpoint?.SessionId ?? existingArchive?.WorkflowSessionId ?? originalRequest.SessionId.ToString(), + checkpoint?.CheckpointId, + originalRequest, + result.NextPhase, + result.ApprovalState, + DateTimeOffset.UtcNow, + replay, + result.ProducedArtifacts); + + await _archiveStore.SaveAsync(archive, cancellationToken); + await UpsertSessionStateAsync(originalRequest, result); + await UpsertArtifactsAsync(result.ProducedArtifacts); + } + + private async ValueTask UpsertSessionStateAsync(AgentTurnRequest request, AgentTurnResult result) + { + 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 = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + 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 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 9fec5b3..4ef86b7 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs @@ -1,5 +1,4 @@ using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeCommunication; using DotPilot.Core.Features.RuntimeFoundation; using ManagedCode.Communication; @@ -7,82 +6,34 @@ namespace DotPilot.Runtime.Features.RuntimeFoundation; public sealed class DeterministicAgentRuntimeClient : IAgentRuntimeClient { - private const string ApprovalKeyword = "approval"; - private const string DeterministicProviderDisplayName = "Deterministic Runtime Client"; - 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 ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken) + public DeterministicAgentRuntimeClient() + : this(TimeProvider.System) { - 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, - DeterministicProviderDisplayName))); - } + internal DeterministicAgentRuntimeClient(TimeProvider timeProvider) + { + _engine = new DeterministicAgentTurnEngine(timeProvider); + } - 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> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(_engine.Execute(request)); } - private static bool RequiresApproval(string prompt) + public ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken) { - return prompt.Contains(ApprovalKeyword, StringComparison.OrdinalIgnoreCase); + cancellationToken.ThrowIfCancellationRequested(); + _ = request; + return ValueTask.FromResult(Result.Fail(DotPilot.Core.Features.RuntimeCommunication.RuntimeCommunicationProblems.OrchestrationUnavailable())); } - private static ArtifactDescriptor CreateArtifact(SessionId sessionId, string artifactName, ArtifactKind artifactKind) + public ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken) { - return new ArtifactDescriptor - { - Id = ArtifactId.New(), - SessionId = sessionId, - Name = artifactName, - Kind = artifactKind, - RelativePath = artifactName, - CreatedAt = DateTimeOffset.UtcNow, - }; + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Result.Fail(DotPilot.Core.Features.RuntimeCommunication.RuntimeCommunicationProblems.SessionArchiveMissing(sessionId))); } } 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 7989231..010144a 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs @@ -7,7 +7,7 @@ namespace DotPilot.Runtime.Features.RuntimeFoundation; public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog { private const string EpicSummary = - "Issue #12 is staged into isolated contracts, communication, host, and orchestration slices so the Uno workbench can stay 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 DeterministicProbePrompt = "Summarize the runtime foundation readiness for a local-first session that may require approval."; private const string DeterministicClientStatusSummary = "Always available for in-repo and CI validation."; @@ -22,7 +22,13 @@ public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog "The Orleans host integration point is sequenced behind dedicated runtime contracts instead of being baked into page code."; 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 visualized through Orleans.Graph 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."; public RuntimeFoundationSnapshot GetSnapshot() { @@ -63,6 +69,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..d8811dd --- /dev/null +++ b/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs @@ -0,0 +1,268 @@ +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 ArchiveFileName = "archive.json"; + private const string ReplayFileName = "replay.md"; + + [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 == "run-completed" && entry.Phase == SessionPhase.Failed); + } + + [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 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 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 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/RuntimeFoundationCatalogTests.cs b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs index 2bf0d9c..9f66e84 100644 --- a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs +++ b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs @@ -17,12 +17,14 @@ public void CatalogGroupsEpicTwelveIntoFourSequencedSlices() var snapshot = catalog.GetSnapshot(); snapshot.EpicLabel.Should().Be(RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.EmbeddedAgentRuntimeHostEpic)); - snapshot.Slices.Should().HaveCount(4); + snapshot.Slices.Should().HaveCount(6); snapshot.Slices.Select(slice => slice.IssueNumber).Should().ContainInOrder( RuntimeFoundationIssues.DomainModel, RuntimeFoundationIssues.CommunicationContracts, RuntimeFoundationIssues.EmbeddedOrleansHost, - RuntimeFoundationIssues.AgentFrameworkRuntime); + RuntimeFoundationIssues.AgentFrameworkRuntime, + RuntimeFoundationIssues.GrainTrafficPolicy, + RuntimeFoundationIssues.SessionPersistence); } [Test] @@ -148,6 +150,31 @@ public async Task DeterministicClientReturnsProviderUnavailableProblemWhenProvid problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.ServiceUnavailable); } + [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(); + } + [TestCase(CodexCommandName)] [TestCase(ClaudeCommandName)] [TestCase(GitHubCommandName)] 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 71420fa..b0b7235 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 @@ -137,6 +137,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"] @@ -148,13 +150,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 @@ -170,26 +178,33 @@ flowchart LR ViewModels["MainViewModel + SecondViewModel + SettingsViewModel"] Catalog["RuntimeFoundationCatalog"] Toolchains["ToolchainCenterCatalog"] - TestClient["DeterministicAgentRuntimeClient"] + BrowserClient["DeterministicAgentRuntimeClient"] + DesktopClient["AgentFrameworkRuntimeClient"] + Archive["RuntimeSessionArchiveStore"] + Traffic["EmbeddedRuntimeTrafficPolicyCatalog"] Probe["ProviderToolchainProbe"] 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 --> Probe 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 @@ -197,7 +212,7 @@ flowchart LR ### Planning and decision docs - `Solution governance` — [../AGENTS.md](../AGENTS.md) -- `Task plan` — [../issue-24-embedded-orleans-host.plan.md](../issue-24-embedded-orleans-host.plan.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) @@ -206,6 +221,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 @@ -230,6 +246,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) @@ -238,8 +256,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 @@ -256,6 +279,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 index b002c19..c2b81ba 100644 --- a/epic-12-embedded-runtime.plan.md +++ b/epic-12-embedded-runtime.plan.md @@ -7,7 +7,7 @@ Implement epic `#12` on one delivery branch by covering its direct child issues 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 using `ManagedCode.Orleans.Graph` +- 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 @@ -24,6 +24,7 @@ Out of scope: - 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 @@ -39,19 +40,19 @@ Out of scope: ## Ordered Plan -- [ ] Confirm the exact direct-child issue set for epic `#12` and keep unrelated issues out of the PR scope. -- [ ] Add or restore the embedded Orleans host slice from the cleanest available implementation path for issue `#24`. -- [ ] Add the minimum runtime dependencies and contracts for Microsoft Agent Framework orchestration for issue `#25`. -- [ ] Implement the first orchestration runtime path on top of the deterministic runtime flow and Orleans-backed runtime boundaries. -- [ ] Add explicit grain traffic policy modeling and enforcement for issue `#26`, including runtime-visible policy information and denial behavior. -- [ ] Add local-first session persistence, replay, checkpointing, and resume for issue `#27` without changing Orleans clustering/storage topology. -- [ ] Update runtime docs, feature docs, ADR references, and architecture diagrams so the epic boundaries and flows are explicit. -- [ ] Add or update automated tests for every covered issue: +- [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 -- [ ] Run the full repo validation sequence: +- [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` @@ -68,6 +69,14 @@ Out of scope: ## 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 @@ -76,3 +85,19 @@ Out of scope: - 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 From c00b07c169c737c57568714b5247e1b2ea337fdd Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 01:37:56 +0100 Subject: [PATCH 4/5] Mark embedded runtime epic plan complete --- epic-12-embedded-runtime.plan.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/epic-12-embedded-runtime.plan.md b/epic-12-embedded-runtime.plan.md index c2b81ba..f7c8ff4 100644 --- a/epic-12-embedded-runtime.plan.md +++ b/epic-12-embedded-runtime.plan.md @@ -57,7 +57,7 @@ Out of scope: - `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"` -- [ ] Commit the epic branch implementation and open one PR that closes epic `#12` and its covered child issues correctly. +- [x] Commit the epic branch implementation and open one PR that closes epic `#12` and its covered child issues correctly. ## Full-Test Baseline @@ -101,3 +101,5 @@ Out of scope: - `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`. From a6ca45329dc1bdfc8039447f47f1a0fecb91e983 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 02:14:05 +0100 Subject: [PATCH 5/5] Fix PR #81 review follow-ups --- .../EmbeddedRuntimeTrafficPolicy.cs | 6 +- .../AgentFrameworkRuntimeClient.cs | 61 +++++++++-- .../RuntimeFoundationCatalog.cs | 2 +- .../AgentFrameworkRuntimeClientTests.cs | 103 ++++++++++++++++++ .../RuntimeFoundationCatalogTests.cs | 5 + 5 files changed, 162 insertions(+), 15 deletions(-) diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs index 07b5e6a..85494bd 100644 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs +++ b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs @@ -9,7 +9,6 @@ internal static class EmbeddedRuntimeTrafficPolicy private const string MermaidHeader = "flowchart LR"; private const string MermaidArrow = " --> "; private const string MermaidActiveArrow = " ==> "; - private const string ClientTargetMethods = "GetAsync, UpsertAsync"; public static string Summary => PolicySummary; @@ -109,9 +108,6 @@ private static string CreateMermaidDiagramCore((string Source, string Target, st transition.SourceMethods.Contains(activeTransition.Value.SourceMethod, StringComparer.Ordinal) && transition.TargetMethods.Contains(activeTransition.Value.TargetMethod, StringComparer.Ordinal); var arrow = isActive ? MermaidActiveArrow : MermaidArrow; - var targetMethods = transition.Target == EmbeddedRuntimeHostNames.ClientSourceName - ? ClientTargetMethods - : string.Join(", ", transition.TargetMethods); lines.Add( string.Concat( transition.Source, @@ -120,7 +116,7 @@ private static string CreateMermaidDiagramCore((string Source, string Target, st " : ", string.Join(", ", transition.SourceMethods), " -> ", - targetMethods)); + string.Join(", ", transition.TargetMethods))); } return string.Join(Environment.NewLine, lines); diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs b/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs index bbcd35d..bba90f6 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs @@ -18,11 +18,17 @@ public sealed class AgentFrameworkRuntimeClient : IAgentRuntimeClient 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) @@ -36,6 +42,7 @@ internal AgentFrameworkRuntimeClient( { _grainFactory = grainFactory; _archiveStore = archiveStore; + _timeProvider = timeProvider; _turnEngine = new DeterministicAgentTurnEngine(timeProvider); _workflow = BuildWorkflow(); } @@ -81,12 +88,26 @@ public async ValueTask> ResumeAsync(AgentTurnResumeReque { cancellationToken.ThrowIfCancellationRequested(); - var archive = await _archiveStore.LoadAsync(request.SessionId, cancellationToken); + 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)); @@ -115,7 +136,7 @@ await PersistRuntimeStateAsync( archive.OriginalRequest, result, resolvedCheckpoint, - request.ApprovalState is ApprovalState.Approved ? ResumeReplayKind : PauseReplayKind, + ResolveResumeReplayKind(request.ApprovalState), cancellationToken); return Result.Succeed(result); @@ -232,13 +253,14 @@ private async ValueTask PersistRuntimeStateAsync( { 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, - DateTimeOffset.UtcNow)); + recordedAt)); if (result.NextPhase is SessionPhase.Execute or SessionPhase.Review or SessionPhase.Failed) { replay.Add( @@ -247,7 +269,7 @@ private async ValueTask PersistRuntimeStateAsync( result.Summary, result.NextPhase, result.ApprovalState, - DateTimeOffset.UtcNow)); + recordedAt)); } var archive = new StoredRuntimeSessionArchive( @@ -257,16 +279,16 @@ private async ValueTask PersistRuntimeStateAsync( originalRequest, result.NextPhase, result.ApprovalState, - DateTimeOffset.UtcNow, + recordedAt, replay, result.ProducedArtifacts); await _archiveStore.SaveAsync(archive, cancellationToken); - await UpsertSessionStateAsync(originalRequest, result); + await UpsertSessionStateAsync(originalRequest, result, recordedAt); await UpsertArtifactsAsync(result.ProducedArtifacts); } - private async ValueTask UpsertSessionStateAsync(AgentTurnRequest request, AgentTurnResult result) + private async ValueTask UpsertSessionStateAsync(AgentTurnRequest request, AgentTurnResult result, DateTimeOffset timestamp) { var session = new SessionDescriptor { @@ -276,8 +298,8 @@ private async ValueTask UpsertSessionStateAsync(AgentTurnRequest request, AgentT Phase = result.NextPhase, ApprovalState = result.ApprovalState, AgentProfileIds = [request.AgentProfileId], - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, + CreatedAt = timestamp, + UpdatedAt = timestamp, }; await _grainFactory.GetGrain(request.SessionId.ToString()).UpsertAsync(session); @@ -352,6 +374,27 @@ await context.QueueStateUpdateAsync( 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, diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs index 010144a..11a6164 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs @@ -25,7 +25,7 @@ public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog "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 visualized through Orleans.Graph instead of hidden conventions."; + "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."; diff --git a/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs index d8811dd..8436f49 100644 --- a/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs +++ b/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs @@ -9,8 +9,10 @@ public sealed class AgentFrameworkRuntimeClientTests 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() @@ -96,9 +98,41 @@ public async Task ResumeAsyncPersistsRejectedApprovalAsFailedReplay() 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() { @@ -133,6 +167,52 @@ public async Task GetSessionArchiveAsyncReturnsCorruptionProblemForInvalidArchiv 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() { @@ -212,6 +292,24 @@ private static AgentTurnRequest CreateRequest(string prompt, AgentExecutionMode 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( @@ -248,6 +346,11 @@ private static int GetFreeTcpPort() } } +internal sealed class FixedTimeProvider(DateTimeOffset timestamp) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => timestamp; +} + internal sealed class TemporaryRuntimePersistenceDirectory : IDisposable { public TemporaryRuntimePersistenceDirectory() diff --git a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs index 9f66e84..81eb999 100644 --- a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs +++ b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs @@ -25,6 +25,11 @@ public void CatalogGroupsEpicTwelveIntoFourSequencedSlices() RuntimeFoundationIssues.AgentFrameworkRuntime, RuntimeFoundationIssues.GrainTrafficPolicy, RuntimeFoundationIssues.SessionPersistence); + snapshot.Slices.Single(slice => slice.IssueNumber == RuntimeFoundationIssues.GrainTrafficPolicy) + .Summary + .Should() + .Contain("Mermaid") + .And.NotContain("Orleans.Graph"); } [Test]