From d4805b06e65343d3c13efa445ed5a0efae6ebd56 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Wed, 13 May 2026 09:38:52 +0300 Subject: [PATCH] Changes --- .gitea/workflows/build.yml | 11 +- cv-cleanup-job/Dockerfile | 22 +++ .../Models/CvStorageCleanupParameters.cs | 19 +++ cv-cleanup-job/Program.cs | 21 +++ .../Tasks/CvStorageCleanupJobTask.cs | 134 ++++++++++++++++++ cv-cleanup-job/appsettings.json | 25 ++++ cv-cleanup-job/cv-cleanup-job.csproj | 20 +++ docker-compose/.env.template | 19 ++- docker-compose/docker-compose.production.yml | 22 +++ docker-compose/docker-compose.staging.yml | 22 +++ docker-compose/docker-compose.yml | 26 ++++ .../Scheduling/JobSchedulerHostedService.cs | 133 +++++++++++++++++ job-scheduler/Tasks/IJobTask.cs | 14 ++ job-scheduler/job-scheduler.csproj | 17 +++ myAi.sln | 16 +++ 15 files changed, 514 insertions(+), 7 deletions(-) create mode 100644 cv-cleanup-job/Dockerfile create mode 100644 cv-cleanup-job/Models/CvStorageCleanupParameters.cs create mode 100644 cv-cleanup-job/Program.cs create mode 100644 cv-cleanup-job/Tasks/CvStorageCleanupJobTask.cs create mode 100644 cv-cleanup-job/appsettings.json create mode 100644 cv-cleanup-job/cv-cleanup-job.csproj create mode 100644 job-scheduler/Scheduling/JobSchedulerHostedService.cs create mode 100644 job-scheduler/Tasks/IJobTask.cs create mode 100644 job-scheduler/job-scheduler.csproj diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 7a63de9..588900d 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -12,6 +12,7 @@ env: CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api RAG_API_IMAGE: apps/myai-rag-api WEB_IMAGE: apps/myai-web + JOB_IMAGE: apps/myai-job IMAGE_TAG: staging jobs: @@ -47,6 +48,10 @@ jobs: run: | docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . + - name: Build Job worker image + run: | + docker build -f cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${JOB_IMAGE}:${IMAGE_TAG}" . + - name: Push API image run: | docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" @@ -61,4 +66,8 @@ jobs: - name: Push Web image run: | - docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file + docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" + + - name: Push Job worker image + run: | + docker push "${REGISTRY_HOST}/${JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file diff --git a/cv-cleanup-job/Dockerfile b/cv-cleanup-job/Dockerfile new file mode 100644 index 0000000..4763ec3 --- /dev/null +++ b/cv-cleanup-job/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +COPY cv-cleanup-job/cv-cleanup-job.csproj cv-cleanup-job/ +COPY api-models/api-models.csproj api-models/ +COPY shared-models/shared-models.csproj shared-models/ + +RUN dotnet restore cv-cleanup-job/cv-cleanup-job.csproj + +COPY cv-cleanup-job/ cv-cleanup-job/ +COPY api-models/ api-models/ +COPY shared-models/ shared-models/ + +RUN dotnet publish cv-cleanup-job/cv-cleanup-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final +WORKDIR /app + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "cv-cleanup-job.dll"] diff --git a/cv-cleanup-job/Models/CvStorageCleanupParameters.cs b/cv-cleanup-job/Models/CvStorageCleanupParameters.cs new file mode 100644 index 0000000..11d5084 --- /dev/null +++ b/cv-cleanup-job/Models/CvStorageCleanupParameters.cs @@ -0,0 +1,19 @@ +namespace CvCleanupJob.Models; + +/// +/// Parameters for the CvStorageCleanup scheduled task (bound from Jobs:Tasks:n:Parameters). +/// +public sealed class CvStorageCleanupParameters +{ + /// Maximum total size of retained CV files (defaults to 40 MiB). + public double MaxTotalSizeMegabytes { get; set; } = 40; + + /// File glob within the storage directory (default matches cached CV PDFs). + public string SearchPattern { get; set; } = "*.pdf"; + + /// + /// When true, only files whose base name is alphanumeric (same convention as API CV cache) are considered. + /// Set false if you store other PDFs under the same folder and want the glob to apply to all of them. + /// + public bool RestrictToCvStyleFileNamesOnly { get; set; } = true; +} diff --git a/cv-cleanup-job/Program.cs b/cv-cleanup-job/Program.cs new file mode 100644 index 0000000..44b92a9 --- /dev/null +++ b/cv-cleanup-job/Program.cs @@ -0,0 +1,21 @@ +using CvCleanupJob.Tasks; +using JobScheduler.Scheduling; +using JobScheduler.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Models.Settings; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton>(sp => new IJobTask[] +{ + sp.GetRequiredService(), +}); + +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/cv-cleanup-job/Tasks/CvStorageCleanupJobTask.cs b/cv-cleanup-job/Tasks/CvStorageCleanupJobTask.cs new file mode 100644 index 0000000..dfaa7ad --- /dev/null +++ b/cv-cleanup-job/Tasks/CvStorageCleanupJobTask.cs @@ -0,0 +1,134 @@ +using CvCleanupJob.Models; +using JobScheduler.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Models.Settings; + +namespace CvCleanupJob.Tasks; + +/// +/// Deletes oldest cached CV files until the total size of remaining files is at or below the configured budget. +/// +public sealed class CvStorageCleanupJobTask : IJobTask +{ + private readonly FileStorageSettings _fileStorage; + private readonly ILogger _logger; + + public CvStorageCleanupJobTask(IOptions fileStorage, ILogger logger) + { + _fileStorage = fileStorage.Value; + _logger = logger; + } + + public string TaskType => "CvStorageCleanup"; + + public Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var parameters = parametersSection.Get() + ?? new CvStorageCleanupParameters(); + + if (parameters.MaxTotalSizeMegabytes <= 0) + { + _logger.LogWarning( + "CvStorageCleanup skipped: MaxTotalSizeMegabytes must be positive (got {Value}).", + parameters.MaxTotalSizeMegabytes); + return Task.CompletedTask; + } + + var root = ResolveStorageRoot(_fileStorage.Path); + if (!Directory.Exists(root)) + { + _logger.LogDebug("CvStorageCleanup: directory does not exist yet: {Path}", root); + return Task.CompletedTask; + } + + var maxBytes = (long)Math.Round(parameters.MaxTotalSizeMegabytes * 1024d * 1024d, MidpointRounding.AwayFromZero); + + var candidatePaths = Directory + .EnumerateFiles(root, parameters.SearchPattern, SearchOption.TopDirectoryOnly) + .Where(p => !parameters.RestrictToCvStyleFileNamesOnly || IsCvStyleFileName(Path.GetFileName(p))) + .Select(p => new FileInfo(p)) + .Where(f => f.Exists) + .ToList(); + + if (candidatePaths.Count == 0) + { + _logger.LogDebug("CvStorageCleanup: no files matched under {Path}.", root); + return Task.CompletedTask; + } + + long totalBytes = candidatePaths.Sum(f => f.Length); + if (totalBytes <= maxBytes) + { + _logger.LogInformation( + "CvStorageCleanup: within budget ({TotalMb:F2} MiB / {MaxMb:F2} MiB). No files removed.", + totalBytes / (1024d * 1024d), + parameters.MaxTotalSizeMegabytes); + return Task.CompletedTask; + } + + var orderedOldestFirst = candidatePaths + .OrderBy(f => f.LastWriteTimeUtc) + .ThenBy(f => f.FullName, StringComparer.Ordinal) + .ToList(); + + long remaining = totalBytes; + var deleted = 0; + long freedBytes = 0; + + while (remaining > maxBytes && orderedOldestFirst.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + var oldest = orderedOldestFirst[0]; + orderedOldestFirst.RemoveAt(0); + + try + { + var len = oldest.Length; + oldest.Delete(); + remaining -= len; + freedBytes += len; + deleted++; + _logger.LogInformation( + "CvStorageCleanup: deleted oldest file {File} ({SizeKb} KiB, remaining aggregate ~{RemainMb:F2} MiB).", + oldest.Name, + len / 1024d, + remaining / (1024d * 1024d)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CvStorageCleanup: could not delete {File}", oldest.FullName); + } + } + + _logger.LogInformation( + "CvStorageCleanup finished: removed {Deleted} file(s), freed ~{FreedMb:F2} MiB; size now ~{RemainMb:F2} MiB (budget {MaxMb:F2} MiB).", + deleted, + freedBytes / (1024d * 1024d), + remaining / (1024d * 1024d), + parameters.MaxTotalSizeMegabytes); + + return Task.CompletedTask; + } + + /// Matches API behavior for cached CV paths (alphanumeric stem). + internal static bool IsCvStyleFileName(string fileName) + { + var stem = Path.GetFileNameWithoutExtension(fileName); + return stem.Length > 0 && stem.All(char.IsLetterOrDigit); + } + + internal static string ResolveStorageRoot(string configuredPath) + { + if (Path.IsPathRooted(configuredPath)) + { + return configuredPath; + } + + return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), configuredPath)); + } +} diff --git a/cv-cleanup-job/appsettings.json b/cv-cleanup-job/appsettings.json new file mode 100644 index 0000000..61b78bd --- /dev/null +++ b/cv-cleanup-job/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "FileStorage": { + "Path": "Files" + }, + "Jobs": { + "Tasks": [ + { + "TaskType": "CvStorageCleanup", + "Enabled": true, + "Interval": "01:00:00", + "Parameters": { + "MaxTotalSizeMegabytes": 40, + "SearchPattern": "*.pdf", + "RestrictToCvStyleFileNamesOnly": true + } + } + ] + } +} diff --git a/cv-cleanup-job/cv-cleanup-job.csproj b/cv-cleanup-job/cv-cleanup-job.csproj new file mode 100644 index 0000000..0148dc2 --- /dev/null +++ b/cv-cleanup-job/cv-cleanup-job.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + CvCleanupJob + cv-cleanup-job + + + + + + + + + + + + diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 4fa4855..7a798cd 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -39,10 +39,6 @@ Database__User=sa Database__Password= Database__TrustServerCertificate=true -# Internal API protection -InternalApi__ApiKey= -InternalApi__RequireApiKey=false - # RAG settings Rag__MaxFileSizeMb=8 Rag__ChunkSize=900 @@ -57,9 +53,15 @@ Matcher__TopK=10 Matcher__DeepScoreTopN=5 Matcher__MaxJobTextChars=60000 -# RagApi used by cv-matcher -RagApi__BaseUrl=http://rag-api:8082 +# RagApi credentials +RagApi__BaseUrl=http://rag-api:8080 RagApi__InternalApiKey= +RagApi__RequireApiKey=true + +# CvMatcher credentials +CvMatcherApi__BaseUrl=http://cv-matcher-api:8080 +CvMatcherApi__InternalApiKey= +CvMatcherApi__RequireApiKey=true # Captcha Captcha__Provider=Recaptcha @@ -67,6 +69,11 @@ Captcha__SecretKey= Captcha__PublicKey= Captcha__MinimumScore=0.5 +# Job worker (scheduled tasks: CV file storage cleanup, etc.) +Jobs__CvStorageCleanupEnabled=true +Jobs__CvStorageCleanupInterval=01:00:00 +Jobs__CvStorageMaxTotalSizeMegabytes=40 + # File Storage FileStorage__Path=/opt/myai/files FileStorage__DefaultFileName= diff --git a/docker-compose/docker-compose.production.yml b/docker-compose/docker-compose.production.yml index c331632..96f27a0 100644 --- a/docker-compose/docker-compose.production.yml +++ b/docker-compose/docker-compose.production.yml @@ -206,6 +206,28 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + job: + image: registry.easysoft.ro/apps/myai-job:production + container_name: myai-job + depends_on: + - api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} + - FileStorage__Path=Files + - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} + - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} + - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} + - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} + - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} + volumes: + - /opt/myai/files:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + web: image: registry.easysoft.ro/apps/myai-web:production container_name: myai-web diff --git a/docker-compose/docker-compose.staging.yml b/docker-compose/docker-compose.staging.yml index 2d36848..d261502 100644 --- a/docker-compose/docker-compose.staging.yml +++ b/docker-compose/docker-compose.staging.yml @@ -206,6 +206,28 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + job: + image: registry.easysoft.ro/apps/myai-job:staging + container_name: myai-job + depends_on: + - api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + - FileStorage__Path=Files + - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} + - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} + - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} + - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} + - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} + volumes: + - /opt/myai/files:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + web: image: registry.easysoft.ro/apps/myai-web:staging container_name: myai-web diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 697e085..2004176 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -226,6 +226,32 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + job: + build: + context: .. + dockerfile: cv-cleanup-job/Dockerfile + container_name: myai-job + depends_on: + - api + env_file: + - .env + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - FileStorage__Path=${FileStorage__Path:-Files} + - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} + - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} + - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} + - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} + - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} + volumes: + - ${FileStorage__Path:-../Files}:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + web: build: context: .. diff --git a/job-scheduler/Scheduling/JobSchedulerHostedService.cs b/job-scheduler/Scheduling/JobSchedulerHostedService.cs new file mode 100644 index 0000000..d29dcc9 --- /dev/null +++ b/job-scheduler/Scheduling/JobSchedulerHostedService.cs @@ -0,0 +1,133 @@ +using JobScheduler.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace JobScheduler.Scheduling; + +/// +/// Loads Jobs:Tasks and runs each enabled task on its own interval. +/// +public sealed class JobSchedulerHostedService : BackgroundService +{ + private readonly IConfiguration _configuration; + private readonly IReadOnlyDictionary _tasksByType; + private readonly ILogger _logger; + + public JobSchedulerHostedService( + IConfiguration configuration, + IEnumerable tasks, + ILogger logger) + { + _configuration = configuration; + _tasksByType = tasks.ToDictionary(t => t.TaskType, t => t, StringComparer.OrdinalIgnoreCase); + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var section = _configuration.GetSection("Jobs:Tasks"); + var children = section.GetChildren().ToList(); + + if (children.Count == 0) + { + _logger.LogWarning("No Jobs:Tasks configured; scheduler idle."); + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + return; + } + + var loops = new List(); + foreach (var taskSection in children) + { + if (!taskSection.GetValue("Enabled", true)) + { + _logger.LogInformation("Job task disabled: {Section}", taskSection.Path); + continue; + } + + var taskType = taskSection["TaskType"]; + if (string.IsNullOrWhiteSpace(taskType)) + { + _logger.LogWarning("Skipping task without TaskType at {Section}", taskSection.Path); + continue; + } + + if (!_tasksByType.TryGetValue(taskType, out var task)) + { + _logger.LogError("No IJobTask registered for TaskType '{TaskType}'.", taskType); + continue; + } + + var interval = ParseInterval(taskSection["Interval"]); + var parameters = taskSection.GetSection("Parameters"); + + loops.Add(RunTaskLoopAsync(taskType, task, parameters, interval, stoppingToken)); + } + + if (loops.Count == 0) + { + _logger.LogWarning("No enabled job tasks to run."); + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + return; + } + + await Task.WhenAll(loops); + } + + private async Task RunTaskLoopAsync( + string taskType, + IJobTask task, + IConfiguration parameters, + TimeSpan interval, + CancellationToken stoppingToken) + { + _logger.LogInformation( + "Starting job loop for {TaskType} every {Interval}.", + taskType, + interval); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await task.ExecuteAsync(parameters, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Job task {TaskType} failed.", taskType); + } + + if (interval <= TimeSpan.Zero) + { + _logger.LogWarning( + "Job task {TaskType} has non-positive Interval; sleeping 1 hour.", + taskType); + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + continue; + } + + await Task.Delay(interval, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Job loop for {TaskType} cancelled.", taskType); + } + } + + private static TimeSpan ParseInterval(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return TimeSpan.FromHours(1); + } + + return TimeSpan.TryParse(value, out var ts) ? ts : TimeSpan.FromHours(1); + } +} diff --git a/job-scheduler/Tasks/IJobTask.cs b/job-scheduler/Tasks/IJobTask.cs new file mode 100644 index 0000000..c613162 --- /dev/null +++ b/job-scheduler/Tasks/IJobTask.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Configuration; + +namespace JobScheduler.Tasks; + +/// +/// A named unit of work invoked by . +/// +public interface IJobTask +{ + /// Matches Jobs:Tasks:*:TaskType in configuration. + string TaskType { get; } + + Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken); +} diff --git a/job-scheduler/job-scheduler.csproj b/job-scheduler/job-scheduler.csproj new file mode 100644 index 0000000..ddb1c78 --- /dev/null +++ b/job-scheduler/job-scheduler.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + JobScheduler + + + + + + + + + + diff --git a/myAi.sln b/myAi.sln index 0684ac2..732b8b7 100644 --- a/myAi.sln +++ b/myAi.sln @@ -30,6 +30,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "startup-helpers", "startup- EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "common-helpers", "common-helpers\common-helpers.csproj", "{4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jobs", "Jobs", "{F1A2B3C4-D5E6-4789-ABCD-EF0123456789}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -80,6 +86,14 @@ Global {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -94,6 +108,8 @@ Global {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {E08A1D43-24A3-4F93-B66A-4230FD8261BA} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}