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/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 8a1cf74..185a54b 100644 --- a/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs +++ b/ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs @@ -20,12 +20,16 @@ public sealed class ProjectSettingsViewModelTests : IDisposable private readonly WorkspacePaths _paths; 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() @@ -77,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); @@ -103,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); @@ -126,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); @@ -145,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); @@ -163,11 +167,11 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS IModelRegistry? modelRegistry = null, IReadOnlyList? executors = null) { - var workspaceService = new WorkspaceService(_paths, _fileWriter); - var templatesService = new AgentTemplatesService(_paths); + var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath); + 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, @@ -188,7 +192,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")); } @@ -205,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.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/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..48118f7 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceService.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceService.cs @@ -3,14 +3,41 @@ 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; + + /// + /// 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; + } + // ── 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 +56,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 +74,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 +83,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 +132,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 +148,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 +181,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 +207,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 +228,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 +241,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 +252,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 +264,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 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