diff --git a/.claude/commands/spec.md b/.claude/commands/spec.md index 4b31a031..c236589c 100644 --- a/.claude/commands/spec.md +++ b/.claude/commands/spec.md @@ -72,12 +72,14 @@ When the user signals they're ready: 1. Re-read the spec file with the Read tool. 2. Collect all `> **Review:** ...` markers and note any direct edits. -3. If any review comment is ambiguous or requires a design decision, - ask clarifying questions first (again, all at once). - **PAUSE if you asked questions — wait for answers before editing.** -4. Update the spec: apply changes, resolve review markers by removing them - or incorporating the feedback, and update Open Questions accordingly. -5. Tell the user what changed and invite another review pass. +3. Address review comments **one at a time** in document order: + a. Present your analysis of the comment — the trade-offs, your + recommendation, and why. + b. **PAUSE — wait for the user's decision before editing.** + c. Update the spec to reflect the resolved decision; remove the + review marker. + d. Tell the user what changed, then move to the next comment. +4. After all comments are resolved, invite another review pass. Repeat Phase 3 until the user says the document is ready. @@ -117,16 +119,29 @@ When Phase 4 is complete: - Exit criteria as a checkbox list; for tasks that include new E2E tests, write those exit criteria as Gherkin-style acceptance scenarios (`Given / When / Then`) -3. Save the updated spec and ask the user to review the task breakdown. +3. If the spec has a `## Related Epics` section listing features to be + spec'd separately, add those as placeholder entries in `## Tasks` as + well — titled "Create epic: \" with a one-line scope description. + These will become Jira epics (not tasks) in step 5. +4. Save the updated spec and ask the user to review the task breakdown. **PAUSE — wait for approval or change requests. Apply any changes before proceeding.** -4. Create a Jira task for each item. If the user provided an epic key, - assign it as the parent of all tasks. If not, create tasks without a - parent — the parent can be added later. -5. Update the `## Tasks` section: replace each task title with a hyperlink +5. Create Jira issues for each item: + - For tasks: create as Task issues. If the user provided an epic key, + assign it as the parent. If not, create without a parent. + - For "Create epic" placeholder items: create as Epic issues (no parent). + Use the scope description as the epic summary. +6. Update the `## Tasks` section: replace each item title with a hyperlink to its Jira ticket. Keep all descriptions and exit criteria in place. The section remains in the spec permanently — future agents may not have Jira access. +7. Update the `## Related Epics` table with the Jira keys assigned to each + related epic in step 5. +8. Update the Jira epic's description with a concise summary of the + finalized design decisions from the spec. The original description + typically contains early design thoughts that are now superseded; replace + it with a brief overview and a bulleted list of the key decisions and + their outcomes. Link to the spec file in the repo. --- @@ -177,6 +192,18 @@ Planned classes, their roles, and important relationships. How data moves through the feature from trigger to output. +## Related Epics + +Features identified during spec drafting that are out of scope here and will +be spec'd separately. Each row becomes a Jira epic in Phase 5. + +| Epic | Scope | +|------|-------| +| (this epic) | ... | +| ADR-XXX | ... | + +_(Omit this section if there are no related epics to create.)_ + ## Open Questions - [ ] Unresolved question (carry forward any unresolved TBDs from above) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f3705f46..f5a19364 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "mcp__jira__getJiraIssue", "Bash(xargs:*)", "mcp__jira__createJiraIssue", - "mcp__jira__editJiraIssue" + "mcp__jira__editJiraIssue", + "mcp__jira__getJiraProjectIssueTypesMetadata" ] } } diff --git a/AdaptiveRemote.sln b/AdaptiveRemote.sln index a63a24a4..61c872f4 100644 --- a/AdaptiveRemote.sln +++ b/AdaptiveRemote.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 18.0.11217.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.App", "src\AdaptiveRemote.App\AdaptiveRemote.App.csproj", "{6C7C380B-D7A4-412E-8487-2AFC89EA802F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Contracts", "src\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj", "{F81FEF3B-DB7A-4C04-9DF0-72E98382097A}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdaptiveRemote", "src\AdaptiveRemote\AdaptiveRemote.csproj", "{7BE31162-0D09-4F80-8CE5-978F7AECC1EF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Console", "src\AdaptiveRemote.Console\AdaptiveRemote.Console.csproj", "{345B73FC-07F9-490F-B566-2677D10B1834}" @@ -46,6 +48,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.EndToEndTest EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.EndToEndTests.Host.Wpf", "test\AdaptiveRemote.EndToEndTests.Host.Wpf\AdaptiveRemote.EndToEndTests.Host.Wpf.csproj", "{54522D5A-CEB3-F5B9-2654-1005EF1C3262}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.CompiledLayoutService", "src\AdaptiveRemote.Backend.CompiledLayoutService\AdaptiveRemote.Backend.CompiledLayoutService.csproj", "{ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.ApiTests", "test\AdaptiveRemote.Backend.ApiTests\AdaptiveRemote.Backend.ApiTests.csproj", "{E581823B-8EA9-4C54-A05E-859632CE1B78}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -68,6 +78,18 @@ Global {6C7C380B-D7A4-412E-8487-2AFC89EA802F}.Release|x64.Build.0 = Release|Any CPU {6C7C380B-D7A4-412E-8487-2AFC89EA802F}.Release|x86.ActiveCfg = Release|Any CPU {6C7C380B-D7A4-412E-8487-2AFC89EA802F}.Release|x86.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x64.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.ActiveCfg = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Debug|x86.Build.0 = Debug|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|Any CPU.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x64.Build.0 = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.ActiveCfg = Release|Any CPU + {F81FEF3B-DB7A-4C04-9DF0-72E98382097A}.Release|x86.Build.0 = Release|Any CPU {7BE31162-0D09-4F80-8CE5-978F7AECC1EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7BE31162-0D09-4F80-8CE5-978F7AECC1EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {7BE31162-0D09-4F80-8CE5-978F7AECC1EF}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -188,6 +210,30 @@ Global {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x64.Build.0 = Release|Any CPU {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x86.ActiveCfg = Release|Any CPU {54522D5A-CEB3-F5B9-2654-1005EF1C3262}.Release|x86.Build.0 = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x64.Build.0 = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Debug|x86.Build.0 = Debug|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|Any CPU.Build.0 = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x64.ActiveCfg = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x64.Build.0 = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x86.ActiveCfg = Release|Any CPU + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF}.Release|x86.Build.0 = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x64.ActiveCfg = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x64.Build.0 = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x86.ActiveCfg = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Debug|x86.Build.0 = Debug|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|Any CPU.Build.0 = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x64.ActiveCfg = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x64.Build.0 = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x86.ActiveCfg = Release|Any CPU + {E581823B-8EA9-4C54-A05E-859632CE1B78}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -202,6 +248,8 @@ Global {72062D2E-6FDF-42F8-8360-98130E2A9861} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} {F631ED02-DB0B-4CE4-8462-89BA239AFB3A} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} {54522D5A-CEB3-F5B9-2654-1005EF1C3262} = {CC3DAD92-6D91-40F5-B57A-C5620CF4F1C7} + {ADEA7AD3-C614-4280-A6BA-DE412C4D6FBF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E581823B-8EA9-4C54-A05E-859632CE1B78} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {556A11E4-2F89-4600-9831-8162F067EC3E} diff --git a/backend.slnf b/backend.slnf new file mode 100644 index 00000000..db1add99 --- /dev/null +++ b/backend.slnf @@ -0,0 +1,10 @@ +{ + "solution": { + "path": "AdaptiveRemote.sln", + "projects": [ + "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj", + "src\\AdaptiveRemote.Backend.CompiledLayoutService\\AdaptiveRemote.Backend.CompiledLayoutService.csproj", + "test\\AdaptiveRemote.Backend.ApiTests\\AdaptiveRemote.Backend.ApiTests.csproj" + ] + } +} diff --git a/client.slnf b/client.slnf new file mode 100644 index 00000000..1991596c --- /dev/null +++ b/client.slnf @@ -0,0 +1,19 @@ +{ + "solution": { + "path": "AdaptiveRemote.sln", + "projects": [ + "src\\AdaptiveRemote.Contracts\\AdaptiveRemote.Contracts.csproj", + "src\\AdaptiveRemote.App\\AdaptiveRemote.App.csproj", + "src\\AdaptiveRemote\\AdaptiveRemote.csproj", + "src\\AdaptiveRemote.Console\\AdaptiveRemote.Console.csproj", + "src\\AdaptiveRemote.Headless\\AdaptiveRemote.Headless.csproj", + "test\\AdaptiveRemote.App.Tests\\AdaptiveRemote.App.Tests.csproj", + "test\\AdaptiveRemote.Speech.Tests\\AdaptiveRemote.Speech.Tests.csproj", + "test\\AdaptiveRemote.EndtoEndTests.TestServices\\AdaptiveRemote.EndtoEndTests.TestServices.csproj", + "test\\AdaptiveRemote.EndToEndTests.Steps\\AdaptiveRemote.EndToEndTests.Steps.csproj", + "test\\AdaptiveRemote.EndToEndTests.Host.Headless\\AdaptiveRemote.EndToEndTests.Host.Headless.csproj", + "test\\AdaptiveRemote.EndToEndTests.Host.Wpf\\AdaptiveRemote.EndToEndTests.Host.Wpf.csproj", + "test\\AdaptiveRemote.EndtoEndTests.Host.Console\\AdaptiveRemote.EndToEndTests.Host.Console.csproj" + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8060fb6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + compiledlayoutservice: + build: + context: . + dockerfile: src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + networks: + - backend + +networks: + backend: + driver: bridge diff --git a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj index 396f43ff..e104217b 100644 --- a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj +++ b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj new file mode 100644 index 00000000..b83f9e8f --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + AdaptiveRemote.Backend.CompiledLayoutService + + + + + + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile b/src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile new file mode 100644 index 00000000..9c700ab7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj files and restore +COPY ["src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj", "AdaptiveRemote.Backend.CompiledLayoutService/"] +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] +RUN dotnet restore "AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj" + +# Copy source and build +COPY ["src/AdaptiveRemote.Contracts/", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.CompiledLayoutService/", "AdaptiveRemote.Backend.CompiledLayoutService/"] +WORKDIR "/src/AdaptiveRemote.Backend.CompiledLayoutService" +RUN dotnet build "AdaptiveRemote.Backend.CompiledLayoutService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "AdaptiveRemote.Backend.CompiledLayoutService.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AdaptiveRemote.Backend.CompiledLayoutService.dll"] diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs new file mode 100644 index 00000000..7a69c4a7 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/HealthEndpoints.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; + +public static class HealthEndpoints +{ + public static void MapHealthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/health", GetHealth) + .WithName(nameof(GetHealth)) + .Produces(StatusCodes.Status200OK); + } + + private static IResult GetHealth(ILogger logger) + { + logger.HealthCheckRequested(); + + string? version = Assembly.GetExecutingAssembly() + .GetCustomAttribute() + ?.InformationalVersion ?? "unknown"; + + HealthResponse response = new HealthResponse( + ServiceName: "CompiledLayoutService", + Version: version, + Status: "healthy" + ); + + logger.HealthCheckSuccessful(); + + return Results.Ok(response); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs new file mode 100644 index 00000000..665016a3 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs @@ -0,0 +1,38 @@ +using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; + +public static class LayoutEndpoints +{ + public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/layouts/compiled/active", GetActiveLayout) + .WithName(nameof(GetActiveLayout)) + .Produces(StatusCodes.Status200OK); + } + + private static async Task GetActiveLayout( + ILogger logger, + ICompiledLayoutRepository repository, + CancellationToken cancellationToken) + { + logger.GetActiveLayoutRequested(); + + // For MVP, we use a hardcoded userId. Auth will provide real userId in ADR-168. + string userId = "mvp-user"; + CompiledLayout? layout = await repository.GetActiveForUserAsync(userId, cancellationToken); + + if (layout == null) + { + return Results.NotFound(); + } + + logger.ReturningActiveLayout(layout.Id); + + // Use the LayoutContractsJsonContext for serialization + return Results.Json( + layout, + LayoutContractsJsonContext.Default.CompiledLayout); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs new file mode 100644 index 00000000..6f926c09 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Logging; + +/// +/// Centralized logging messages for CompiledLayoutService. +/// All log messages MUST be defined here as [LoggerMessage] source-generated methods. +/// Event ID ranges: +/// 1100-1199: CompiledLayoutService +/// +public static partial class MessageLogger +{ + [LoggerMessage(EventId = 1100, Level = LogLevel.Information, Message = "CompiledLayoutService starting")] + public static partial void ServiceStarting(this ILogger logger); + + [LoggerMessage(EventId = 1101, Level = LogLevel.Information, Message = "CompiledLayoutService started successfully on {ListenAddress}")] + public static partial void ServiceStarted(this ILogger logger, string listenAddress); + + [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received")] + public static partial void GetActiveLayoutRequested(this ILogger logger); + + [LoggerMessage(EventId = 1103, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] + public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); + + [LoggerMessage(EventId = 1104, Level = LogLevel.Information, Message = "GET /health request received")] + public static partial void HealthCheckRequested(this ILogger logger); + + [LoggerMessage(EventId = 1105, Level = LogLevel.Information, Message = "Health check successful")] + public static partial void HealthCheckSuccessful(this ILogger logger); + + [LoggerMessage(EventId = 1106, Level = LogLevel.Error, Message = "Error retrieving active layout for userId={UserId}")] + public static partial void ErrorRetrievingActiveLayout(this ILogger logger, string userId, Exception exception); + + [LoggerMessage(EventId = 1107, Level = LogLevel.Error, Message = "Error processing health check request")] + public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs new file mode 100644 index 00000000..0c642748 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -0,0 +1,29 @@ +using AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; +using AdaptiveRemote.Backend.CompiledLayoutService.Logging; +using AdaptiveRemote.Backend.CompiledLayoutService.Services; +using AdaptiveRemote.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Register services +builder.Services.AddSingleton(); + +WebApplication app = builder.Build(); + +ILogger logger = app.Services.GetRequiredService>(); +logger.ServiceStarting(); + +// Map endpoints +app.MapHealthEndpoints(); +app.MapLayoutEndpoints(); + +string listenAddress = app.Urls.FirstOrDefault() ?? "http://localhost:5000"; +logger.ServiceStarted(listenAddress); + +app.Run(); + +// Make Program visible for testing +public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs new file mode 100644 index 00000000..2fca7dc8 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Services/HardcodedLayoutProvider.cs @@ -0,0 +1,89 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.CompiledLayoutService.Services; + +/// +/// Hardcoded implementation of ICompiledLayoutRepository for ADR-167 Static layout MVP. +/// Returns a fixed layout matching the current StaticCommandGroupProvider. +/// Will be replaced with real DynamoDB storage in ADR-173. +/// +public class HardcodedLayoutProvider : ICompiledLayoutRepository +{ + // Hardcoded layout ID and user ID for MVP + private static readonly Guid LayoutId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private static readonly Guid RawLayoutId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + + public Task GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + List elements = new List + { + new LayoutGroupDefinitionDto( + CssId: "DPAD", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "UP"), + new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "DOWN"), + new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "LEFT"), + new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "RIGHT"), + new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "SELECT"), + new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "BACK"), + new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "POWER"), + new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "POWERON"), + new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "POWEROFF"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "WELL", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TIVO"), + new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "NETFLIX"), + new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "GUIDE"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "PLAYBACK", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "PLAY"), + new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "PAUSE"), + new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "RECORD"), + new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "SKIP"), + new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "REPLAY"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "CHANNELANDVOLUME", + Children: new List + { + new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "CHANNELUP"), + new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "CHANNELDOWN"), + new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VOLUMEUP"), + new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VOLUMEDOWN"), + new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "MUTE"), + }.AsReadOnly() + ), + new LayoutGroupDefinitionDto( + CssId: "GUTTER", + Children: new List + { + new CommandDefinitionDto(CommandType.Lifecycle, "Learn", "Learn", null, "Sent Learn", null, "LEARN"), + new CommandDefinitionDto(CommandType.Lifecycle, "Exit", "Exit", null, "Goodbye", null, "EXIT"), + }.AsReadOnly() + ), + }; + + CompiledLayout layout = new CompiledLayout( + Id: LayoutId, + RawLayoutId: RawLayoutId, + UserId: userId, + IsActive: true, + Version: 1, + Elements: elements.AsReadOnly(), + CssDefinitions: "/* Placeholder CSS - real CSS generation in ADR-171 */", + CompiledAt: DateTimeOffset.UtcNow + ); + + return Task.FromResult(layout); + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json new file mode 100644 index 00000000..34f00ef1 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj b/src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj new file mode 100644 index 00000000..d804afe1 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + AdaptiveRemote.Contracts + + + diff --git a/src/AdaptiveRemote.Contracts/CommandType.cs b/src/AdaptiveRemote.Contracts/CommandType.cs new file mode 100644 index 00000000..5ea8a4f9 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/CommandType.cs @@ -0,0 +1,9 @@ +namespace AdaptiveRemote.Contracts; + +// Identifies the runtime command type. The client uses this to instantiate the correct +// App runtime type (TiVoCommand, IRCommand, LifecycleCommand). +// Type-specific execution parameters are resolved by the client from its own configuration: +// TiVo — CommandId = Name.ToUpperInvariant() (existing convention) +// IR — payload programmed via remote, stored in ProgrammaticSettings +// Others — keyed by Name +public enum CommandType { Lifecycle, TiVo, IR } diff --git a/src/AdaptiveRemote.Contracts/HealthResponse.cs b/src/AdaptiveRemote.Contracts/HealthResponse.cs new file mode 100644 index 00000000..bc9da912 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/HealthResponse.cs @@ -0,0 +1,10 @@ +namespace AdaptiveRemote.Contracts; + +/// +/// Standard health check response for all backend services. +/// +public record HealthResponse( + string ServiceName, + string Version, + string Status +); diff --git a/src/AdaptiveRemote.Contracts/ICommandProperties.cs b/src/AdaptiveRemote.Contracts/ICommandProperties.cs new file mode 100644 index 00000000..51d7f273 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ICommandProperties.cs @@ -0,0 +1,14 @@ +namespace AdaptiveRemote.Contracts; + +// Shared behavioral interface — prevents drift between the compiled and raw command types. +// Adding a new behavioral property means updating this interface first; the compiler +// will flag any implementing record that doesn't follow. +public interface ICommandProperties +{ + CommandType Type { get; } + string Name { get; } + string Label { get; } + string? Glyph { get; } + string SpeakPhrase { get; } + string? Reverse { get; } +} diff --git a/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs new file mode 100644 index 00000000..80cd7f7d --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ICompiledLayoutRepository.cs @@ -0,0 +1,12 @@ +namespace AdaptiveRemote.Contracts; + +/// +/// Repository interface for compiled layout storage and retrieval. +/// +public interface ICompiledLayoutRepository +{ + /// + /// Gets the active compiled layout for the specified user. + /// + Task GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default); +} diff --git a/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs new file mode 100644 index 00000000..b165db23 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/LayoutContractsJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Contracts; + +// Source-generated JSON context — required for Native AOT Lambda functions; +// shared by all consumers to ensure consistent serialization behaviour. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(RawLayout))] +[JsonSerializable(typeof(CompiledLayout))] +[JsonSerializable(typeof(PreviewLayout))] +[JsonSerializable(typeof(ValidationResult))] +[JsonSerializable(typeof(HealthResponse))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +public partial class LayoutContractsJsonContext : JsonSerializerContext { } diff --git a/src/AdaptiveRemote.Contracts/LayoutElementDto.cs b/src/AdaptiveRemote.Contracts/LayoutElementDto.cs new file mode 100644 index 00000000..92cf7d51 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/LayoutElementDto.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Contracts; + +// --------------------------------------------------------------------------- +// Compiled layout element DTOs +// Used in CompiledLayout.Elements. Deserialized directly by the client application. +// Contains only behavioral properties — grid positions and CSS overrides have been +// compiled into CssDefinitions and are not needed by the client. +// --------------------------------------------------------------------------- + +// "$type" avoids conflict with the behavioral Type property on CommandDefinitionDto. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(CommandDefinitionDto), "command")] +[JsonDerivedType(typeof(LayoutGroupDefinitionDto), "group")] +public abstract record LayoutElementDto(string CssId); + +// Maps to AdaptiveRemote.App.Models.Command at layout-apply time (client epic). +// Type carries the CommandType discriminator so the client knows which runtime type to instantiate. +// No subtype hierarchy is used — all behavioral properties are flat; type-specific execution +// parameters are resolved by the client from its own configuration (see CommandType above). +public record CommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId +) : LayoutElementDto(CssId), ICommandProperties; + +// Maps to AdaptiveRemote.App.Models.LayoutGroup at layout-apply time (client epic). +public record LayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children +) : LayoutElementDto(CssId); + +// --------------------------------------------------------------------------- +// Client-consumable format produced by LayoutCompilerService. +// Deserialized directly by the client application — no intermediate parsing model needed. +// The client maps Elements → runtime Command objects at layout-apply time (client epic). +// --------------------------------------------------------------------------- + +public record CompiledLayout( + Guid Id, + Guid RawLayoutId, + string UserId, + bool IsActive, + int Version, + IReadOnlyList Elements, + string CssDefinitions, // global CSS for the layout grid + DateTimeOffset CompiledAt +); diff --git a/src/AdaptiveRemote.Contracts/PreviewLayout.cs b/src/AdaptiveRemote.Contracts/PreviewLayout.cs new file mode 100644 index 00000000..2e968d29 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/PreviewLayout.cs @@ -0,0 +1,11 @@ +namespace AdaptiveRemote.Contracts; + +// Editor-consumable preview format, produced by LayoutCompilerService. +public record PreviewLayout( + Guid RawLayoutId, + int Version, + string RenderedHtml, + string RenderedCss, + DateTimeOffset CompiledAt, + ValidationResult ValidationResult +); diff --git a/src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs b/src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs new file mode 100644 index 00000000..3bf30c8e --- /dev/null +++ b/src/AdaptiveRemote.Contracts/RawLayoutElementDto.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Contracts; + +// --------------------------------------------------------------------------- +// Raw layout element DTOs +// Shared between the editor application (serialization) and LayoutCompilerService +// (deserialization). Extends behavioral properties with authoring properties that +// the compiler resolves into CssDefinitions and strips from the compiled output. +// --------------------------------------------------------------------------- + +// "$type" avoids conflict with the behavioral Type property on RawCommandDefinitionDto. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(RawCommandDefinitionDto), "command")] +[JsonDerivedType(typeof(RawLayoutGroupDefinitionDto), "group")] +public abstract record RawLayoutElementDto( + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null // per-element CSS overrides (e.g. red background for Power) +); + +public record RawCommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss), + ICommandProperties; + +public record RawLayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss); + +// --------------------------------------------------------------------------- +// Administrator-editable source format. Elements are typed; no opaque JSON string. +// --------------------------------------------------------------------------- + +public record RawLayout( + Guid Id, + string UserId, + string Name, + IReadOnlyList Elements, + int Version, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + ValidationResult? ValidationResult // written by LayoutProcessingService via IRawLayoutStatusWriter +); diff --git a/src/AdaptiveRemote.Contracts/ValidationResult.cs b/src/AdaptiveRemote.Contracts/ValidationResult.cs new file mode 100644 index 00000000..81c3e7b5 --- /dev/null +++ b/src/AdaptiveRemote.Contracts/ValidationResult.cs @@ -0,0 +1,5 @@ +namespace AdaptiveRemote.Contracts; + +public record ValidationIssue(string Code, string Message, string? Path); + +public record ValidationResult(bool IsValid, IReadOnlyList Issues); diff --git a/src/_doc_Projects.md b/src/_doc_Projects.md index b786872f..e81f19de 100644 --- a/src/_doc_Projects.md +++ b/src/_doc_Projects.md @@ -33,6 +33,14 @@ This document describes the high-level organization of the AdaptiveRemote reposi - Minimal code to launch the WPF app with console logging. - No business logic or features. +### AdaptiveRemote.Contracts +- **Purpose:** Shared class library containing layout definition DTOs, enums, interfaces, and the source-generated `LayoutContractsJsonContext` used by both the client application and backend services. +- **Guidance:** _No platform-specific dependencies._ Targets `net10.0` only. Contains pure data types (records, enums, interfaces) with no behavior. +- **Boundaries:** + - No WPF, Windows APIs, or Blazor dependencies. + - No MVVM or runtime behavior — DTOs only. + - Included in both `client.slnf` and `backend.slnf`. + ## Test Projects ### AdaptiveRemote.App.Tests diff --git a/src/_spec_LayoutCustomizationService.md b/src/_spec_LayoutCustomizationService.md new file mode 100644 index 00000000..246fd0d6 --- /dev/null +++ b/src/_spec_LayoutCustomizationService.md @@ -0,0 +1,918 @@ +# Layout Customization Service (Backend) + +> **Status:** Draft +> **Will become:** `_doc_LayoutCustomizationService.md` once implementation is complete + +## Overview + +The Layout Customization Service is a microservice backend that enables administrators to +remotely create, edit, compile, and publish remote control layouts for the AdaptiveRemote +client application. Administrators edit layouts via a web editor; the backend stores, +compiles, validates, and distributes them; client applications download and cache layouts +and update automatically when new versions are published. This epic covers the backend +services only. The web editor UI, client-side layout integration, CI/CD deployment, and +load testing are covered by separate related epics. + +## Terminology + +| Term | Meaning | +|------|---------| +| Client application | The end-user AdaptiveRemote Windows app (remote control) | +| Editor application | The administrator-facing Blazor WebAssembly app for editing layouts | +| Backend | The microservices defined in this epic | +| End user | Person using the client application | +| Administrator | Person using the editor application to modify layouts for an end user | +| Raw layout | An administrator-editable layout definition (source format, JSON) | +| Compiled layout | A processed layout ready for client consumption (command JSON + CSS) | + +## Responsibilities & Boundaries + +- **Owns:** Storage, compilation, validation, and distribution of remote layouts via REST + APIs; layout change notifications to connected clients via SSE +- **Does not own:** Web editor UI; client-side layout consumption, caching, and update + application; CI/CD deployment pipeline; load testing infrastructure; user authentication + (delegated to external IdP); layout schema definition (defined in the editor epic) +- **Integrates with:** External OAuth2 identity provider (JWT validation); `AdaptiveRemote.Contracts` + shared library (layout DTOs); client application (SSE consumer); editor application + (layout CRUD consumer) + +## Key Design Decisions + +### Repo organization: solution filters, not folder split + +_Context:_ Backend projects will be added to the same repo as the client application. Options +were to reorganize into top-level `client/` and `backend/` folders, or to keep everything +under `src/` and `test/` and use solution filters. + +_Decision:_ Keep all projects under `src/` and `test/`. Add `client.slnf` and `backend.slnf` +solution filters so developers can load only the relevant set. A master `AdaptiveRemote.sln` +includes all projects. + +_Consequences:_ No folder restructuring of existing client projects. Consistent layout for +both audiences. Backend project names follow the convention `AdaptiveRemote.Backend.*` to +avoid collision with client projects. + +### DynamoDB for layout storage; SQS for processing queue + +_Context:_ Layout access patterns are almost entirely key/user/timestamp lookups with opaque +JSON content. A relational database would add server management overhead without providing +relational features we'd actually use. Given the AWS deployment target, purpose-built managed +AWS services are the natural fit. + +_Decision:_ Use **DynamoDB** for `RawLayoutService` and `CompiledLayoutService` storage. +Partition key is `UserId`; sort key is `Id` (a KSUID or similar time-ordered ID). This +covers all access patterns: point-read by ID, list all layouts for a user, get the active +layout by user. Layout elements (`RawLayout.Elements`, `CompiledLayout.Elements`) and CSS +(`CompiledLayout.CssDefinitions`) are serialized to JSON strings and stored as DynamoDB +string attributes. Use **SQS** as the message queue between `RawLayoutService` and +`LayoutProcessingService`. The SQS queue is configured with a **max receive count of 3** +(4 total attempts including the first); messages that exhaust retries are moved to a +**Dead Letter Queue (DLQ)**. DLQ messages are retained for 14 days. `LayoutProcessingService` +logs an error on every failed attempt and on DLQ arrival. The raw layout's `ValidationResult` +is not automatically updated for DLQ messages; manual reprocessing (by re-saving the raw +layout) is required. This is a known limitation and a candidate for future improvement. + +_Consequences:_ No database server to provision or manage in production. Pay-per-request +pricing is well-suited to low initial traffic. Local development uses LocalStack +(a Docker container that emulates DynamoDB, SQS, and Lambda). Strong .NET support via AWSSDK. +The DynamoDB single-table design requires upfront key schema decisions; the partition/sort key +model above is sufficient for all current access patterns. Adding a new query pattern (e.g., +list layouts by name) may require a Global Secondary Index. + +### Direct HTTP between services for MVP; event-driven boundary preserved + +_Context:_ The epic raised event-driven architecture (e.g., Kafka) as a question. Event-driven +adds substantial infrastructure complexity (broker, consumer groups, at-least-once delivery) +not justified at MVP scale, but the architecture should not rule it out. + +_Decision:_ Services communicate via direct HTTP for the initial implementation, with one +exception: `RawLayoutService` enqueues a message to SQS when a layout is saved, and +`LayoutProcessingService` polls that queue. This makes compilation inherently asynchronous — +the editor receives a `201 Created` immediately after save and learns that compilation is +complete via an SSE event (the same stream the client uses). All other service-to-service +calls (e.g., `LayoutProcessingService` → `LayoutCompilerService`) remain synchronous HTTP. +Each service-to-service communication boundary is modeled as an injected interface so the +transport can be changed without modifying callers. + +_Consequences:_ Async processing prevents slow compilation from blocking the editor. The +SQS queue provides natural retry and backpressure if `LayoutProcessingService` is +temporarily unavailable. Synchronous HTTP for the remaining internal calls keeps the design +simple for MVP. + +### All service-to-service communication is interface-abstracted + +_Context:_ The transport mechanism for any given service-to-service call may need to change +(e.g., HTTP → SQS, or direct call → fan-out to multiple consumers). Callers should not be +coupled to transport details. + +_Decision:_ Every cross-service call is expressed as an injected interface in the calling +service. The interface captures intent (what is being requested or notified), not transport. +Implementations are registered in DI and can be swapped without changing callers. This +applies uniformly: storage repositories, HTTP client wrappers, SQS publishers, and SSE +notification publishers all follow this pattern. + +_Consequences:_ Transport changes are contained to the implementation class and DI +registration. The pattern adds a small amount of indirection but makes each service +independently testable with mock implementations. No special framework is required — +standard .NET DI is sufficient. + +### Service discovery and load balancing + +_Context:_ Services that communicate via HTTP need a way to locate each other. The approach +differs between local development and production. + +_Decision:_ + +**Production:** Services run as Docker containers on **AWS ECS (Fargate)**. Internal +service-to-service traffic uses **ECS Service Connect**, which provides DNS-based service +discovery and client-side load balancing within the ECS cluster. Each service registers +under a short name (e.g., `rawlayoutservice`); callers reach it at +`http://rawlayoutservice/...` with no additional infrastructure. External traffic from the +client and editor applications enters through **AWS API Gateway**, which handles auth +validation and routes requests to the appropriate ECS service. + +**Local development:** Docker Compose provides service discovery automatically via Docker +DNS. Services are reachable by their Compose service name (e.g., +`http://rawlayoutservice:8080`). No additional tooling is required. + +**Configuration:** Each service's base URL is injected via environment variable or +`appsettings.json`. No URLs are hardcoded. The same binaries run locally and in production; +only configuration changes. + +_Consequences:_ ECS Service Connect eliminates the need for a separate internal load +balancer or service mesh. Docker Compose DNS makes local development zero-configuration. +The environment-variable–driven URL model is a standard .NET pattern and requires no +framework changes. + +### Orchestration over choreography for the compilation pipeline + +_Context:_ `LayoutProcessingService` coordinates five steps (fetch → compile → validate → +store → notify) and is therefore coupled to five other services. Choreography was considered +as an alternative: each service would react to events rather than being called, eliminating +the central coordinator. + +_Decision:_ Keep the orchestrator pattern. The compilation pipeline is strictly linear with +no fan-out, making choreography's main benefit (independent step scaling and reuse across +workflows) inapplicable. In an orchestrated design, error handling — specifically the +`ValidationResult` write-back to `RawLayoutService` on failure — lives in one place with +full context. In a choreographed design, `RawLayoutService` would need to subscribe to +failure events, adding business logic to a CRUD service. The orchestrator's coupling is +managed through injected interfaces (independently testable) and it owns no storage of its +own. Revisit choreography if the pipeline grows significantly or steps need to be reused +across multiple workflows. + +_Consequences:_ The overall workflow is explicit and debuggable in one place. `LayoutProcessingService` +is intentionally coupled to its participants — this is the orchestrator pattern working as +designed, not a design flaw. + +### Lambda for stateless services; ECS Fargate for stateful services + +_Context:_ `LayoutCompilerService` and `LayoutValidationService` are stateless and invoked +only when an administrator saves a layout — not on the hot path of any client request. +Running them as always-on ECS containers means paying for idle capacity on services that +may go hours without being invoked. + +_Decision:_ Host `LayoutCompilerService` and `LayoutValidationService` as **AWS Lambda +functions**. All other services run as **ECS Fargate** containers. Lambda functions are +exposed via **Lambda Function URLs** (no API Gateway layer needed for internal calls); +`LayoutProcessingService` reaches them over HTTPS using the existing `ILayoutCompilerClient` +and `ILayoutValidationClient` HTTP interfaces — the ECS-to-Lambda boundary is transparent +to callers. Use **Native AOT** compilation for the Lambda functions to minimize cold start +latency. Cold starts are acceptable regardless, because `LayoutProcessingService` is already +running asynchronously via SQS — a cold start adds seconds to a background process, not to +a user-facing response. LocalStack emulates Lambda locally, consistent with the existing +DynamoDB and SQS setup. + +_Consequences:_ Pay-per-invocation cost model for low-frequency services. No idle container +cost. Native AOT requires that Lambda function code avoids reflection-heavy libraries. +Lambda Function URLs keep the calling convention identical to ECS HTTP services, preserving +the interface abstraction. + +### Shared contracts library for layout definition DTOs; existing App types stay in App + +_Context:_ The client application, editor application, and backend all need to work with +layout data structures. The existing `RemoteLayoutElement` and `Command` types in +`AdaptiveRemote.App.Models` were considered for sharing, but they inherit from `MvvmObject` +and carry MVVM properties, execution delegates, and client lifecycle concerns — they cannot +live in a framework-agnostic library. + +_Decision:_ Introduce `AdaptiveRemote.Contracts` as a shared .NET class library (no +**platform-specific** dependencies, no `-windows` target) containing layout definition DTOs +and a source-generated `JsonSerializerContext`. "No platform-specific dependencies" means +no WPF, Windows APIs, or Blazor — BCL libraries including `System.Text.Json` and +`System.Collections.Generic` are permitted and expected. The library contains pure records +representing what a layout element *is* — name, label, glyph, grid position, CSS overrides +— with no behavior. The existing `Command` and `RemoteLayoutElement` types remain in +`AdaptiveRemote.App` as runtime types; they are mapped from the Contracts DTOs at +layout-apply time (responsibility of the client-side consumption epic). + +`AdaptiveRemote.Contracts` defines a `LayoutContractsJsonContext : JsonSerializerContext` +annotated with `[JsonSerializable]` for each top-level DTO type. This serves two purposes: +source-generated serialization is **required** for the Native AOT Lambda functions +(`LayoutCompilerService`, `LayoutValidationService`), and placing the context in Contracts +ensures all consumers share one consistent serialization definition rather than maintaining +separate contexts that could drift. + +The client application uses the Contracts DTOs and context directly for deserializing API +responses. JSON field names and structure are defined once and shared by both the +serializing backend and the deserializing client. + +`AdaptiveRemote.Contracts` is included in both `client.slnf` and `backend.slnf`. + +_Consequences:_ Single source of truth for the wire format. Breaking changes to shared +types are caught at compile time across all consumers. The App runtime types and Contracts +DTOs are not duplicates — they serve different purposes (runtime behavior vs. data +transport). The mapping from DTO to runtime type is a contained, testable step. + +### Server-Sent Events for client push notifications + +_Context:_ The `NotificationService` needs to push layout-change events to connected clients. +WebSockets support bidirectional communication, which is unnecessary — clients only need to +receive events. + +_Decision:_ Use Server-Sent Events (SSE) over HTTPS. The client application opens a +persistent SSE connection on startup. Standard SSE retry handles reconnection automatically. + +_Consequences:_ Simpler server implementation than WebSockets. Works through most HTTP +proxies and firewalls. Limitation: SSE is one-way; if bidirectional communication is +needed in the future, migration to WebSockets would be required. + +### OAuth2 with AWS Cognito; two flows for two client types + +_Context:_ The client application runs unattended on a disabled user's machine and cannot +present an interactive login. Stress bot accounts need to be provisioned programmatically +without manual IdP UI work. A custom API key store was considered but would require owning +key generation, hashing, rotation, and revocation — a non-trivial security surface. + +_Decision:_ Use **AWS Cognito** as the identity provider with two OAuth2 flows: + +- **Authorization Code flow** — for administrators using the editor application. Standard + browser-based login; Cognito handles MFA, session management, and token refresh. +- **Client Credentials flow** — for the client application and stress bot accounts. Each + machine client is registered as a Cognito app client with a `client_id` and + `client_secret`, stored in environment variables or a config file. Tokens are acquired + and refreshed automatically in the background; no user interaction occurs. Bot accounts + are provisioned and revoked via the Cognito API (scriptable, no manual console work). + +All services validate JWT bearer tokens from Cognito using the published JWKS endpoint. +Services receive the `sub` claim as the stable user identifier. No custom auth service or +user database is required. + +For local development, use a **dedicated Cognito dev user pool** rather than a local OIDC +stub. This avoids incomplete emulation and ensures auth behavior matches production exactly. +The dev user pool requires only AWS credentials and internet access — both already assumed +for LocalStack configuration. + +_Consequences:_ Client application and bot auth is non-interactive and config-file–driven, +matching the desired UX. Cognito handles all security-sensitive concerns (key storage, token +signing, revocation). Cognito is AWS-native, consistent with DynamoDB, SQS, and Lambda. +The dev user pool adds a small AWS dependency to local development but is free within +Cognito's free tier. + +### Auto-update layout on notification; defer application until user is idle + +_Context:_ When the backend publishes a new compiled layout, the client needs to update. +Applying immediately risks disrupting an active interaction; requiring a manual user action +adds friction. + +_Decision:_ Auto-update. When the client receives an SSE layout-changed event, it fetches +the new compiled layout. It defers applying it (swapping the active layout) until the user +is idle. The exact idle-detection policy is defined in the client-side consumption epic. + +_Consequences:_ End users always see the latest layout without manual intervention. The +deferral policy protects against jarring mid-interaction updates but is out of scope for +this epic. + +## Planned Implementation + +### Project naming convention + +| Project | Type | +|---------|------| +| `AdaptiveRemote.Contracts` | Shared class library (DTOs, enums) | +| `AdaptiveRemote.Backend.RawLayoutService` | .NET 10 Web API — ECS Fargate | +| `AdaptiveRemote.Backend.CompiledLayoutService` | .NET 10 Web API — ECS Fargate | +| `AdaptiveRemote.Backend.LayoutCompilerService` | .NET 10 Lambda function (Native AOT) | +| `AdaptiveRemote.Backend.LayoutValidationService` | .NET 10 Lambda function (Native AOT) | +| `AdaptiveRemote.Backend.LayoutProcessingService` | .NET 10 Web API — ECS Fargate | +| `AdaptiveRemote.Backend.NotificationService` | .NET 10 Web API — ECS Fargate (SSE) | + +Test projects follow the pattern `.Tests` under `test/`. + +### Shared Contracts (`AdaptiveRemote.Contracts`) + +```csharp +// Identifies the runtime command type. The client uses this to instantiate the correct +// App runtime type (TiVoCommand, IRCommand, LifecycleCommand, ActionCommand). +// Type-specific execution parameters are resolved by the client from its own configuration: +// TiVo — CommandId = Name.ToUpperInvariant() (existing convention) +// IR — payload programmed via remote, stored in ProgrammaticSettings +// Others — keyed by Name +// Subtypes with additional properties are deferred until a concrete need arises. +public enum CommandType { Lifecycle, TiVo, IR, Action } + +// Shared behavioral interface — prevents drift between the compiled and raw command types. +// Adding a new behavioral property means updating this interface first; the compiler +// will flag any implementing record that doesn't follow. +public interface ICommandProperties +{ + CommandType Type { get; } + string Name { get; } + string Label { get; } + string? Glyph { get; } + string SpeakPhrase { get; } + string? Reverse { get; } +} + +// --------------------------------------------------------------------------- +// Compiled layout element DTOs +// Used in CompiledLayout.Elements. Deserialized directly by the client application. +// Contains only behavioral properties — grid positions and CSS overrides have been +// compiled into CssDefinitions and are not needed by the client. +// --------------------------------------------------------------------------- + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(CommandDefinitionDto), "command")] +[JsonDerivedType(typeof(LayoutGroupDefinitionDto), "group")] +public abstract record LayoutElementDto(string CssId); + +// Maps to AdaptiveRemote.App.Models.Command at layout-apply time (client epic). +// Type carries the CommandType discriminator so the client knows which runtime type to instantiate. +// No subtype hierarchy is used — all behavioral properties are flat; type-specific execution +// parameters are resolved by the client from its own configuration (see CommandType above). +public record CommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId +) : LayoutElementDto(CssId), ICommandProperties; + +// Maps to AdaptiveRemote.App.Models.LayoutGroup at layout-apply time (client epic). +public record LayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children +) : LayoutElementDto(CssId); + +// --------------------------------------------------------------------------- +// Raw layout element DTOs +// Shared between the editor application (serialization) and LayoutCompilerService +// (deserialization). Extends behavioral properties with authoring properties that +// the compiler resolves into CssDefinitions and strips from the compiled output. +// --------------------------------------------------------------------------- + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(RawCommandDefinitionDto), "command")] +[JsonDerivedType(typeof(RawLayoutGroupDefinitionDto), "group")] +public abstract record RawLayoutElementDto( + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null // per-element CSS overrides (e.g. red background for Power) +); + +public record RawCommandDefinitionDto( + CommandType Type, + string Name, + string Label, + string? Glyph, + string SpeakPhrase, + string? Reverse, + string CssId, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss), + ICommandProperties; + +public record RawLayoutGroupDefinitionDto( + string CssId, + IReadOnlyList Children, + int GridRow, + int GridColumn, + int GridRowSpan = 1, + int GridColumnSpan = 1, + string? AdditionalCss = null +) : RawLayoutElementDto(CssId, GridRow, GridColumn, GridRowSpan, GridColumnSpan, AdditionalCss); + +// --------------------------------------------------------------------------- +// Top-level layout records +// --------------------------------------------------------------------------- + +// Administrator-editable source format. Elements are typed; no opaque JSON string. +public record RawLayout( + Guid Id, + string UserId, + string Name, + IReadOnlyList Elements, + int Version, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + ValidationResult? ValidationResult // written by LayoutProcessingService via IRawLayoutStatusWriter +); + +// Client-consumable format produced by LayoutCompilerService. +// Deserialized directly by the client application — no intermediate parsing model needed. +// The client maps Elements → runtime Command objects at layout-apply time (client epic). +public record CompiledLayout( + Guid Id, + Guid RawLayoutId, + string UserId, + bool IsActive, + int Version, + IReadOnlyList Elements, + string CssDefinitions, // global CSS for the layout grid + DateTimeOffset CompiledAt +); + +// Editor-consumable preview format, produced by LayoutCompilerService. +public record PreviewLayout( + Guid RawLayoutId, + int Version, + string RenderedHtml, + string RenderedCss, + DateTimeOffset CompiledAt, + ValidationResult ValidationResult +); + +public record ValidationIssue(string Code, string Message, string? Path); +public record ValidationResult(bool IsValid, IReadOnlyList Issues); + +// Source-generated JSON context — required for Native AOT Lambda functions; +// shared by all consumers to ensure consistent serialization behaviour. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(RawLayout))] +[JsonSerializable(typeof(CompiledLayout))] +[JsonSerializable(typeof(PreviewLayout))] +[JsonSerializable(typeof(ValidationResult))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +public partial class LayoutContractsJsonContext : JsonSerializerContext { } +``` + +### Interfaces + +```csharp +// RawLayoutService — CRUD for the editor; also implements IRawLayoutStatusWriter (below). +// Editor consumers depend on IRawLayoutRepository only. +// LayoutProcessingService depends on IRawLayoutStatusWriter only. +// RawLayoutService implements both; neither consumer gets more surface than it needs. +interface IRawLayoutRepository +{ + Task GetAsync(Guid id, CancellationToken ct); + Task> ListByUserAsync(string userId, CancellationToken ct); + Task SaveAsync(RawLayout layout, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} + +// Narrow write-back interface for LayoutProcessingService to record compilation results +// on a raw layout without requiring full CRUD access to RawLayoutService. +interface IRawLayoutStatusWriter +{ + Task UpdateValidationResultAsync(Guid rawLayoutId, ValidationResult result, CancellationToken ct); +} + +// RawLayoutService → LayoutProcessingService — SQS-backed; enqueues a message on layout save +interface ILayoutProcessingTrigger +{ + Task TriggerAsync(Guid rawLayoutId, CancellationToken ct); +} + +// LayoutProcessingService injects IRawLayoutRepository (shared with the editor) to fetch +// the raw layout by ID after dequeuing an SQS message. It also injects IRawLayoutStatusWriter +// (separate narrow interface) to write the ValidationResult back on completion. + +// LayoutCompilerService — stateless; called by LayoutProcessingService (CompileAsync) +// and by RawLayoutService (CompilePreviewAsync) for the live preview endpoint. +// CompilePreviewAsync takes only the elements — no stored RawLayout record needed. +interface ILayoutCompilerClient +{ + Task CompileAsync(RawLayout raw, CancellationToken ct); + Task CompilePreviewAsync(IReadOnlyList elements, CancellationToken ct); +} + +// LayoutValidationService — stateless; called by LayoutProcessingService +interface ILayoutValidationClient +{ + Task ValidateAsync(CompiledLayout compiled, CancellationToken ct); +} + +// CompiledLayoutService — storage and retrieval +// SetActiveAsync sets IsActive = true on the specified layout and clears it on all +// other layouts for the same user. +interface ICompiledLayoutRepository +{ + Task GetActiveForUserAsync(string userId, CancellationToken ct); + Task> ListByUserAsync(string userId, CancellationToken ct); + Task GetByIdAsync(Guid id, CancellationToken ct); + Task SaveAsync(CompiledLayout layout, CancellationToken ct); + Task SetActiveAsync(Guid id, string userId, CancellationToken ct); +} + +// NotificationService — called by RawLayoutService on save, and by LayoutProcessingService on publish +// SSE event types: +// layout-saved → editor subscribes; used to detect concurrent saves on the same layout +// layout-ready → client subscribes; triggers download of the new compiled layout +// Future: layout-error (compilation failed) can be added if polling for validation results proves insufficient +interface INotificationPublisher +{ + Task PublishLayoutSavedAsync(string userId, Guid rawLayoutId, CancellationToken ct); + Task PublishLayoutReadyAsync(string userId, Guid compiledLayoutId, CancellationToken ct); +} +``` + +### REST API surface + +**Endpoints consumed by the client application:** + +``` +GET /layouts/compiled → list compiled layouts for the authenticated user +GET /layouts/compiled/active → active compiled layout; 404 if none exists yet +GET /layouts/compiled/{id} → specific compiled layout by ID +PUT /layouts/compiled/{id}/active → set a compiled layout as active +GET /notifications/layouts/stream → SSE stream; emits layout-saved and layout-ready events +``` + +**Endpoints consumed by the editor application:** + +``` +GET /layouts/raw → list raw layouts for the authenticated user +GET /layouts/raw/{id} → fetch a specific raw layout by ID +POST /layouts/raw → create a new raw layout (triggers compilation) +PUT /layouts/raw/{id} → update a raw layout (triggers recompilation) +DELETE /layouts/raw/{id} → delete a raw layout +POST /layouts/raw/preview → compile a live preview from unsaved elements (no storage); + request body: IReadOnlyList; + returns PreviewLayout +``` + +**Internal endpoints (not exposed via API Gateway):** + +``` +POST /compile → LayoutCompilerService: compile a raw layout to CompiledLayout +POST /compile/preview → LayoutCompilerService: compile elements to PreviewLayout +POST /validate → LayoutValidationService: validate a compiled layout +``` + +### Data flow: publish a layout (administrator saves) + +1. Editor `POST`s or `PUT`s a raw layout → `RawLayoutService` stores it in DynamoDB +2. `RawLayoutService` calls `INotificationPublisher.PublishLayoutSavedAsync(userId, rawLayoutId)` + → `NotificationService` pushes `layout-saved` SSE event to any connected editor for that user + (concurrent-edit awareness — the saving editor and any others watching the same layout are notified) +3. `RawLayoutService` calls `ILayoutProcessingTrigger.TriggerAsync(rawLayoutId)` → SQS message enqueued; + returns `201 Created` to the editor +4. `LayoutProcessingService` dequeues the SQS message, fetches the raw layout from `RawLayoutService` +5. Calls `ILayoutCompilerClient.CompileAsync(raw)` → `LayoutCompilerService` returns compiled layout +6. Calls `ILayoutValidationClient.ValidateAsync(compiled)` → `LayoutValidationService` returns `ValidationResult` +7. If valid: stores compiled layout via `CompiledLayoutService`; if invalid: the failure is + recorded on the `RawLayout` record (`ValidationResult`) so the editor can display it on next fetch +8. If valid: calls `INotificationPublisher.PublishLayoutReadyAsync(userId, compiledId)` + → `NotificationService` pushes `layout-ready` SSE event to any connected client for that user + +### Data flow: client startup + +1. Client authenticates with Cognito using Client Credentials flow; receives JWT +2. Client `GET /layouts/compiled/active` → receives active compiled layout and caches it + locally; if `404`, client falls back to a bundled default layout until one is published +3. Client opens SSE connection: `GET /notifications/layouts/stream` +4. On SSE `layout-ready` event: client re-fetches `GET /layouts/compiled/active`, applies + when user is idle + +### Local development + +All services run locally via `docker-compose`. A **LocalStack** container provides local emulation of DynamoDB, SQS, and Lambda. A +**dedicated Cognito dev user pool** handles JWT issuance and validation — real Cognito is +used rather than a local stub to ensure auth behavior matches production exactly. AWS +credentials are required for local development (for both LocalStack and Cognito). All internal +service-to-service URLs are resolved via Docker DNS. The client application is configured +to point to the local backend via `appsettings.Development.json`. + +## Related Epics + +The following epics will each receive their own spec before implementation begins. + +| Epic | Scope | +|------|-------| +| **[ADR-161](https://jodasoft.atlassian.net/browse/ADR-161)** (this) | Backend services: storage, compilation, validation, processing, notifications | +| **[ADR-162](https://jodasoft.atlassian.net/browse/ADR-162)** | Client-side layout consumption: download, cache, apply, auto-update | +| **[ADR-163](https://jodasoft.atlassian.net/browse/ADR-163)** | Blazor WebAssembly editor: text editor + live preview | +| **[ADR-164](https://jodasoft.atlassian.net/browse/ADR-164)** | AWS CI/CD deployment pipeline: containerized deployment to AWS | +| **[ADR-165](https://jodasoft.atlassian.net/browse/ADR-165)** | Stress testing and availability: bot accounts, load scenarios, availability metrics | + +## Open Questions + +- [x] ~~Which external IdP will be used in production?~~ **Resolved:** AWS Cognito. + Authorization Code flow for editor users; Client Credentials flow for the client + application and bot accounts. Dev environment uses a dedicated Cognito dev user pool. +- [x] ~~Should layout compilation be synchronous or asynchronous?~~ **Resolved:** Async, by + virtue of using SQS. `RawLayoutService` returns `201 Created` immediately; compilation + result is delivered via SSE (`layout-ready` or `layout-error`). +- [ ] What validation rules does `LayoutValidationService` enforce? The structure is defined + by `RawLayoutElementDto` (nested hierarchy of `RawCommandDefinitionDto` and + `RawLayoutGroupDefinitionDto`; grid position and per-element CSS overrides included). + The specific constraints (e.g. valid grid ranges, required fields, CSS syntax) must be + defined before `LayoutValidationService` can be implemented. This is a dependency on the + editor epic. +- [x] ~~Can a user have multiple named layouts?~~ **Resolved:** Multiple layouts per user + are supported from the start; one is designated active. `CompiledLayout` carries `UserId` + and `IsActive`. A dedicated endpoint sets the active layout. Client-side support for + switching layouts (e.g. by input source) is deferred to a future client epic. + +## Related Docs + +- [`src/_doc_Projects.md`](_doc_Projects.md) +- [`src/AdaptiveRemote.App/Services/_doc_Services.md`](AdaptiveRemote.App/Services/_doc_Services.md) +- [`src/AdaptiveRemote.App/Services/Commands/_doc_Commands.md`](AdaptiveRemote.App/Services/Commands/_doc_Commands.md) +- [`src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md`](AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md) +- [`src/AdaptiveRemote.App/Services/ProgrammaticSettings/_doc_ProgrammaticSettings.md`](AdaptiveRemote.App/Services/ProgrammaticSettings/_doc_ProgrammaticSettings.md) + +## Tasks + +### Task 1 — Repo reorganization and shared contracts ([ADR-166](https://jodasoft.atlassian.net/browse/ADR-166)) + +Add solution filters and the `AdaptiveRemote.Contracts` shared library. + +- [ ] `client.slnf` and `backend.slnf` solution filters created; both build cleanly with `dotnet build /warnaserror` +- [ ] `AdaptiveRemote.Contracts` project created; targets `net10.0` (no `-windows`); no platform-specific dependencies +- [ ] All DTOs, enums, interfaces, and `LayoutContractsJsonContext` from the spec's Shared Contracts section are implemented +- [ ] `AdaptiveRemote.Contracts` is referenced by `AdaptiveRemote.App` and builds without warnings +- [ ] All existing client unit tests and headless E2E tests pass + +### Task 2 — Static layout MVP ([ADR-167](https://jodasoft.atlassian.net/browse/ADR-167)) + +Create `AdaptiveRemote.Backend.CompiledLayoutService` returning the current hardcoded layout. +Establish the backend API integration test infrastructure, the observability pattern (health +endpoints, structured logging, metrics), and the log validation pattern for API tests. All +subsequent backend services follow these patterns from the start. + +- [ ] `AdaptiveRemote.Backend.CompiledLayoutService` project created under `src/`; included in `backend.slnf` +- [ ] `GET /layouts/compiled/active` returns the current hardcoded layout serialized as `CompiledLayout` using `LayoutContractsJsonContext` +- [ ] No auth required for this task; endpoint is unauthenticated +- [ ] `GET /health` implemented; returns `200 OK` with service name and version; **this pattern is required for all subsequent backend services** +- [ ] Structured logging pattern established: log messages defined as `[LoggerMessage]` source-generated methods (same discipline as `MessageLogger.cs` in the client app); request/response logging middleware applied; **this pattern is required for all subsequent backend services** +- [ ] Metrics pattern established: key operations emit structured log events that serve as the local-dev metrics signal (e.g. request count, status code); CloudWatch as the production sink is deferred to the CI/CD deployment epic; **this pattern is required for all subsequent backend services** +- [ ] `docker-compose` configured so structured log output is visible for all running services in local dev +- [ ] Service runs in `docker-compose` and is reachable from the client app via `appsettings.Development.json` +- [ ] Backend API integration test project created (e.g. `AdaptiveRemote.Backend.ApiTests`); + includes an `HttpClient` fixture that spins up services via `docker-compose` and is + runnable against local dev, CI, and deployed environments; captures structured log output + from each service so Gherkin scenarios can assert on expected log events and the absence + of warnings or errors; pattern documented for reuse in subsequent tasks +- [ ] API integration tests cover `GET /layouts/compiled/active` and `GET /health`: + + ```gherkin + Given CompiledLayoutService is running + When a test client calls GET /layouts/compiled/active + Then the response is 200 OK + And the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext + And the CompiledLayout contains the expected hardcoded commands + And the service logs contain a request log entry for GET /layouts/compiled/active + And the service logs contain no warnings or errors + + Given CompiledLayoutService is running + When a test client calls GET /health + Then the response is 200 OK + And the body contains the service name and version + ``` + +- [ ] All existing headless E2E tests pass with the client reading from the service + +### Task 3 — Auth integration (Cognito) ([ADR-168](https://jodasoft.atlassian.net/browse/ADR-168)) + +Wire up JWT validation via AWS Cognito and API Gateway before any user-specific storage is built. Establishing auth at this stage surfaces Cognito unknowns (dev user pool setup, JWT issuance, JWKS validation) while the service count is still low, and ensures every subsequent task builds on a working auth layer from the start rather than retrofitting it across multiple services at once. + +- [ ] Cognito dev user pool created; JWKS endpoint configured in API Gateway +- [ ] API Gateway validates JWT bearer tokens on all external endpoints; unauthenticated requests return `401` +- [ ] `CompiledLayoutService` extracts the `sub` claim as `userId`; Task 2 API integration tests updated to include valid JWT headers +- [ ] Client app configured with `client_id` / `client_secret` via `appsettings.Development.json`; acquires and refreshes tokens automatically in the background +- [ ] Editor app auth flow (Authorization Code) documented with setup instructions for local dev +- [ ] Internal endpoints (Lambda Function URLs) are network-isolated and not exposed via API Gateway +- [ ] `GET /health` added to `CompiledLayoutService`; logging and metrics pattern from Task 2 verified under authenticated requests; API integration tests updated to assert no warnings or errors in service logs +- [ ] API integration tests cover authentication enforcement: + + ```gherkin + Given a request with no Authorization header + When a test client calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + + Given a request with a valid Cognito JWT + When a test client calls GET /layouts/compiled/active + Then the response is 200 OK + + Given a request with an expired Cognito JWT + When a test client calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + ``` + +### Task 4 — RawLayoutService + DynamoDB ([ADR-169](https://jodasoft.atlassian.net/browse/ADR-169)) + +Implement `AdaptiveRemote.Backend.RawLayoutService` with full CRUD backed by DynamoDB. + +- [ ] `AdaptiveRemote.Backend.RawLayoutService` project created; included in `backend.slnf` +- [ ] `IRawLayoutRepository` and `IRawLayoutStatusWriter` implemented against DynamoDB (LocalStack in dev) +- [ ] DynamoDB table created with partition key `UserId`, sort key `Id` (KSUID) +- [ ] All CRUD endpoints (`GET /layouts/raw`, `GET /layouts/raw/{id}`, `POST /layouts/raw`, `PUT /layouts/raw/{id}`, `DELETE /layouts/raw/{id}`) implemented and unit tested +- [ ] `docker-compose.yml` updated with LocalStack container; DynamoDB table provisioned on startup +- [ ] `ILayoutProcessingTrigger` stub (no-op) injected so save/update endpoints compile; SQS wiring deferred to Task 5 +- [ ] `INotificationPublisher` stub (no-op) injected; notification wiring deferred to Task 9 +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; API integration tests assert no warnings or errors in service logs during normal CRUD operations +- [ ] Unit tests cover repository logic against LocalStack or mocked DynamoDB client +- [ ] API integration tests cover all CRUD endpoints: + + ```gherkin + Given an authenticated user has no raw layouts + When a test client calls GET /layouts/raw + Then the response is 200 OK + And the body is an empty array + + Given an authenticated user + When a test client calls POST /layouts/raw with a valid RawLayout body + Then the response is 201 Created + And the body contains the created RawLayout with a generated Id + And GET /layouts/raw/{id} returns the same layout + + Given a raw layout exists with id {id} + When a test client calls PUT /layouts/raw/{id} with updated elements + Then the response is 200 OK + And GET /layouts/raw/{id} returns the updated elements + + Given a raw layout exists with id {id} + When a test client calls DELETE /layouts/raw/{id} + Then the response is 204 No Content + And GET /layouts/raw/{id} returns 404 Not Found + ``` + +### Task 5 — LayoutProcessingService (with stubs) ([ADR-170](https://jodasoft.atlassian.net/browse/ADR-170)) + +Implement `AdaptiveRemote.Backend.LayoutProcessingService` with SQS polling and the full +orchestration pipeline. `ILayoutCompilerClient` and `ILayoutValidationClient` are backed by +stub implementations that return hardcoded valid results, keeping the pipeline testable +end-to-end before the real Lambda functions are built in Tasks 6 and 7. + +- [ ] `AdaptiveRemote.Backend.LayoutProcessingService` project created; included in `backend.slnf` +- [ ] SQS queue and DLQ provisioned in `docker-compose` via LocalStack; max receive count = 3; DLQ retention = 14 days +- [ ] `ILayoutCompilerClient` stub returns a hardcoded `CompiledLayout` derived from the input `RawLayout` elements (names and labels passed through; no real CSS generation) +- [ ] `ILayoutValidationClient` stub returns `ValidationResult { IsValid = true, Issues = [] }` +- [ ] Service polls SQS queue and processes messages: fetch raw layout → compile → validate → store compiled → notify +- [ ] On validation failure: calls `IRawLayoutStatusWriter.UpdateValidationResultAsync`; does not store a compiled layout; does not notify client +- [ ] On success: calls `ICompiledLayoutRepository.SaveAsync` then `INotificationPublisher.PublishLayoutReadyAsync` +- [ ] Failed processing attempts are logged as errors; DLQ arrival is logged as an error +- [ ] `RawLayoutService` SQS trigger wired up (replaces no-op stub from Task 4) +- [ ] `INotificationPublisher` stub (no-op) injected; notification wiring deferred to Task 9 +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; structured log events emitted on each SQS message processed (success and failure); API integration tests assert expected log events and no unexpected warnings or errors +- [ ] Unit tests cover success path, validation failure path, and SQS message retry behaviour +- [ ] API integration tests cover the end-to-end processing pipeline (stub compiler and validator in use): + + ```gherkin + Given a raw layout with valid elements has been saved via POST /layouts/raw + When LayoutProcessingService dequeues and processes the SQS message + Then GET /layouts/compiled/active returns a CompiledLayout for the user + And the CompiledLayout.Elements match the commands from the raw layout + + Given a raw layout with a command missing a Label has been saved via POST /layouts/raw + When LayoutProcessingService dequeues and processes the SQS message + Then no compiled layout is stored for the user + And GET /layouts/raw/{id} returns a RawLayout with a non-null ValidationResult + And ValidationResult.IsValid is false + ``` + +### Task 6 — LayoutCompilerService (Lambda) ([ADR-171](https://jodasoft.atlassian.net/browse/ADR-171)) + +Implement `AdaptiveRemote.Backend.LayoutCompilerService` as a Native AOT Lambda, replacing +the stub injected in Task 5. + +- [ ] `AdaptiveRemote.Backend.LayoutCompilerService` project created as a .NET 10 Lambda function with Native AOT; included in `backend.slnf` +- [ ] `POST /compile` accepts `RawLayout`, returns `CompiledLayout`; grid positions and CSS overrides resolved into `CssDefinitions`; layout elements stripped of authoring properties +- [ ] `POST /compile/preview` accepts `IReadOnlyList`, returns `PreviewLayout` with rendered HTML and CSS +- [ ] All serialization uses `LayoutContractsJsonContext`; no reflection-based JSON +- [ ] Lambda runs locally via LocalStack; `LayoutProcessingService` `ILayoutCompilerClient` stub replaced with real Lambda-backed implementation +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; Lambda invocation events logged; API integration tests assert no warnings or errors during successful compilation +- [ ] Unit tests cover compilation logic for representative layout inputs +- [ ] API integration tests cover both endpoints (called directly via Lambda Function URL): + + ```gherkin + Given a valid RawLayout with one command element at grid position (1, 1) + When a test client calls POST /compile with the RawLayout + Then the response is 200 OK + And the body deserializes to a CompiledLayout + And CompiledLayout.Elements contains a CommandDefinitionDto matching the input command + And CompiledLayout.CssDefinitions contains a CSS rule for the element's grid position + And the CommandDefinitionDto does not contain grid or CSS authoring properties + + Given a valid list of RawLayoutElementDto + When a test client calls POST /compile/preview with the elements + Then the response is 200 OK + And the body deserializes to a PreviewLayout + And PreviewLayout.RenderedHtml is non-empty + And PreviewLayout.RenderedCss is non-empty + ``` + +### Task 7 — LayoutValidationService (Lambda) ([ADR-172](https://jodasoft.atlassian.net/browse/ADR-172)) + +Implement `AdaptiveRemote.Backend.LayoutValidationService` as a Native AOT Lambda, replacing +the stub injected in Task 5. + +- [ ] `AdaptiveRemote.Backend.LayoutValidationService` project created as a .NET 10 Lambda function with Native AOT; included in `backend.slnf` +- [ ] `POST /validate` accepts `CompiledLayout`, returns `ValidationResult` +- [ ] Validates that all `CommandDefinitionDto` entries have non-empty `Name`, `Label`, and `SpeakPhrase`; duplicate `CssId` values within a layout are flagged +- [ ] Additional validation rules deferred pending editor epic (see Open Questions) +- [ ] All serialization uses `LayoutContractsJsonContext`; no reflection-based JSON +- [ ] `LayoutProcessingService` `ILayoutValidationClient` stub replaced with real Lambda-backed implementation +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; validation outcome (pass/fail, issue count) emitted as a structured log event; API integration tests assert no unexpected warnings or errors +- [ ] Unit tests cover valid layout, missing required fields, and duplicate CSS IDs +- [ ] API integration tests cover both valid and invalid cases (called directly via Lambda Function URL): + + ```gherkin + Given a CompiledLayout where all commands have non-empty Name, Label, and SpeakPhrase + And all CssId values are unique + When a test client calls POST /validate with the CompiledLayout + Then the response is 200 OK + And ValidationResult.IsValid is true + And ValidationResult.Issues is empty + + Given a CompiledLayout where one command has an empty Label + When a test client calls POST /validate with the CompiledLayout + Then the response is 200 OK + And ValidationResult.IsValid is false + And ValidationResult.Issues contains one issue referencing the empty Label + + Given a CompiledLayout where two elements share the same CssId + When a test client calls POST /validate with the CompiledLayout + Then the response is 200 OK + And ValidationResult.IsValid is false + And ValidationResult.Issues contains one issue referencing the duplicate CssId + ``` + +### Task 8 — CompiledLayoutService with DynamoDB ([ADR-173](https://jodasoft.atlassian.net/browse/ADR-173)) + +Replace the static hardcoded response in `CompiledLayoutService` with real DynamoDB storage and active layout management. + +- [ ] `ICompiledLayoutRepository` implemented against DynamoDB +- [ ] `GetActiveForUserAsync`, `ListByUserAsync`, `GetByIdAsync`, `SaveAsync`, and `SetActiveAsync` all implemented and unit tested +- [ ] `SetActiveAsync` sets `IsActive = true` on the specified layout and clears it on all other layouts for the same user (via DynamoDB transaction or conditional writes) +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; API integration tests assert no warnings or errors during normal storage operations +- [ ] All compiled layout endpoints functional end-to-end with DynamoDB +- [ ] `PUT /layouts/compiled/{id}/active` endpoint implemented +- [ ] Previously hardcoded layout seeded into DynamoDB on first run so the client continues to work +- [ ] API integration tests cover the 404 case and active layout switching: + + ```gherkin + Given no compiled layout exists for the user + When a test client calls GET /layouts/compiled/active + Then the response is 404 Not Found + + Given a user has two compiled layouts and layout B is active + When a test client calls PUT /layouts/compiled/{A}/active + Then the response is 200 OK + And GET /layouts/compiled/active returns layout A + And layout B is no longer active + ``` + +### Task 9 — NotificationService (SSE) ([ADR-174](https://jodasoft.atlassian.net/browse/ADR-174)) + +Implement `AdaptiveRemote.Backend.NotificationService` with SSE push for `layout-saved` and `layout-ready` events. + +- [ ] `AdaptiveRemote.Backend.NotificationService` project created; included in `backend.slnf` +- [ ] `GET /notifications/layouts/stream` SSE endpoint implemented; connection is keyed to the authenticated user +- [ ] `INotificationPublisher` implementation sends `layout-saved` events to connected editors and `layout-ready` events to connected clients for the relevant user +- [ ] Standard SSE retry mechanism honoured; disconnected clients reconnect automatically +- [ ] `RawLayoutService` and `LayoutProcessingService` notification stubs replaced with real `INotificationPublisher` implementation +- [ ] Follows the logging, metrics, and health endpoint pattern established in Task 2; SSE connection lifecycle events (connect, disconnect, reconnect) emitted as structured log events +- [ ] Unit tests cover event publishing and per-user fan-out + + ```gherkin + Given a client is connected to the SSE stream + And the administrator publishes a new compiled layout + When LayoutProcessingService completes successfully + Then the client receives a layout-ready SSE event + And fetching GET /layouts/compiled/active returns the new layout + + Given two editor sessions are open for the same layout + When one editor saves the layout + Then both editors receive a layout-saved SSE event + ``` + +--- + +### [ADR-162](https://jodasoft.atlassian.net/browse/ADR-162): Client-side layout consumption + +Implement layout download, local caching, compiled layout application, and auto-update on `layout-ready` SSE event in the client app. Includes the mapping from `CommandDefinitionDto` → runtime `Command` types and the idle-detection policy for deferred layout application. + +### [ADR-163](https://jodasoft.atlassian.net/browse/ADR-163): Blazor WebAssembly editor + +Implement the administrator-facing editor application: text editor for raw layout JSON, live preview via `POST /layouts/raw/preview`, and layout management (create, update, delete, set active). + +### [ADR-164](https://jodasoft.atlassian.net/browse/ADR-164): AWS CI/CD deployment pipeline + +Containerize all ECS Fargate services; package Lambda functions; define infrastructure as code (ECS task definitions, API Gateway configuration, DynamoDB tables, SQS queues); automate deployment to AWS on merge to main. Includes wiring the CloudWatch metrics sink (replacing the local structured-log-based signal established in Task 2), CloudWatch alarms (DLQ depth > 0, error rate thresholds), and ECS health check integration. + +### [ADR-165](https://jodasoft.atlassian.net/browse/ADR-165): Stress testing and availability + +Define bot account provisioning via Cognito API; implement load generation scenarios; instrument availability and latency metrics; establish baseline SLOs. diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj new file mode 100644 index 00000000..d8f66699 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + AdaptiveRemote.Backend.ApiTests + + + + + + + + + + + + + + + + + + diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature new file mode 100644 index 00000000..392864ad --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature @@ -0,0 +1,10 @@ +Feature: CompiledLayoutService Endpoints + +Scenario: Get active compiled layout + Given CompiledLayoutService is running + When a test client calls GET /layouts/compiled/active + Then the response is 200 OK + And the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext + And the CompiledLayout contains the expected hardcoded commands + And the service logs contain a request log entry for GET /layouts/compiled/active + And the service logs contain no warnings or errors diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs new file mode 100644 index 00000000..72246dc2 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/CompiledLayoutEndpoints.feature.cs @@ -0,0 +1,171 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CompiledLayoutServiceEndpointsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "CompiledLayoutService Endpoints", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CompiledLayoutEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/CompiledLayoutEndpoints.feature.ndjson", 3); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get active compiled layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get active compiled layout")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Endpoints")] + public async global::System.Threading.Tasks.Task GetActiveCompiledLayout() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get active compiled layout", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 3 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.WhenAsync("a test client calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 6 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 7 + await testRunner.AndAsync("the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 8 + await testRunner.AndAsync("the CompiledLayout contains the expected hardcoded commands", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 9 + await testRunner.AndAsync("the service logs contain a request log entry for GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync("the service logs contain no warnings or errors", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature new file mode 100644 index 00000000..0437ee86 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature @@ -0,0 +1,7 @@ +Feature: Health Endpoints + +Scenario: Get service health status + Given CompiledLayoutService is running + When a test client calls GET /health + Then the response is 200 OK + And the body contains the service name and version diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs new file mode 100644 index 00000000..fb6375e0 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/HealthEndpoints.feature.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class HealthEndpointsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "Health Endpoints", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "HealthEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/HealthEndpoints.feature.ndjson", 3); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Get service health status")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get service health status")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Health Endpoints")] + public async global::System.Threading.Tasks.Task GetServiceHealthStatus() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get service health status", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 3 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.WhenAsync("a test client calls GET /health", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 6 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 7 + await testRunner.AndAsync("the body contains the service name and version", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs new file mode 100644 index 00000000..1481184b --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Text.Json; +using AdaptiveRemote.Backend.ApiTests.Support; +using AdaptiveRemote.Contracts; +using FluentAssertions; +using Reqnroll; + +namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; + +[Binding] +public class CommonSteps : IDisposable +{ + private readonly ServiceFixture _fixture = new(); + private HttpResponseMessage? _response; + private string? _responseBody; + + [Given(@"CompiledLayoutService is running")] + public void GivenCompiledLayoutServiceIsRunning() + { + _fixture.StartService(); + } + + [When(@"a test client calls GET (.*)")] + public async Task WhenATestClientCallsGet(string endpoint) + { + _response = await _fixture.HttpClient.GetAsync(endpoint); + _responseBody = await _response.Content.ReadAsStringAsync(); + } + + [Then(@"the response is (\d+) OK")] + public void ThenTheResponseIsOk(int statusCode) + { + _response.Should().NotBeNull(); + ((int)_response!.StatusCode).Should().Be(statusCode); + } + + [Then(@"the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext")] + public void ThenTheBodyDeserializesToValidCompiledLayout() + { + _responseBody.Should().NotBeNullOrEmpty(); + + CompiledLayout? layout = JsonSerializer.Deserialize( + _responseBody!, + LayoutContractsJsonContext.Default.CompiledLayout); + + layout.Should().NotBeNull(); + layout!.Id.Should().NotBeEmpty(); + layout.Elements.Should().NotBeEmpty(); + } + + [Then(@"the CompiledLayout contains the expected hardcoded commands")] + public void ThenTheCompiledLayoutContainsExpectedCommands() + { + _responseBody.Should().NotBeNullOrEmpty(); + + CompiledLayout? layout = JsonSerializer.Deserialize( + _responseBody!, + LayoutContractsJsonContext.Default.CompiledLayout); + + layout.Should().NotBeNull(); + + // Verify key commands from StaticCommandGroupProvider exist + List commands = ExtractAllCommands(layout!.Elements); + + commands.Should().Contain(c => c.Name == "Up" && c.Type == CommandType.TiVo); + commands.Should().Contain(c => c.Name == "Select" && c.Type == CommandType.TiVo); + commands.Should().Contain(c => c.Name == "Power" && c.Type == CommandType.IR); + commands.Should().Contain(c => c.Name == "Learn" && c.Type == CommandType.Lifecycle); + commands.Should().Contain(c => c.Name == "Exit" && c.Type == CommandType.Lifecycle); + } + + [Then(@"the service logs contain a request log entry for GET (.*)")] + public void ThenTheServiceLogsContainRequestLogEntry(string endpoint) + { + string logs = _fixture.GetLogs(); + logs.Should().Contain(endpoint); + } + + [Then(@"the service logs contain no warnings or errors")] + public void ThenTheServiceLogsContainNoWarningsOrErrors() + { + string logs = _fixture.GetLogs(); + logs.Should().NotContain("WARNING", "service should not log warnings"); + logs.Should().NotContain("ERROR", "service should not log errors"); + logs.Should().NotContain("Exception", "service should not log exceptions"); + } + + [Then(@"the body contains the service name and version")] + public void ThenTheBodyContainsServiceNameAndVersion() + { + _responseBody.Should().NotBeNullOrEmpty(); + + HealthResponse? healthResponse = JsonSerializer.Deserialize( + _responseBody!, + LayoutContractsJsonContext.Default.HealthResponse); + + healthResponse.Should().NotBeNull(); + healthResponse!.ServiceName.Should().Be("CompiledLayoutService"); + healthResponse.Version.Should().NotBeNullOrEmpty(); + healthResponse.Status.Should().Be("healthy"); + } + + private static List ExtractAllCommands(IReadOnlyList elements) + { + List commands = new(); + + foreach (LayoutElementDto element in elements) + { + if (element is CommandDefinitionDto command) + { + commands.Add(command); + } + else if (element is LayoutGroupDefinitionDto group) + { + commands.AddRange(ExtractAllCommands(group.Children)); + } + } + + return commands; + } + + public void Dispose() + { + _response?.Dispose(); + _fixture.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs new file mode 100644 index 00000000..ea39cfa4 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using System.Text; + +namespace AdaptiveRemote.Backend.ApiTests.Support; + +/// +/// Manages the lifecycle of CompiledLayoutService for API integration tests. +/// Starts the service process and captures structured log output. +/// +public class ServiceFixture : IDisposable +{ + private Process? _serviceProcess; + private readonly StringBuilder _logOutput = new(); + private readonly object _logLock = new(); + + public string ServiceUrl { get; private set; } = "http://localhost:5000"; + public HttpClient HttpClient { get; private set; } = null!; + + public void StartService() + { + if (_serviceProcess != null) + { + return; // Already started + } + + // Find the repository root by looking for the .git directory + string currentDir = Directory.GetCurrentDirectory(); + string? repoRoot = currentDir; + while (repoRoot != null && !Directory.Exists(Path.Combine(repoRoot, ".git"))) + { + repoRoot = Directory.GetParent(repoRoot)?.FullName; + } + + if (repoRoot == null) + { + throw new InvalidOperationException("Could not find repository root (no .git directory found)"); + } + + string projectPath = Path.Combine( + repoRoot, + "src", "AdaptiveRemote.Backend.CompiledLayoutService", + "AdaptiveRemote.Backend.CompiledLayoutService.csproj"); + + if (!File.Exists(projectPath)) + { + throw new InvalidOperationException($"Project file not found at: {projectPath}"); + } + + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + Arguments = $"run --project \"{projectPath}\" --no-build", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + Environment = + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["ASPNETCORE_URLS"] = ServiceUrl + } + }; + + _serviceProcess = new Process { StartInfo = startInfo }; + + _serviceProcess.OutputDataReceived += (sender, args) => + { + if (args.Data != null) + { + lock (_logLock) + { + _logOutput.AppendLine(args.Data); + } + } + }; + + _serviceProcess.ErrorDataReceived += (sender, args) => + { + if (args.Data != null) + { + lock (_logLock) + { + _logOutput.AppendLine($"ERROR: {args.Data}"); + } + } + }; + + _serviceProcess.Start(); + _serviceProcess.BeginOutputReadLine(); + _serviceProcess.BeginErrorReadLine(); + + // Wait for service to be ready - poll for health endpoint + HttpClient = new HttpClient { BaseAddress = new Uri(ServiceUrl) }; + + bool isReady = false; + for (int i = 0; i < 30 && !_serviceProcess.HasExited; i++) + { + DateTime startTime = DateTime.Now; + try + { + HttpResponseMessage response = HttpClient.GetAsync("/health").Result; + if (response.IsSuccessStatusCode) + { + isReady = true; + break; + } + } + catch + { + // Service not ready yet + } + + TimeSpan sleepTime = TimeSpan.FromSeconds(1000) - (DateTime.Now - startTime); + if (sleepTime > TimeSpan.Zero) + { + Thread.Sleep(sleepTime); + } + } + + if (!isReady) + { + string logs = GetLogs(); + throw new InvalidOperationException($"Service failed to start within 30 seconds. Logs:\n{logs}"); + } + } + + public string GetLogs() + { + lock (_logLock) + { + return _logOutput.ToString(); + } + } + + public void Dispose() + { + if (_serviceProcess != null && !_serviceProcess.HasExited) + { + _serviceProcess.Kill(entireProcessTree: true); + _serviceProcess.WaitForExit(5000); + _serviceProcess.Dispose(); + } + + HttpClient?.Dispose(); + GC.SuppressFinalize(this); + } +}