From 1ba16d1e36d2504323f2c0434f02b5cdc8de6bfa Mon Sep 17 00:00:00 2001 From: Allan Nielsen <2005474+Allann@users.noreply.github.com> Date: Fri, 15 May 2026 13:48:38 +1000 Subject: [PATCH 1/3] Refactor project registry: managed-projects.json with slug, isolated test paths - Rename registry.json -> managed-projects.json (FilePathConstants, GlobalPaths) - Add Slug property to WorkspaceRegistryEntry for direct slug lookup - Add WorkspacePaths-based WorkspaceService constructor with injectable registryFilePath so tests never touch the real global registry - Add CreateProject(slug, name) short overload deriving codebasePath from paths - RegisterEntry deduplicates by path (OrdinalIgnoreCase) and stores slug - App.xaml.cs: update RegistryFile -> ManagedProjectsFile reference - WorkspaceServiceTests: IDisposable cleanup + temp registry path - ConsistencyCheckServiceTests: temp registry path - ProductionReadinessIntegrationTests: temp registry, named params for CreateProject - ConsistencyUiAndDetectionIntegrationTests: temp registry path - ProjectSettingsViewModelTests: temp registry + explicit codebasePath overload Fixes stale temp-dir entries accumulating in the real registry from test runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...nsistencyUiAndDetectionIntegrationTests.cs | 11 +- .../ProductionReadinessIntegrationTests.cs | 23 ++-- .../ConsistencyCheckServiceTests.cs | 8 +- .../WorkspaceServiceTests.cs | 51 +++++--- .../ProjectSettingsViewModelTests.cs | 6 +- ai-dev.core/Features/Workspace/GlobalPaths.cs | 4 +- .../Workspace/WorkspaceRegistryEntry.cs | 12 +- .../Features/Workspace/WorkspaceService.cs | 113 +++++++++++++----- ai-dev.core/FilePathConstants.cs | 2 +- ai-dev.ui.winui/App.xaml.cs | 6 +- 10 files changed, 158 insertions(+), 78 deletions(-) diff --git a/ai-dev-net.tests.integration/ConsistencyUiAndDetectionIntegrationTests.cs b/ai-dev-net.tests.integration/ConsistencyUiAndDetectionIntegrationTests.cs index 2433584..d80cea7 100644 --- a/ai-dev-net.tests.integration/ConsistencyUiAndDetectionIntegrationTests.cs +++ b/ai-dev-net.tests.integration/ConsistencyUiAndDetectionIntegrationTests.cs @@ -12,18 +12,17 @@ namespace AiDevNet.Tests.Integration; public class ConsistencyUiAndDetectionIntegrationTests : IDisposable { private readonly string _rootPath; - private readonly ActiveWorkspaceHolder _holder; private readonly WorkspacePaths _paths; private readonly AtomicFileWriter _fileWriter = new(); private readonly ProjectMutationCoordinator _coordinator = new(); + private readonly string _registryPath; public ConsistencyUiAndDetectionIntegrationTests() { _rootPath = Path.Combine(Path.GetTempPath(), $"consistency-ui-{Guid.NewGuid():N}"); Directory.CreateDirectory(_rootPath); - _holder = new ActiveWorkspaceHolder(); - _holder.Activate(_rootPath); - _paths = _holder.Paths; + _paths = new WorkspacePaths(new RootDir(Path.Combine(_rootPath, ".ai-dev"))); + _registryPath = Path.Combine(_rootPath, "test-managed-projects.json"); } public void Dispose() @@ -35,8 +34,8 @@ public void Dispose() [Fact] public async Task CheckProjectAsync_WhenRawBoardHasDuplicateTaskReference_ReportsRawFinding() { - var workspaceService = new WorkspaceService(_holder, _fileWriter); - workspaceService.CreateProject(_rootPath, "demo-project", "Demo Project", null).ShouldBeOfType>(); + var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); + workspaceService.CreateProject(slug: "demo-project", name: "Demo Project").ShouldBeOfType>(); var projectSlug = new ProjectSlug("demo-project"); var boardPath = _paths.BoardPath(projectSlug); _fileWriter.WriteAllText(boardPath, "{\"columns\":[{\"id\":\"backlog\",\"title\":\"Backlog\",\"taskIds\":[\"task-1\"]},{\"id\":\"review\",\"title\":\"Review\",\"taskIds\":[\"task-1\"]}],\"tasks\":{\"task-1\":{\"id\":\"task-1\",\"title\":\"Investigate failure\",\"priority\":\"normal\"}}}"); diff --git a/ai-dev-net.tests.integration/ProductionReadinessIntegrationTests.cs b/ai-dev-net.tests.integration/ProductionReadinessIntegrationTests.cs index 08f57ac..6fa4893 100644 --- a/ai-dev-net.tests.integration/ProductionReadinessIntegrationTests.cs +++ b/ai-dev-net.tests.integration/ProductionReadinessIntegrationTests.cs @@ -17,20 +17,19 @@ namespace AiDevNet.Tests.Integration; public class ProductionReadinessIntegrationTests : IDisposable { private readonly string _rootPath; - private readonly ActiveWorkspaceHolder _holder; private readonly WorkspacePaths _paths; private readonly AtomicFileWriter _fileWriter = new(); private readonly ProjectMutationCoordinator _coordinator = new(); private readonly string _templatesDir; + private readonly string _registryPath; public ProductionReadinessIntegrationTests() { _rootPath = Path.Combine(Path.GetTempPath(), $"prod-ready-{Guid.NewGuid():N}"); Directory.CreateDirectory(_rootPath); - _holder = new ActiveWorkspaceHolder(); - _holder.Activate(_rootPath); - _paths = _holder.Paths; + _paths = new WorkspacePaths(new RootDir(Path.Combine(_rootPath, ".ai-dev"))); _templatesDir = Path.Combine(Path.GetTempPath(), $"templates-{Guid.NewGuid():N}"); + _registryPath = Path.Combine(_rootPath, "test-managed-projects.json"); } public void Dispose() @@ -142,14 +141,14 @@ public void AtomicFileWriter_WhenOverwritingFile_ReplacesContent() [Fact] public void WorkspaceService_CreateProject_UsesAtomicWritesForArtifacts() { - var service = new WorkspaceService(_holder, _fileWriter); + var service = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); - var result = service.CreateProject(_rootPath, "demo-project", "Demo Project", "Main app"); + var result = service.CreateProject(slug: "demo-project", name: "Demo Project", description: "Main app"); result.ShouldBeOfType>(); File.Exists(_paths.ProjectJsonPath(new ProjectSlug("demo-project"))).ShouldBeTrue(); File.Exists(_paths.BoardPath(new ProjectSlug("demo-project"))).ShouldBeTrue(); - File.Exists(GlobalPaths.RegistryFile).ShouldBeTrue(); + File.Exists(_registryPath).ShouldBeTrue(); } [Fact] @@ -166,8 +165,8 @@ public void AgentService_CreateAgent_WritesFilesAtomically() modelRegistry, NullLogger.Instance); var projectSlug = new ProjectSlug("demo-project"); - var workspaceService = new WorkspaceService(_holder, _fileWriter); - workspaceService.CreateProject(_rootPath, projectSlug.Value, "Demo Project", null).ShouldBeOfType>(); + var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); + workspaceService.CreateProject(slug: projectSlug.Value, name: "Demo Project").ShouldBeOfType>(); Directory.CreateDirectory(_templatesDir); File.WriteAllText(Path.Combine(_templatesDir, "generic-standard.json"), @@ -185,10 +184,10 @@ public void AgentService_CreateAgent_WritesFilesAtomically() public void KbAndPlaybookServices_SaveAtomically() { var projectSlug = new ProjectSlug("demo-project"); - var workspaceService = new WorkspaceService(_holder, _fileWriter); - workspaceService.CreateProject(_rootPath, projectSlug.Value, "Demo Project", null).ShouldBeOfType>(); + var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); + workspaceService.CreateProject(slug: projectSlug.Value, name: "Demo Project").ShouldBeOfType>(); - var kbService = new KbService(_paths, _fileWriter, _coordinator); + var kbService= new KbService(_paths, _fileWriter, _coordinator); var playbookService = new PlaybookService(_paths, _fileWriter, _coordinator); kbService.Save(projectSlug, "deployment", "# Deployment\n\nSteps").ShouldBeOfType>(); diff --git a/ai-dev-net.tests.unit/ConsistencyCheckServiceTests.cs b/ai-dev-net.tests.unit/ConsistencyCheckServiceTests.cs index 7d48687..a24c491 100644 --- a/ai-dev-net.tests.unit/ConsistencyCheckServiceTests.cs +++ b/ai-dev-net.tests.unit/ConsistencyCheckServiceTests.cs @@ -190,11 +190,11 @@ private static (WorkspacePaths paths, WorkspaceService workspace) CreateServices { var codebasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(codebasePath); - var holder = new ActiveWorkspaceHolder(); - holder.Activate(codebasePath); - var workspace = new WorkspaceService(holder, new AtomicFileWriter()); + var registryPath = Path.Combine(codebasePath, "test-managed-projects.json"); + var paths = new WorkspacePaths(new RootDir(Path.Combine(codebasePath, ".ai-dev"))); + var workspace = new WorkspaceService(paths, new AtomicFileWriter(), registryFilePath: registryPath); workspace.CreateProject(codebasePath, "demo-project", "Demo Project", null).ShouldBeOfType>(); - return (holder.Paths, workspace); + return (paths, workspace); } private sealed class PassingDispatcher : IDomainEventDispatcher diff --git a/ai-dev-net.tests.unit/WorkspaceServiceTests.cs b/ai-dev-net.tests.unit/WorkspaceServiceTests.cs index 0e138cc..568f76d 100644 --- a/ai-dev-net.tests.unit/WorkspaceServiceTests.cs +++ b/ai-dev-net.tests.unit/WorkspaceServiceTests.cs @@ -1,13 +1,30 @@ namespace AiDevNet.Tests.Unit; -public class WorkspaceServiceTests +public class WorkspaceServiceTests : IDisposable { + private readonly string _codebasePath; + private readonly string _registryPath; + private readonly WorkspaceService _service; + + public WorkspaceServiceTests() + { + _codebasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_codebasePath); + _registryPath = Path.Combine(_codebasePath, "test-managed-projects.json"); + var paths = new WorkspacePaths(new RootDir(Path.Combine(_codebasePath, ".ai-dev"))); + _service = new WorkspaceService(paths, new AtomicFileWriter(), registryFilePath: _registryPath); + } + + public void Dispose() + { + if (Directory.Exists(_codebasePath)) + Directory.Delete(_codebasePath, recursive: true); + } + [Fact] public void CreateProject_WhenSlugInvalid_ReturnsError() { - var (service, codebasePath) = CreateService(); - - var result = service.CreateProject(codebasePath, "Invalid Slug", "Demo", null); + var result = _service.CreateProject(_codebasePath, "Invalid Slug", "Demo", null); result.ShouldBeOfType>(); } @@ -15,31 +32,29 @@ public void CreateProject_WhenSlugInvalid_ReturnsError() [Fact] public void CreateProject_WhenValid_ReturnsOk() { - var (service, codebasePath) = CreateService(); - var paths = new WorkspacePaths(new RootDir(Path.Combine(codebasePath, ".ai-dev"))); + var paths = new WorkspacePaths(new RootDir(Path.Combine(_codebasePath, ".ai-dev"))); - var result = service.CreateProject(codebasePath, "demo-project", "Demo Project", "Main app"); + var result = _service.CreateProject(_codebasePath, "demo-project", "Demo Project", "Main app"); result.ShouldBeOfType>(); paths.ProjectJsonPath(new ProjectSlug("demo-project")).Exists().ShouldBeTrue(); } [Fact] - public void UpdateProject_WhenMissing_ReturnsError() + public void CreateProject_WhenValid_StoresSlugInRegistry() { - var (service, codebasePath) = CreateService(); - - var result = service.UpdateProject(new ProjectSlug("demo-project"), "Demo Project", null); + _service.CreateProject(_codebasePath, "demo-project", "Demo Project", null); - result.ShouldBeOfType>(); + var json = File.ReadAllText(_registryPath); + json.ShouldContain("\"slug\""); + json.ShouldContain("demo-project"); } - private static (WorkspaceService service, string codebasePath) CreateService() + [Fact] + public void UpdateProject_WhenMissing_ReturnsError() { - var codebasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(codebasePath); - var holder = new ActiveWorkspaceHolder(); - holder.Activate(codebasePath); - return (new WorkspaceService(holder, new AtomicFileWriter()), codebasePath); + var result = _service.UpdateProject(new ProjectSlug("demo-project"), "Demo Project", null); + + result.ShouldBeOfType>(); } } diff --git a/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs b/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs index 8a1cf74..1a80b2d 100644 --- a/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs +++ b/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs @@ -20,12 +20,14 @@ public sealed class ProjectSettingsViewModelTests : IDisposable private readonly WorkspacePaths _paths; private readonly AtomicFileWriter _fileWriter = new(); private readonly ProjectMutationCoordinator _coordinator = new(); + private readonly string _registryPath; public ProjectSettingsViewModelTests() { _rootPath = Path.Combine(Path.GetTempPath(), $"project-settings-vm-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_rootPath); _paths = new WorkspacePaths(new RootDir(_rootPath)); + _registryPath = Path.Combine(_rootPath, "test-managed-projects.json"); } public void Dispose() @@ -163,7 +165,7 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS IModelRegistry? modelRegistry = null, IReadOnlyList? executors = null) { - var workspaceService = new WorkspaceService(_paths, _fileWriter); + var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); var templatesService = new AgentTemplatesService(_paths); var effectiveModelRegistry = modelRegistry ?? Substitute.For(); if (modelRegistry is null) @@ -188,7 +190,7 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS if (withActiveProject) { - workspaceService.CreateProject("demo-project", "Demo Project", "Test project").ShouldBeOfType>(); + workspaceService.CreateProject(codebasePath: _rootPath, slug: "demo-project", name: "Demo Project", description: "Test project").ShouldBeOfType>(); mainViewModel.ActiveProject = workspaceService.GetProject(new ProjectSlug("demo-project")); } diff --git a/ai-dev.core/Features/Workspace/GlobalPaths.cs b/ai-dev.core/Features/Workspace/GlobalPaths.cs index f218f82..dab7434 100644 --- a/ai-dev.core/Features/Workspace/GlobalPaths.cs +++ b/ai-dev.core/Features/Workspace/GlobalPaths.cs @@ -9,8 +9,8 @@ public static class GlobalPaths private static readonly string AppDataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AiDevStudio"); - /// Path to the global project registry file. - public static string RegistryFile => Path.Combine(AppDataDir, "registry.json"); + /// Path to the global managed-projects file. + public static string ManagedProjectsFile => Path.Combine(AppDataDir, "managed-projects.json"); /// Path to the global studio settings file. public static string StudioSettingsFile => Path.Combine(AppDataDir, "studio-settings.json"); diff --git a/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs b/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs index e1d08c1..d9370cd 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs @@ -1,9 +1,9 @@ namespace AiDev.Features.Workspace; /// -/// Represents a known codebase in the global project registry. -/// The project's slug and display name are read from the codebase's own -/// .ai-dev/project.json at runtime and are not duplicated here. +/// Represents a known codebase in the global managed-projects file. +/// The slug is stored alongside the path so listing projects does not require +/// reading every .ai-dev/project.json just to obtain the identifier. /// public class WorkspaceRegistryEntry { @@ -11,4 +11,10 @@ public class WorkspaceRegistryEntry /// Gets or sets the absolute path to the codebase root directory. /// public required string Path { get; set; } + + /// + /// Gets or sets the project slug. Populated when the project is registered; + /// may be for entries migrated from older registry files. + /// + public string? Slug { get; set; } } diff --git a/ai-dev.core/Features/Workspace/WorkspaceService.cs b/ai-dev.core/Features/Workspace/WorkspaceService.cs index b3d50d9..2692c8b 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceService.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceService.cs @@ -3,14 +3,53 @@ namespace AiDev.Features.Workspace; /// /// Manages the global registry of known project codebases and per-project metadata operations. /// -public class WorkspaceService(ActiveWorkspaceHolder workspace, AtomicFileWriter fileWriter, ILogger? logger = null) +public class WorkspaceService { private static readonly DomainError ProjectNotFoundError = new("WORKSPACE_NOT_FOUND", "Project not found."); + private readonly WorkspacePaths _paths; + private readonly AtomicFileWriter _fileWriter; + private readonly ActiveWorkspaceHolder? _holder; + private readonly string? _registryFilePath; + private readonly ILogger? _logger; + + private string EffectiveRegistryFile => _registryFilePath ?? GlobalPaths.ManagedProjectsFile; + + /// + /// Initialises the service bound to a specific set of workspace paths. + /// Suitable for direct construction in tests; pass + /// to redirect registry reads and writes away from the real managed-projects.json. + /// + public WorkspaceService( + WorkspacePaths paths, + AtomicFileWriter fileWriter, + string? registryFilePath = null, + ILogger? logger = null) + { + _paths = paths; + _fileWriter = fileWriter; + _registryFilePath = registryFilePath; + _logger = logger; + } + + /// + /// Initialises the service from the active workspace holder (used by the DI container). + /// + [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor] + public WorkspaceService( + ActiveWorkspaceHolder workspace, + AtomicFileWriter fileWriter, + ILogger? logger = null) + : this(workspace.Paths, fileWriter, logger: logger) + { + _holder = workspace; + } + // ── Registry ────────────────────────────────────────────────────────────── /// /// Lists all registered project codebases by reading each one's .ai-dev/project.json. + /// The slug is read from the registry entry when available, falling back to parsing the file. /// public List ListProjects() { @@ -29,10 +68,12 @@ public List ListProjects() File.ReadAllText(projectJsonPath), JsonDefaults.Read); if (raw is null) continue; - var slug = ReadString(raw, "projectSlug") ?? ReadString(raw, "slug"); - if (slug is null || !ProjectSlug.TryParse(slug, out var projectSlug)) continue; + // Prefer the slug stored in the registry entry; fall back to reading from file + // to support entries migrated from older registry files. + var slugStr = entry.Slug ?? ReadString(raw, "projectSlug") ?? ReadString(raw, "slug"); + if (slugStr is null || !ProjectSlug.TryParse(slugStr, out var projectSlug)) continue; - var name = ReadString(raw, "name") ?? slug; + var name = ReadString(raw, "name") ?? slugStr; var description = ReadString(raw, "description"); var createdAtStr = ReadString(raw, "createdAt"); var createdAt = DateTime.TryParse(createdAtStr, null, @@ -45,7 +86,7 @@ public List ListProjects() } catch (Exception ex) { - logger?.LogWarning(ex, "[workspace] Failed to read project at {Path} — skipping", entry.Path); + _logger?.LogWarning(ex, "[workspace] Failed to read project at {Path} — skipping", entry.Path); } } @@ -54,6 +95,18 @@ public List ListProjects() // ── Project lifecycle ───────────────────────────────────────────────────── + /// + /// Initialises a codebase as an AI Dev project at the active workspace root: creates the + /// .ai-dev/ structure, writes a project.json, and registers the path globally. + /// + public Result CreateProject(string slug, string name, string? description = null, int apiPort = 0) + { + var codebasePath = Path.GetDirectoryName(_paths.Root.Value) + ?? throw new InvalidOperationException( + "Cannot derive codebase root from WorkspacePaths.Root — Root must be the .ai-dev/ directory."); + return CreateProject(codebasePath, slug, name, description, apiPort); + } + /// /// Initialises a codebase as an AI Dev project: creates the .ai-dev/ structure, /// writes a merged project.json, registers the path globally, and activates it. @@ -91,7 +144,7 @@ public Result CreateProject(string codebasePath, string slug, string name, }; if (apiPort > 0) meta["apiPort"] = apiPort; - fileWriter.WriteAllText( + _fileWriter.WriteAllText( tempPaths.ProjectJsonPath(projectSlug), JsonSerializer.Serialize(meta, JsonDefaults.Write)); @@ -107,15 +160,15 @@ public Result CreateProject(string codebasePath, string slug, string name, }, tasks = new { }, }; - fileWriter.WriteAllText( + _fileWriter.WriteAllText( tempPaths.BoardPath(projectSlug), JsonSerializer.Serialize(boardJson, JsonDefaults.Write)); - // Register in global registry - RegisterPath(absolutePath); + // Register in managed-projects.json + RegisterEntry(absolutePath, slug); - // Activate - workspace.Activate(absolutePath); + // Activate via the holder when available (DI path) + _holder?.Activate(absolutePath); return new Ok(Unit.Value); } @@ -140,8 +193,8 @@ public Result RegisterProject(string codebasePath) return new Err(new DomainError("WORKSPACE_INVALID_PATH", "No valid .ai-dev/project.json found at that path.")); - RegisterPath(absolutePath); - workspace.Activate(absolutePath); + RegisterEntry(absolutePath, config.ProjectSlug); + _holder?.Activate(absolutePath); return new Ok(Unit.Value); } @@ -166,7 +219,7 @@ public void RemoveProject(string codebasePath) /// public ProjectDetail? GetProject(ProjectSlug projectSlug) { - var jsonPath = workspace.Paths.ProjectJsonPath(projectSlug); + var jsonPath = _paths.ProjectJsonPath(projectSlug); if (!File.Exists(jsonPath)) return null; try @@ -187,7 +240,7 @@ public void RemoveProject(string codebasePath) } catch (Exception ex) { - logger?.LogWarning(ex, "[workspace] Failed to read project detail for {Slug}", projectSlug); + _logger?.LogWarning(ex, "[workspace] Failed to read project detail for {Slug}", projectSlug); return null; } } @@ -200,7 +253,7 @@ public Result UpdateProject(ProjectSlug projectSlug, string name, string? if (string.IsNullOrWhiteSpace(name)) return new Err(new DomainError("WORKSPACE_NAME_REQUIRED", "Name is required.")); - var jsonPath = workspace.Paths.ProjectJsonPath(projectSlug); + var jsonPath = _paths.ProjectJsonPath(projectSlug); if (!File.Exists(jsonPath)) return new Err(ProjectNotFoundError); try @@ -211,7 +264,7 @@ public Result UpdateProject(ProjectSlug projectSlug, string name, string? merged["name"] = name; merged["description"] = description ?? string.Empty; - fileWriter.WriteAllText(jsonPath, JsonSerializer.Serialize(merged, JsonDefaults.Write)); + _fileWriter.WriteAllText(jsonPath, JsonSerializer.Serialize(merged, JsonDefaults.Write)); return new Ok(Unit.Value); } catch (JsonException ex) { return new Err(new DomainError("WORKSPACE_INVALID_METADATA", ex.Message)); } @@ -223,33 +276,39 @@ public Result UpdateProject(ProjectSlug projectSlug, string name, string? internal WorkspaceRegistry ReadRegistry() { - GlobalPaths.EnsureCreated(); - if (!File.Exists(GlobalPaths.RegistryFile)) return new WorkspaceRegistry(); + if (_registryFilePath is null) GlobalPaths.EnsureCreated(); + if (!File.Exists(EffectiveRegistryFile)) return new WorkspaceRegistry(); try { - var json = File.ReadAllText(GlobalPaths.RegistryFile); + var json = File.ReadAllText(EffectiveRegistryFile); return JsonSerializer.Deserialize(json, JsonDefaults.Read) ?? new WorkspaceRegistry(); } catch (Exception ex) { - logger?.LogError(ex, "[workspace] Failed to read global registry at {Path}", GlobalPaths.RegistryFile); + _logger?.LogError(ex, "[workspace] Failed to read managed-projects at {Path}", EffectiveRegistryFile); return new WorkspaceRegistry(); } } private void WriteRegistry(WorkspaceRegistry registry) { - GlobalPaths.EnsureCreated(); - fileWriter.WriteAllText(GlobalPaths.RegistryFile, JsonSerializer.Serialize(registry, JsonDefaults.Write)); + if (_registryFilePath is null) GlobalPaths.EnsureCreated(); + else Directory.CreateDirectory(Path.GetDirectoryName(_registryFilePath)!); + _fileWriter.WriteAllText(EffectiveRegistryFile, JsonSerializer.Serialize(registry, JsonDefaults.Write)); } - private void RegisterPath(string absolutePath) + private void RegisterEntry(string absolutePath, string slug) { var registry = ReadRegistry(); - if (!registry.Projects.Any(e => - string.Equals(Path.GetFullPath(e.Path), absolutePath, StringComparison.OrdinalIgnoreCase))) + var existing = registry.Projects.FirstOrDefault(e => + string.Equals(Path.GetFullPath(e.Path), absolutePath, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + { + registry.Projects.Add(new WorkspaceRegistryEntry { Path = absolutePath, Slug = slug }); + } + else { - registry.Projects.Add(new WorkspaceRegistryEntry { Path = absolutePath }); + existing.Slug = slug; } registry.LastActivePath = absolutePath; WriteRegistry(registry); diff --git a/ai-dev.core/FilePathConstants.cs b/ai-dev.core/FilePathConstants.cs index 0fb6165..25c6703 100644 --- a/ai-dev.core/FilePathConstants.cs +++ b/ai-dev.core/FilePathConstants.cs @@ -3,7 +3,7 @@ namespace AiDev; public static class FilePathConstants { public const string WorkspacesDirName = "workspaces"; - public const string RegistryFileName = "workspaces.json"; + public const string ManagedProjectsFileName = "managed-projects.json"; public const string StudioSettingsFileName = "studio-settings.json"; public const string FeatureFlagsFileName = "feature-flags.json"; public const string AgentTemplatesDirName = "agent-templates"; diff --git a/ai-dev.ui.winui/App.xaml.cs b/ai-dev.ui.winui/App.xaml.cs index 03eaa4e..580519f 100644 --- a/ai-dev.ui.winui/App.xaml.cs +++ b/ai-dev.ui.winui/App.xaml.cs @@ -104,8 +104,8 @@ private static void ConfigureServices(IConfiguration configuration, IServiceColl { try { - if (!File.Exists(GlobalPaths.RegistryFile)) return null; - var json = File.ReadAllText(GlobalPaths.RegistryFile); + if (!File.Exists(GlobalPaths.ManagedProjectsFile)) return null; + var json = File.ReadAllText(GlobalPaths.ManagedProjectsFile); using var doc = System.Text.Json.JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("lastActivePath", out var prop)) return prop.GetString(); @@ -113,4 +113,4 @@ private static void ConfigureServices(IConfiguration configuration, IServiceColl catch { } return null; } -} +} \ No newline at end of file From 5735bb40829ddf3661c3a771f97d58ceba57aa7f Mon Sep 17 00:00:00 2001 From: Allan Nielsen <2005474+Allann@users.noreply.github.com> Date: Fri, 15 May 2026 14:31:06 +1000 Subject: [PATCH 2/3] Fix all WinUI test compilation errors and runtime failures DecisionsViewModelTests: - Id.Value.ShouldBe() to avoid Shouldly IEnumerable ambiguity on DecisionId - Add missing IAgentInboxService arg to DecisionChatService constructor - Fix CS4014: capture ShouldNotBeNull() result before await ProjectSettingsViewModelTests: - AgentExecutorName.AnthropicValue (string const) -> AgentExecutorName.Anthropic (typed) for all ModelDescriptor constructors and GetModelsForExecutor() calls - Arg.Any() -> Arg.Any() for IModelRegistry mock setup - AgentTemplatesService(_paths) -> AgentTemplatesService(_templatesDir) (add _templatesDir field) - _paths.Root = _rootPath/.ai-dev so CreateProject(codebasePath:_rootPath) aligns with what WorkspacePaths expects (paths must match .ai-dev structure) ai-dev.ui.winui.csproj: - Scope PublishReadyToRun and PublishTrimmed to actual publish operations only (add ' != ''' condition) so building the test project in Release doesn't trigger NETSDK1094/NETSDK1102 ILLink errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DecisionsViewModelTests.cs | 10 +++--- .../ProjectSettingsViewModelTests.cs | 32 ++++++++++--------- ai-dev.ui.winui/ai-dev.ui.winui.csproj | 4 +-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/ai-dev-net.tests.winui/DecisionsViewModelTests.cs b/ai-dev-net.tests.winui/DecisionsViewModelTests.cs index 7fac83a..17c06f1 100644 --- a/ai-dev-net.tests.winui/DecisionsViewModelTests.cs +++ b/ai-dev-net.tests.winui/DecisionsViewModelTests.cs @@ -58,7 +58,7 @@ public async Task SelectDecisionAsync_WhenChatHistoryExists_LoadsChatMessages() await viewModel.SelectDecisionAsync(decision); viewModel.SelectedDecision.ShouldNotBeNull(); - viewModel.SelectedDecision.Id.ShouldBe("decision-1"); + viewModel.SelectedDecision.Id.Value.ShouldBe("decision-1"); viewModel.ChatMessages.Count.ShouldBe(1); viewModel.ChatMessages[0].Content.ShouldBe("Use Option A"); } @@ -146,7 +146,7 @@ public async Task PollSelectedDecisionAsync_WhenDecisionWasResolved_UpdatesSelec }; var runner = Substitute.For(); - var chatService = new DecisionChatService(_paths, runner, _projectStateNotifier, NullLogger.Instance); + var chatService = new DecisionChatService(_paths, runner, Substitute.For(), _projectStateNotifier, NullLogger.Instance); var uiDispatcher = new ImmediateDispatcher(); var viewModel = new DecisionsViewModel(decisionsService, chatService, mainViewModel, uiDispatcher); @@ -157,10 +157,8 @@ private static async Task InvokePollSelectedDecisionAsync(DecisionsViewModel vie { var method = typeof(DecisionsViewModel).GetMethod("PollSelectedDecisionAsync", BindingFlags.Instance | BindingFlags.NonPublic); method.ShouldNotBeNull(); - var task = method!.Invoke(viewModel, null) as Task; - task.ShouldNotBeNull(); - var nonNullTask = task; - await nonNullTask!; + var nonNullTask = (method!.Invoke(viewModel, null) as Task).ShouldNotBeNull(); + await nonNullTask; } private sealed class ImmediateDispatcher : IUiDispatcher diff --git a/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs b/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs index 1a80b2d..185a54b 100644 --- a/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs +++ b/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs @@ -21,13 +21,15 @@ public sealed class ProjectSettingsViewModelTests : IDisposable private readonly AtomicFileWriter _fileWriter = new(); private readonly ProjectMutationCoordinator _coordinator = new(); private readonly string _registryPath; + private readonly string _templatesDir; public ProjectSettingsViewModelTests() { _rootPath = Path.Combine(Path.GetTempPath(), $"project-settings-vm-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_rootPath); - _paths = new WorkspacePaths(new RootDir(_rootPath)); + _paths = new WorkspacePaths(new RootDir(Path.Combine(_rootPath, ".ai-dev"))); _registryPath = Path.Combine(_rootPath, "test-managed-projects.json"); + _templatesDir = Path.Combine(_rootPath, "agent-templates"); } public void Dispose() @@ -79,8 +81,8 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenTargetExecutorHasNoModels_R public async Task ApplyBulkSwitchToExecutorAsync_WhenValid_UpdatesExecutorModelAndThinkingLevel() { var modelRegistry = Substitute.For(); - modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([ - new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.AnthropicValue) + modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([ + new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.Anthropic) ]); var (viewModel, _, agentService, _) = CreateViewModel(modelRegistry: modelRegistry); @@ -105,9 +107,9 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenValid_UpdatesExecutorModelA public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideProvided_UsesOverrideForAllAgents() { var modelRegistry = Substitute.For(); - modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([ - new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.AnthropicValue), - new ModelDescriptor("anthropic-sonnet", "Anthropic Sonnet", AgentExecutorName.AnthropicValue, ModelCapabilities.Streaming | ModelCapabilities.ToolCalling | ModelCapabilities.Reasoning) + modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([ + new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.Anthropic), + new ModelDescriptor("anthropic-sonnet", "Anthropic Sonnet", AgentExecutorName.Anthropic, ModelCapabilities.Streaming | ModelCapabilities.ToolCalling | ModelCapabilities.Reasoning) ]); var (viewModel, _, agentService, _) = CreateViewModel(modelRegistry: modelRegistry); @@ -128,8 +130,8 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideProvided_UsesO public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideIsUnsupported_ReturnsError() { var modelRegistry = Substitute.For(); - modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([ - new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.AnthropicValue) + modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([ + new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.Anthropic) ]); var (viewModel, _, agentService, _) = CreateViewModel(modelRegistry: modelRegistry); @@ -147,10 +149,10 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideIsUnsupported_ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctSortedIds() { var modelRegistry = Substitute.For(); - modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([ - new ModelDescriptor("z-model", "Z Model", AgentExecutorName.AnthropicValue), - new ModelDescriptor("a-model", "A Model", AgentExecutorName.AnthropicValue), - new ModelDescriptor("A-model", "A Model Duplicate", AgentExecutorName.AnthropicValue) + modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([ + new ModelDescriptor("z-model", "Z Model", AgentExecutorName.Anthropic), + new ModelDescriptor("a-model", "A Model", AgentExecutorName.Anthropic), + new ModelDescriptor("A-model", "A Model Duplicate", AgentExecutorName.Anthropic) ]); var (viewModel, _, _, _) = CreateViewModel(modelRegistry: modelRegistry); @@ -166,10 +168,10 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS IReadOnlyList? executors = null) { var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); - var templatesService = new AgentTemplatesService(_paths); + var templatesService = new AgentTemplatesService(_templatesDir); var effectiveModelRegistry = modelRegistry ?? Substitute.For(); if (modelRegistry is null) - effectiveModelRegistry.GetModelsForExecutor(Arg.Any()).Returns([]); + effectiveModelRegistry.GetModelsForExecutor(Arg.Any()).Returns([]); var agentService = new AgentService( _paths, @@ -207,7 +209,7 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS private void SeedProjectWithAgent(AgentService agentService) { - var templatesService = new AgentTemplatesService(_paths); + var templatesService = new AgentTemplatesService(_templatesDir); templatesService.CreateTemplate(new AgentTemplate { Slug = new AgentSlug("generic-standard"), diff --git a/ai-dev.ui.winui/ai-dev.ui.winui.csproj b/ai-dev.ui.winui/ai-dev.ui.winui.csproj index 0a67692..634e8ea 100644 --- a/ai-dev.ui.winui/ai-dev.ui.winui.csproj +++ b/ai-dev.ui.winui/ai-dev.ui.winui.csproj @@ -63,9 +63,9 @@ False - True + True False - True + True AI Dev Studio en-AU From fa9c15ffbb628010f7fa8dd8cd95ffbd133b752c Mon Sep 17 00:00:00 2001 From: Allan Nielsen <2005474+Allann@users.noreply.github.com> Date: Fri, 15 May 2026 16:11:51 +1000 Subject: [PATCH 3/3] fix: remove WorkspacePaths/ActiveWorkspaceHolder from AddAiDevCore, fix DI ambiguity WorkspaceService had two constructors after the registry refactor, making the DI container throw 'ambiguous constructors' when both WorkspacePaths and ActiveWorkspaceHolder were resolvable (both registered by callers + AddAiDevCore). Fixes: - Remove ActiveWorkspaceHolder and WorkspacePaths registrations from AddAiDevCore (callers own these registrations, as they did before the refactor) - Merge WorkspaceService into a single constructor: WorkspaceService(WorkspacePaths, AtomicFileWriter, ActiveWorkspaceHolder? = null, string? registryFilePath = null, ILogger? = null) - Register WorkspaceService via explicit factory in AddAiDevCore, using GetRequiredService and GetService (null in API context; real holder in WinUI context) All tests now pass (625 unit + 60 integration + 10 WinUI) and registry.json is never modified by any test run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/CoreServiceExtensions.cs | 13 ++++++------ .../Features/Workspace/WorkspaceService.cs | 20 ++++--------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/ai-dev.core/Extensions/CoreServiceExtensions.cs b/ai-dev.core/Extensions/CoreServiceExtensions.cs index d39c410..6cd07c9 100644 --- a/ai-dev.core/Extensions/CoreServiceExtensions.cs +++ b/ai-dev.core/Extensions/CoreServiceExtensions.cs @@ -24,11 +24,8 @@ public static class CoreServiceExtensions /// public static IServiceCollection AddAiDevCore(this IServiceCollection services) { - // WorkspacePaths is resolved lazily from the active workspace holder so that the holder - // can be populated (by activating a project) before any service first uses it. - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService().Paths); - + // WorkspacePaths and (optionally) ActiveWorkspaceHolder are registered by the host + // (Program.cs for the API, App.xaml.cs for WinUI). AddAiDevCore consumes them. services.AddSingleton(); services.AddSingleton, TaskAssignedHandler>(); services.AddSingleton(); @@ -37,7 +34,11 @@ public static IServiceCollection AddAiDevCore(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(sp => new WorkspaceService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService(), + logger: sp.GetService>())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/ai-dev.core/Features/Workspace/WorkspaceService.cs b/ai-dev.core/Features/Workspace/WorkspaceService.cs index 2692c8b..48118f7 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceService.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceService.cs @@ -16,35 +16,23 @@ public class WorkspaceService private string EffectiveRegistryFile => _registryFilePath ?? GlobalPaths.ManagedProjectsFile; /// - /// Initialises the service bound to a specific set of workspace paths. - /// Suitable for direct construction in tests; pass - /// to redirect registry reads and writes away from the real managed-projects.json. + /// Primary constructor. Pass to redirect registry reads/writes + /// away from the real managed-projects.json (used in tests). /// public WorkspaceService( WorkspacePaths paths, AtomicFileWriter fileWriter, + ActiveWorkspaceHolder? holder = null, string? registryFilePath = null, ILogger? logger = null) { _paths = paths; _fileWriter = fileWriter; + _holder = holder; _registryFilePath = registryFilePath; _logger = logger; } - /// - /// Initialises the service from the active workspace holder (used by the DI container). - /// - [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor] - public WorkspaceService( - ActiveWorkspaceHolder workspace, - AtomicFileWriter fileWriter, - ILogger? logger = null) - : this(workspace.Paths, fileWriter, logger: logger) - { - _holder = workspace; - } - // ── Registry ────────────────────────────────────────────────────────────── ///