diff --git a/ai-dev-net.AppHost/ai-dev-net.AppHost.csproj b/ai-dev-net.AppHost/ai-dev-net.AppHost.csproj index d0858b7..3da5450 100644 --- a/ai-dev-net.AppHost/ai-dev-net.AppHost.csproj +++ b/ai-dev-net.AppHost/ai-dev-net.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/ai-dev-net.tests.integration/LmStudioExecutorTests.cs b/ai-dev-net.tests.integration/LmStudioExecutorTests.cs index a8d63c8..b4cf7bc 100644 --- a/ai-dev-net.tests.integration/LmStudioExecutorTests.cs +++ b/ai-dev-net.tests.integration/LmStudioExecutorTests.cs @@ -525,11 +525,10 @@ private static async Task SkipIfUnavailableAsync() var models = new List(); if (doc.RootElement.TryGetProperty("data", out var data)) { - models = data.EnumerateArray() + models = [.. data.EnumerateArray() .Select(m => m.TryGetProperty("id", out var idProp) ? idProp.GetString() : null) .Where(n => n is { Length: > 0 }) - .Select(n => n!) - .ToList(); + .Select(n => n!)]; } if (models.Count == 0) diff --git a/ai-dev-net.tests.unit/AgentInfoTests.cs b/ai-dev-net.tests.unit/AgentInfoTests.cs index c518fbf..5e6f2ef 100644 --- a/ai-dev-net.tests.unit/AgentInfoTests.cs +++ b/ai-dev-net.tests.unit/AgentInfoTests.cs @@ -2,136 +2,145 @@ namespace AiDevNet.Tests.Unit; public class AgentInfoTests { - private static AgentInfo CreateInfo() => new( - slug: new AgentSlug("my-agent"), - name: "My Agent", - role: "Assistant", - description: "Handles agent workflows"); - - // ------------------------------------------------------------------------- - // Defaults - // ------------------------------------------------------------------------- + private static AgentInfoIdle CreateIdle(DateTime? previousRunAt = null) => new() + { + Slug = new AgentSlug("my-agent"), + Name = "My Agent", + Role = "Assistant", + Description = "Handles agent workflows", + Model = "sonnet", + Executor = AgentExecutorName.Default, + Skills = [], + ThinkingLevel = default, + InboxCount = 0, + PreviousRunAt = previousRunAt, + }; + + // ── AgentInfoIdle ──────────────────────────────────────────────────────── [Fact] - public void Defaults_AreCorrect() + public void AgentInfoIdle_DefaultProperties_AreCorrect() { - var info = CreateInfo(); + var info = CreateIdle(); info.Name.ShouldBe("My Agent"); info.Role.ShouldBe("Assistant"); info.Model.ShouldBe("sonnet"); - info.Status.ShouldBe(AgentStatus.Idle); info.Description.ShouldBe("Handles agent workflows"); - info.LastRunAt.ShouldBeNull(); info.InboxCount.ShouldBe(0); - info.Executor.ShouldBe(AgentExecutorName.Default); + info.Executor.ShouldBe(AgentExecutorName.Claude); + info.PreviousRunAt.ShouldBeNull(); } - // ------------------------------------------------------------------------- - // Status - // ------------------------------------------------------------------------- - [Fact] - public void Status_CanBeSetToRunning() + public void AgentInfoIdle_Status_IsIdle() { - var info = CreateInfo(); - info.MarkRunning(DateTime.UtcNow); - info.Status.IsRunning.ShouldBeTrue(); + var info = CreateIdle(); + info.Status.ShouldBe(AgentStatus.Idle); } [Fact] - public void Status_CanBeSetToError() + public void AgentInfoIdle_LastRunAt_ReturnsPreviousRunAt() { - var info = CreateInfo(); - info.MarkError(); - info.Status.IsError.ShouldBeTrue(); + var now = DateTime.UtcNow; + var info = CreateIdle(previousRunAt: now); + info.LastRunAt.ShouldBe(now); } - // ------------------------------------------------------------------------- - // LastRunAt - // ------------------------------------------------------------------------- - [Fact] - public void LastRunAt_WhenSet_ReturnsCorrectDateTime() + public void AgentInfoIdle_LastError_IsNull() { - var now = DateTime.UtcNow; - var info = CreateInfo(); - info.MarkRunning(now); - info.LastRunAt.ShouldBe(now); + var info = CreateIdle(); + info.LastError.ShouldBeNull(); + info.LastErrorAt.ShouldBeNull(); } - // ------------------------------------------------------------------------- - // InboxCount - // ------------------------------------------------------------------------- + // ── AgentInfoRunning ───────────────────────────────────────────────────── [Fact] - public void InboxCount_ReflectsAssignedValue() + public void AgentInfoRunning_Status_IsRunning() { - var info = CreateInfo(); - info.SetInboxCount(5); - info.InboxCount.ShouldBe(5); + var info = CreateIdle() with { } as AgentInfo; + var running = new AgentInfoRunning + { + Slug = new AgentSlug("my-agent"), Name = "My Agent", Role = "Assistant", + Description = "Handles agent workflows", Model = "sonnet", + Executor = AgentExecutorName.Default, Skills = [], ThinkingLevel = default, + InboxCount = 0, StartedAt = DateTime.UtcNow, + }; + + running.Status.IsRunning.ShouldBeTrue(); } - // ------------------------------------------------------------------------- - // Executor default - // ------------------------------------------------------------------------- - [Fact] - public void Executor_DefaultMatchesIAgentExecutorDefault() + public void AgentInfoRunning_LastRunAt_ReturnsStartedAt() { - var info = CreateInfo(); - info.Executor.ShouldBe(AgentExecutorName.Claude); + var now = DateTime.UtcNow; + var running = new AgentInfoRunning + { + Slug = new AgentSlug("my-agent"), Name = "My Agent", Role = "Assistant", + Description = "", Model = "sonnet", Executor = AgentExecutorName.Default, + Skills = [], ThinkingLevel = default, InboxCount = 0, StartedAt = now, + }; + + running.LastRunAt.ShouldBe(now); + running.StartedAt.ShouldBe(now); } + // ── AgentInfoFailed ────────────────────────────────────────────────────── + [Fact] - public void UpdateMetadata_WhenExecutorProvided_StoresSupportedExecutor() + public void AgentInfoFailed_Status_IsError() { - var info = CreateInfo(); - - info.UpdateMetadata("My Agent", "Assistant", "Handles agent workflows", "sonnet", AgentExecutorName.Anthropic, []); - - info.Executor.ShouldBe(AgentExecutorName.Anthropic); + var failed = new AgentInfoFailed + { + Slug = new AgentSlug("my-agent"), Name = "My Agent", Role = "Assistant", + Description = "", Model = "sonnet", Executor = AgentExecutorName.Default, + Skills = [], ThinkingLevel = default, InboxCount = 0, + Failure = new AgentFailure("Something broke", DateTime.UtcNow), + }; + + failed.Status.IsError.ShouldBeTrue(); } [Fact] - public void SetLastError_WhenProvided_StoresMessageAndTimestamp() + public void AgentInfoFailed_FailureAlwaysPresent() { var occurredAt = DateTime.UtcNow; - var info = CreateInfo(); - - info.SetLastError("Unsupported Ollama tools", occurredAt); - - info.LastError.ShouldBe("Unsupported Ollama tools"); - info.LastErrorAt.ShouldBe(occurredAt); + var failed = new AgentInfoFailed + { + Slug = new AgentSlug("my-agent"), Name = "My Agent", Role = "Assistant", + Description = "", Model = "sonnet", Executor = AgentExecutorName.Default, + Skills = [], ThinkingLevel = default, InboxCount = 0, + Failure = new AgentFailure("Unsupported Ollama tools", occurredAt), + }; + + failed.Failure.Error.ShouldBe("Unsupported Ollama tools"); + failed.Failure.OccurredAt.ShouldBe(occurredAt); + failed.LastError.ShouldBe("Unsupported Ollama tools"); + failed.LastErrorAt.ShouldBe(occurredAt); } - [Fact] - public void SetLastError_WhenCleared_RemovesTimestamp() - { - var info = CreateInfo(); - info.SetLastError("Unsupported Ollama tools", DateTime.UtcNow); - - info.SetLastError(null, DateTime.UtcNow); - - info.LastError.ShouldBeNull(); - info.LastErrorAt.ShouldBeNull(); - } + // ── with expressions ───────────────────────────────────────────────────── [Fact] - public void Constructor_WhenNameMissing_ThrowsArgumentException() + public void WithExpression_CanUpdateInboxCount() { - Should.Throw(() => new AgentInfo( - slug: new AgentSlug("my-agent"), - name: " ", - role: "Assistant", - description: "Handles agent workflows")); + var info = CreateIdle(); + var updated = info with { InboxCount = 5 }; + + updated.InboxCount.ShouldBe(5); + info.InboxCount.ShouldBe(0); // original unchanged } [Fact] - public void SetInboxCount_WhenNegative_ThrowsArgumentOutOfRangeException() + public void WithExpression_CanSetFailover() { - var info = CreateInfo(); + var info = CreateIdle(); + var failover = new AgentFailover(AgentExecutorName.Anthropic, DateTime.UtcNow); + var updated = info with { Failover = failover }; - Should.Throw(() => info.SetInboxCount(-1)); + updated.Failover.ShouldBe(failover); + info.Failover.ShouldBeNull(); // original unchanged } } diff --git a/ai-dev-net.tests.unit/AgentServiceTests.cs b/ai-dev-net.tests.unit/AgentServiceTests.cs index 9c3710c..c9294e7 100644 --- a/ai-dev-net.tests.unit/AgentServiceTests.cs +++ b/ai-dev-net.tests.unit/AgentServiceTests.cs @@ -67,10 +67,9 @@ public void LoadAgent_WhenJsonContainsLastError_LoadsPersistedFailureState() var agent = service.LoadAgent(projectSlug, agentSlug); - agent.ShouldNotBeNull(); - agent!.Status.IsError.ShouldBeTrue(); - agent.LastError!.ShouldContain("does not support workspace tools"); - agent.LastErrorAt.ShouldBe(errorAt); + var failed = agent.ShouldBeOfType(); + failed.Failure.Error.ShouldContain("does not support workspace tools"); + failed.Failure.OccurredAt.ShouldBe(errorAt); } [Fact] diff --git a/ai-dev-net.tests.unit/BoardServiceCompleteTaskTests.cs b/ai-dev-net.tests.unit/BoardServiceCompleteTaskTests.cs index 30a3afe..fdab26f 100644 --- a/ai-dev-net.tests.unit/BoardServiceCompleteTaskTests.cs +++ b/ai-dev-net.tests.unit/BoardServiceCompleteTaskTests.cs @@ -30,7 +30,7 @@ private static BoardService CreateService() private static SessionResult MakeResult(string taskId, IReadOnlyList? tags = null) => new(TaskId: taskId, - Status: "completed", + SessionStatus: SessionStatus.Completed, Summary: "Done", PullRequestUrl: null, FilesChanged: [], diff --git a/ai-dev-net.tests.unit/DecisionsServiceTests.cs b/ai-dev-net.tests.unit/DecisionsServiceTests.cs new file mode 100644 index 0000000..a19c0f7 --- /dev/null +++ b/ai-dev-net.tests.unit/DecisionsServiceTests.cs @@ -0,0 +1,219 @@ +using AiDev.Features.Decision; + +using Microsoft.Extensions.Logging.Abstractions; + +using UnitResult = AiDev.Models.Unit; + +namespace AiDevNet.Tests.Unit; + +public class DecisionsServiceTests +{ + private static DecisionsService CreateService(out WorkspacePaths paths) + { + var root = new RootDir(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + paths = new WorkspacePaths(root); + var dispatcher = Substitute.For(); + dispatcher.Dispatch(Arg.Any>(), Arg.Any()) + .Returns(new Ok(UnitResult.Value)); + return new DecisionsService(paths, dispatcher, new AtomicFileWriter(), new ProjectMutationCoordinator(), NullLogger.Instance); + } + + private static (ProjectSlug Project, DecisionId Id) SeedPendingDecision(WorkspacePaths paths, string body = "Which approach should we take?") + { + var projectSlug = new ProjectSlug("test-project"); + var pendingDir = paths.DecisionsPendingDir(projectSlug); + Directory.CreateDirectory(pendingDir); + const string filename = "20260510-120000-test-decision.md"; + var fields = new Dictionary + { + ["from"] = "pm-agent", + ["date"] = DateTime.UtcNow.ToString("o"), + ["priority"] = "normal", + ["subject"] = "Test Decision", + ["status"] = "pending", + }; + File.WriteAllText(Path.Combine(pendingDir, filename), FrontmatterParser.Stringify(fields, body)); + return (projectSlug, new DecisionId("20260510-120000-test-decision")); + } + + [Fact] + public void CreateDecision_WritesPendingFile() + { + var svc = CreateService(out var paths); + var project = new ProjectSlug("test-project"); + + var result = svc.CreateDecision(project, "pm-agent", "Pick a DB", Priority.Normal, null, "Should we use Postgres or SQLite?"); + + result.ShouldBeOfType>(); + var files = Directory.GetFiles(paths.DecisionsPendingDir(project), "*.md"); + files.Length.ShouldBe(1); + } + + [Fact] + public void ListDecisions_ReturnsPendingByDefault() + { + var svc = CreateService(out var paths); + var (project, _) = SeedPendingDecision(paths); + + var decisions = svc.ListDecisions(project); + + decisions.Count.ShouldBe(1); + decisions[0].Status.IsPending.ShouldBeTrue(); + } + + [Fact] + public void ListDecisions_WhenResolvedRequested_ReturnsOnlyResolved() + { + var svc = CreateService(out var paths); + var (project, _) = SeedPendingDecision(paths); + + var decisions = svc.ListDecisions(project, DecisionStatus.Resolved); + + decisions.ShouldBeEmpty(); + } + + [Fact] + public void GetDecision_FindsPendingDecision() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + + var decision = svc.GetDecision(project, id); + + decision.ShouldNotBeNull(); + decision.Id.ShouldBe(id); + decision.Status.IsPending.ShouldBeTrue(); + } + + [Fact] + public void GetDecision_ReturnsNullForNonExistentId() + { + var svc = CreateService(out var paths); + var project = new ProjectSlug("test-project"); + + var decision = svc.GetDecision(project, new DecisionId("does-not-exist")); + + decision.ShouldBeNull(); + } + + [Fact] + public async Task ResolveDecisionAsync_MovesFileFromPendingToResolved() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + + var result = await svc.ResolveDecisionAsync(project, id, "Go with Postgres.", TestContext.Current.CancellationToken); + + result.ShouldBeOfType>(); + File.Exists(Path.Combine(paths.DecisionsPendingDir(project), $"{id.Value}.md")).ShouldBeFalse(); + File.Exists(Path.Combine(paths.DecisionsResolvedDir(project), $"{id.Value}.md")).ShouldBeTrue(); + } + + [Fact] + public async Task ResolveDecisionAsync_WritesResponseToResolvedFile() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + const string response = "Go with Postgres."; + + await svc.ResolveDecisionAsync(project, id, response, TestContext.Current.CancellationToken); + + var content = File.ReadAllText(Path.Combine(paths.DecisionsResolvedDir(project), $"{id.Value}.md")); + content.ShouldContain(response); + content.ShouldContain("## Human Response"); + } + + [Fact] + public async Task ResolveDecisionAsync_DeletesPendingFile() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + + await svc.ResolveDecisionAsync(project, id, "Approved.", TestContext.Current.CancellationToken); + + var pendingPath = Path.Combine(paths.DecisionsPendingDir(project), $"{id.Value}.md"); + File.Exists(pendingPath).ShouldBeFalse(); + } + + [Fact] + public async Task ResolveDecisionAsync_UpdatesStatusToResolved() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + + await svc.ResolveDecisionAsync(project, id, "Approved.", TestContext.Current.CancellationToken); + + var resolved = svc.GetDecision(project, id); + resolved.ShouldNotBeNull(); + resolved.Status.IsResolved.ShouldBeTrue(); + resolved.Response.ShouldBe("Approved."); + resolved.ResolvedBy.ShouldBe("human"); + } + + [Fact] + public async Task ResolveDecisionAsync_RoundTrip_ParsesResponseCorrectly() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths, "Which option do we choose?"); + const string response = "We go with option B because it reduces risk."; + + await svc.ResolveDecisionAsync(project, id, response, TestContext.Current.CancellationToken); + var resolved = svc.GetDecision(project, id); + + resolved.ShouldNotBeNull(); + resolved.Response.ShouldBe(response); + resolved.Body.ShouldBe("Which option do we choose?"); + } + + [Fact] + public async Task ResolveDecisionAsync_WhenDecisionNotFound_ReturnsError() + { + var svc = CreateService(out var paths); + var project = new ProjectSlug("test-project"); + + var result = await svc.ResolveDecisionAsync(project, new DecisionId("does-not-exist"), "Some response.", TestContext.Current.CancellationToken); + + var err = result.ShouldBeOfType>(); + err.Error.Code.ShouldBe("DECISION_NOT_FOUND"); + } + + [Fact] + public async Task ResolveDecisionAsync_WhenAlreadyResolved_ReturnsError() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + await svc.ResolveDecisionAsync(project, id, "First answer.", TestContext.Current.CancellationToken); + + var result = await svc.ResolveDecisionAsync(project, id, "Second answer.", TestContext.Current.CancellationToken); + + var err = result.ShouldBeOfType>(); + err.Error.Code.ShouldBe("DECISION_ALREADY_RESOLVED"); + } + + [Fact] + public async Task ResolveDecisionAsync_WhenResponseIsWhitespace_ReturnsError() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + + var result = await svc.ResolveDecisionAsync(project, id, " ", TestContext.Current.CancellationToken); + + var err = result.ShouldBeOfType>(); + err.Error.Code.ShouldBe("DECISION_INVALID_RESPONSE"); + } + + [Fact] + public async Task ListDecisions_AfterResolve_ReturnsResolvedDecision() + { + var svc = CreateService(out var paths); + var (project, id) = SeedPendingDecision(paths); + await svc.ResolveDecisionAsync(project, id, "Done.", TestContext.Current.CancellationToken); + + var resolved = svc.ListDecisions(project, DecisionStatus.Resolved); + var pending = svc.ListDecisions(project, DecisionStatus.Pending); + + resolved.Count.ShouldBe(1); + resolved[0].Id.ShouldBe(id); + pending.ShouldBeEmpty(); + } +} diff --git a/ai-dev-net.tests.unit/DigestServiceTests.cs b/ai-dev-net.tests.unit/DigestServiceTests.cs index 15bb759..957e276 100644 --- a/ai-dev-net.tests.unit/DigestServiceTests.cs +++ b/ai-dev-net.tests.unit/DigestServiceTests.cs @@ -11,9 +11,9 @@ public void GetDigest_WhenNoDirectoriesExist_ReturnsZeroCountsAndEmptyAgentList( var service = CreateService(out _); var projectSlug = new ProjectSlug("test-project"); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); - result.Date.ShouldBe("2026-01-01"); + result.Date.ShouldBe(new DateOnly(2026, 1, 1)); result.TotalMessages.ShouldBe(0); result.PendingDecisions.ShouldBe(0); result.ResolvedDecisions.ShouldBe(0); @@ -31,7 +31,7 @@ public void GetDigest_WhenPendingDecisionsExist_CountsThemCorrectly() File.WriteAllText(Path.Combine(pendingDir, "decision1.md"), "content"); File.WriteAllText(Path.Combine(pendingDir, "decision2.md"), "content"); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.PendingDecisions.ShouldBe(2); } @@ -49,7 +49,7 @@ public void GetDigest_WhenResolvedDecisionsExistForDate_CountsThemCorrectly() File.WriteAllText(Path.Combine(resolvedDir, "20260101-130000-decision2.md"), "content"); File.WriteAllText(Path.Combine(resolvedDir, "20260102-120000-decision3.md"), "content"); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.ResolvedDecisions.ShouldBe(2); } @@ -66,7 +66,7 @@ public void GetDigest_WhenAgentDirectoriesExist_IncludesAgentActivity() var agent1Dir = Path.Combine(agentsDir, "agent1"); Directory.CreateDirectory(agent1Dir); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity.Count.ShouldBe(1); result.AgentActivity[0].AgentSlug.Value.ShouldBe("agent1"); @@ -92,7 +92,7 @@ public void GetDigest_WhenAgentJsonExists_LoadsAgentMetadata() }"; File.WriteAllText(jsonPath, jsonContent); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity.Count.ShouldBe(1); result.AgentActivity[0].AgentName.ShouldBe("Agent One"); @@ -111,7 +111,7 @@ public void GetDigest_WhenAgentJsonMissing_UsesDefaultName() var agent1Dir = Path.Combine(agentsDir, "agent1"); Directory.CreateDirectory(agent1Dir); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity[0].AgentName.ShouldBe("agent1"); result.AgentActivity[0].Executor.ShouldBeNull(); @@ -133,7 +133,7 @@ public void GetDigest_WhenAgentJsonIsInvalid_IgnoresErrorAndUsesDefaults() File.WriteAllText(jsonPath, "{ invalid json"); // Should not throw - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity[0].AgentName.ShouldBe("agent1"); } @@ -160,7 +160,7 @@ public void GetDigest_WhenInboxAndOutboxMessagesExist_CountsThemCorrectly() File.WriteAllText(Path.Combine(inboxDir, "20260101-130000-msg2.md"), "content"); File.WriteAllText(Path.Combine(outboxDir, "20260101-140000-msg1.md"), "content"); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity[0].MessagesReceived.ShouldBe(2); result.AgentActivity[0].MessagesSent.ShouldBe(1); @@ -187,7 +187,7 @@ public void GetDigest_WhenMessagesExistForDifferentDates_CountsOnlyForSpecificDa File.WriteAllText(Path.Combine(inboxDir, "20260101-130000-msg2.md"), "content"); File.WriteAllText(Path.Combine(inboxDir, "20260102-120000-msg3.md"), "content"); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity[0].MessagesReceived.ShouldBe(2); } @@ -205,7 +205,7 @@ public void GetDigest_WhenMultipleAgentsExist_SortsAgentsByName() Directory.CreateDirectory(Path.Combine(agentsDir, "alpha-agent")); Directory.CreateDirectory(Path.Combine(agentsDir, "beta-agent")); - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity.Count.ShouldBe(3); result.AgentActivity[0].AgentName.ShouldBe("alpha-agent"); @@ -226,7 +226,7 @@ public void GetDigest_WhenDirectoryNameIsNotValidAgentSlug_IgnoresDirectory() Directory.CreateDirectory(Path.Combine(agentsDir, "-invalid")); // Invalid: starts with dash Directory.CreateDirectory(Path.Combine(agentsDir, "a")); // Invalid: too short - var result = service.GetDigest(projectSlug, "2026-01-01"); + var result = service.GetDigest(projectSlug, new DateOnly(2026, 1, 1)); result.AgentActivity.Count.ShouldBe(1); result.AgentActivity[0].AgentSlug.Value.ShouldBe("valid-agent"); diff --git a/ai-dev-net.tests.unit/SessionResultTests.cs b/ai-dev-net.tests.unit/SessionResultTests.cs index 270e0ec..ffea741 100644 --- a/ai-dev-net.tests.unit/SessionResultTests.cs +++ b/ai-dev-net.tests.unit/SessionResultTests.cs @@ -26,11 +26,11 @@ public void Deserialize_FullPayload_ReadsAllFields() result.ShouldNotBeNull(); result!.TaskId.ShouldBe("TASK-42"); - result.Status.ShouldBe("completed"); + result.SessionStatus.ShouldBe(SessionStatus.Completed); result.Summary.ShouldBe("Shipped the feature"); result.PullRequestUrl.ShouldBe("https://github.com/org/repo/pull/99"); result.FilesChanged.ShouldBe(["src/Foo.cs", "src/Bar.cs"]); - result.TestOutcome.ShouldBe("passed"); + result.TestOutcome.ShouldBe(TestOutcome.Passed); result.CompletedAt.ShouldNotBeNull(); result.Tags.ShouldBe(["feat", "api"]); } @@ -63,7 +63,7 @@ public void Deserialize_CamelCaseKeys_RoundTrips() { var original = new SessionResult( TaskId: "TASK-1", - Status: "completed", + SessionStatus: SessionStatus.Completed, Summary: "done", PullRequestUrl: null, FilesChanged: [], diff --git a/ai-dev-net.tests.unit/SessionSizeRatingTests.cs b/ai-dev-net.tests.unit/SessionSizeRatingTests.cs new file mode 100644 index 0000000..ebfd978 --- /dev/null +++ b/ai-dev-net.tests.unit/SessionSizeRatingTests.cs @@ -0,0 +1,48 @@ +namespace AiDevNet.Tests.Unit; + +public class SessionSizeRatingTests +{ + [Fact] + public void From_WhenSmall_ReturnsSmall() + { + var size = SessionSizeRating.From("small"); + + size.ShouldBe(SessionSizeRating.Small); + size.IsSmall.ShouldBeTrue(); + } + + [Fact] + public void From_WhenUnknown_ReturnsMedium() + { + var size = SessionSizeRating.From("x-large"); + + size.ShouldBe(SessionSizeRating.Medium); + size.IsMedium.ShouldBeTrue(); + } + + [Fact] + public void From_WhenNull_ReturnsMedium() + { + var size = SessionSizeRating.From(null); + + size.ShouldBe(SessionSizeRating.Medium); + } + + [Theory] + [InlineData(" ")] + [InlineData("")] + public void Constructor_WhenEmpty_Throws(string value) + { + Should.Throw(() => new SessionSizeRating(value)); + } + + [Fact] + public void JsonRoundTrip_SerializesAsString() + { + var json = System.Text.Json.JsonSerializer.Serialize(SessionSizeRating.Large); + json.ShouldBe("\"large\""); + + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + deserialized.ShouldBe(SessionSizeRating.Large); + } +} diff --git a/ai-dev-net.tests.unit/SessionStatusTests.cs b/ai-dev-net.tests.unit/SessionStatusTests.cs new file mode 100644 index 0000000..33e9bcd --- /dev/null +++ b/ai-dev-net.tests.unit/SessionStatusTests.cs @@ -0,0 +1,51 @@ +namespace AiDevNet.Tests.Unit; + +public class SessionStatusTests +{ + [Fact] + public void From_WhenCompleted_ReturnsCompleted() + { + var status = SessionStatus.From("completed"); + + status.ShouldBe(SessionStatus.Completed); + status.IsCompleted.ShouldBeTrue(); + } + + [Fact] + public void From_WhenFailed_ReturnsFailed() + { + var status = SessionStatus.From("failed"); + + status.ShouldBe(SessionStatus.Failed); + status.IsFailed.ShouldBeTrue(); + } + + [Fact] + public void From_WhenPartial_ReturnsPartial() + { + var status = SessionStatus.From("partial"); + + status.ShouldBe(SessionStatus.Partial); + status.IsPartial.ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("other")] + public void From_WhenInvalid_ThrowsArgumentException(string? value) + { + Should.Throw(() => SessionStatus.From(value)); + } + + [Fact] + public void JsonRoundTrip_SerializesAsString() + { + var json = System.Text.Json.JsonSerializer.Serialize(SessionStatus.Completed); + json.ShouldBe("\"completed\""); + + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + deserialized.ShouldBe(SessionStatus.Completed); + } +} diff --git a/ai-dev-net.tests.unit/TaskClassificationTests.cs b/ai-dev-net.tests.unit/TaskClassificationTests.cs new file mode 100644 index 0000000..713c738 --- /dev/null +++ b/ai-dev-net.tests.unit/TaskClassificationTests.cs @@ -0,0 +1,48 @@ +namespace AiDevNet.Tests.Unit; + +public class TaskClassificationTests +{ + [Fact] + public void From_WhenFeature_ReturnsFeature() + { + var classification = TaskClassification.From("feature"); + + classification.ShouldBe(TaskClassification.Feature); + classification.IsFeature.ShouldBeTrue(); + } + + [Fact] + public void From_WhenUnknown_ReturnsOther() + { + var classification = TaskClassification.From("custom"); + + classification.ShouldBe(TaskClassification.Other); + classification.IsOther.ShouldBeTrue(); + } + + [Fact] + public void From_WhenNull_ReturnsOther() + { + var classification = TaskClassification.From(null); + + classification.ShouldBe(TaskClassification.Other); + } + + [Theory] + [InlineData(" ")] + [InlineData("")] + public void Constructor_WhenEmpty_Throws(string value) + { + Should.Throw(() => new TaskClassification(value)); + } + + [Fact] + public void JsonRoundTrip_SerializesAsString() + { + var json = System.Text.Json.JsonSerializer.Serialize(TaskClassification.Refactor); + json.ShouldBe("\"refactor\""); + + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + deserialized.ShouldBe(TaskClassification.Refactor); + } +} diff --git a/ai-dev-net.tests.unit/TestOutcomeTests.cs b/ai-dev-net.tests.unit/TestOutcomeTests.cs new file mode 100644 index 0000000..549d97c --- /dev/null +++ b/ai-dev-net.tests.unit/TestOutcomeTests.cs @@ -0,0 +1,51 @@ +namespace AiDevNet.Tests.Unit; + +public class TestOutcomeTests +{ + [Fact] + public void From_WhenPassed_ReturnsPassed() + { + var outcome = TestOutcome.From("passed"); + + outcome.ShouldBe(TestOutcome.Passed); + outcome.IsPassed.ShouldBeTrue(); + } + + [Fact] + public void From_WhenFailed_ReturnsFailed() + { + var outcome = TestOutcome.From("failed"); + + outcome.ShouldBe(TestOutcome.Failed); + outcome.IsFailed.ShouldBeTrue(); + } + + [Fact] + public void From_WhenSkipped_ReturnsSkipped() + { + var outcome = TestOutcome.From("skipped"); + + outcome.ShouldBe(TestOutcome.Skipped); + outcome.IsSkipped.ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("unknown")] + public void From_WhenInvalid_ThrowsArgumentException(string? value) + { + Should.Throw(() => TestOutcome.From(value)); + } + + [Fact] + public void JsonRoundTrip_SerializesAsString() + { + var json = System.Text.Json.JsonSerializer.Serialize(TestOutcome.Passed); + json.ShouldBe("\"passed\""); + + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + deserialized.ShouldBe(TestOutcome.Passed); + } +} diff --git a/ai-dev.core.local/Implementation/LocalToolBroker.cs b/ai-dev.core.local/Implementation/LocalToolBroker.cs index f6fd063..3425053 100644 --- a/ai-dev.core.local/Implementation/LocalToolBroker.cs +++ b/ai-dev.core.local/Implementation/LocalToolBroker.cs @@ -89,7 +89,7 @@ private ToolOutcome ListDir(ToolRequest request) request.ToolName, Succeeded: true, Summary: $"{entries.Count} item(s) in {Path.GetRelativePath(_root, dir)}", - Evidence: entries.Select(e => Path.Combine(dir, e)).Take(20).ToList()); + Evidence: [.. entries.Select(e => Path.Combine(dir, e)).Take(20)]); } private async Task GrepAsync(ToolRequest request, CancellationToken ct) diff --git a/ai-dev.core.local/Implementation/ProgressiveDiscoveryEngine.cs b/ai-dev.core.local/Implementation/ProgressiveDiscoveryEngine.cs index 2d255b3..c77d171 100644 --- a/ai-dev.core.local/Implementation/ProgressiveDiscoveryEngine.cs +++ b/ai-dev.core.local/Implementation/ProgressiveDiscoveryEngine.cs @@ -180,10 +180,9 @@ private static string BuildRecommendation( } private static string[] ExtractTerms(string query) - => query.Split([' ', '.', ':', '/', '\\'], StringSplitOptions.RemoveEmptyEntries) + => [.. query.Split([' ', '.', ':', '/', '\\'], StringSplitOptions.RemoveEmptyEntries) .Where(t => t.Length >= 3) - .Take(5) - .ToArray(); + .Take(5)]; private static async Task FileContainsAnyTermAsync( string filePath, diff --git a/ai-dev.core.local/Implementation/RuleBasedContextCompactor.cs b/ai-dev.core.local/Implementation/RuleBasedContextCompactor.cs index e15da1b..a728f6c 100644 --- a/ai-dev.core.local/Implementation/RuleBasedContextCompactor.cs +++ b/ai-dev.core.local/Implementation/RuleBasedContextCompactor.cs @@ -37,31 +37,27 @@ private static IReadOnlyList ApplyKeepDropRules( var tail = unique.TakeLast(AlwaysKeepTail).ToHashSet(ReferenceEqualityComparer.Instance); - return unique - .Where(o => tail.Contains(o) || o.Evidence.Count > 0) - .ToList(); + return [.. unique.Where(o => tail.Contains(o) || o.Evidence.Count > 0)]; } // A fact is stable when it carries at least two independent citations. private static IReadOnlyList BuildFacts(IReadOnlyList observations) - => observations + => [.. observations .Select(o => new RuntimeFact( Category: o.Source, Fact: o.Summary, Citations: o.Evidence, - IsStable: o.Evidence.Count >= 2)) - .ToList(); + IsStable: o.Evidence.Count >= 2))]; // Open questions = latest decision per subject (superseded decisions dropped). private static IReadOnlyList BuildOpenQuestions(IReadOnlyList decisions) { if (decisions.Count == 0) return []; - return decisions + return [.. decisions .GroupBy(d => SubjectKey(d.Decision)) .Select(g => g.OrderByDescending(d => d.AtUtc).First()) - .Select(d => d.Decision) - .ToList(); + .Select(d => d.Decision)]; } // Use the first four words as the subject key so revisions to the same question group together. diff --git a/ai-dev.core/Executors/ModelCapabilities.cs b/ai-dev.core/Executors/ModelCapabilities.cs index 59a5ed3..8a1a412 100644 --- a/ai-dev.core/Executors/ModelCapabilities.cs +++ b/ai-dev.core/Executors/ModelCapabilities.cs @@ -7,17 +7,28 @@ namespace AiDev.Executors; [Flags] public enum ModelCapabilities { + /// + /// No special model capabilities. + /// None = 0, - /// Model supports streaming token output. + /// + /// Model supports streaming token output. + /// Streaming = 1 << 0, - /// Model supports function/tool calling. + /// + /// Model supports function and tool calling. + /// ToolCalling = 1 << 1, - /// Model accepts image inputs. + /// + /// Model accepts image inputs. + /// Vision = 1 << 2, - /// Model supports extended reasoning / chain-of-thought. + /// + /// Model supports extended reasoning. + /// Reasoning = 1 << 3, } diff --git a/ai-dev.core/Executors/ThinkingLevel.cs b/ai-dev.core/Executors/ThinkingLevel.cs index c2c2fbb..75a3d02 100644 --- a/ai-dev.core/Executors/ThinkingLevel.cs +++ b/ai-dev.core/Executors/ThinkingLevel.cs @@ -21,26 +21,62 @@ private ThinkingLevel(string key, string? reasoningEffort, int budgetTokens, str DisplayName = displayName; } + /// + /// Disables extended reasoning. + /// public static readonly ThinkingLevel Off = new("off", null, 0, "Off"); + + /// + /// Uses a low extended reasoning budget. + /// public static readonly ThinkingLevel Low = new("low", "low", 1_024, "Low (1K tokens)"); + + /// + /// Uses a medium extended reasoning budget. + /// public static readonly ThinkingLevel Medium = new("medium", "medium", 4_096, "Medium (4K tokens)"); + + /// + /// Uses a high extended reasoning budget. + /// public static readonly ThinkingLevel High = new("high", "high", 16_384, "High (16K tokens)"); + /// + /// Gets all supported thinking levels. + /// public static IReadOnlyList All { get; } = [Off, Low, Medium, High]; - /// Maximum thinking tokens to request. Zero when . + /// + /// Gets the maximum thinking tokens to request. Zero when . + /// public int BudgetTokens { get; } - /// "low"/"medium"/"high" for reasoning_effort APIs. Null when . + /// + /// Gets the reasoning effort value for reasoning-effort APIs. Null when . + /// public string? ReasoningEffort { get; } - /// User-facing display name including token count. + /// + /// Gets the user-facing display name including token count. + /// public string DisplayName { get; } = "Off"; + /// + /// Gets a value indicating whether extended reasoning is disabled. + /// public bool IsOff => BudgetTokens == 0; + /// + /// Serializes the thinking level to its persisted key. + /// + /// The serialized thinking level key. public string Serialize() => _key ?? "off"; + /// + /// Parses a persisted thinking level value. + /// + /// The raw thinking level value. + /// The parsed thinking level, defaulting to . public static ThinkingLevel Parse(string? value) => value?.ToLowerInvariant() switch { "low" => Low, @@ -49,6 +85,11 @@ private ThinkingLevel(string key, string? reasoningEffort, int budgetTokens, str _ => Off, }; + /// + /// Determines whether this thinking level equals another instance. + /// + /// The other thinking level. + /// when the instances are equal; otherwise, . public bool Equals(ThinkingLevel other) => (_key ?? "off") == (other._key ?? "off"); public override bool Equals(object? obj) => obj is ThinkingLevel other && Equals(other); public override int GetHashCode() => (_key ?? "off").GetHashCode(); diff --git a/ai-dev.core/Executors/ThinkingLevelJsonConverter.cs b/ai-dev.core/Executors/ThinkingLevelJsonConverter.cs index 42d291b..7ba3ad0 100644 --- a/ai-dev.core/Executors/ThinkingLevelJsonConverter.cs +++ b/ai-dev.core/Executors/ThinkingLevelJsonConverter.cs @@ -3,7 +3,15 @@ namespace AiDev.Executors; internal sealed class ThinkingLevelJsonConverter : JsonConverter { public override ThinkingLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => ThinkingLevel.Parse(reader.GetString()); + { + if (reader.TokenType == JsonTokenType.Number) + { + // Legacy: numeric values stored before string serialisation was introduced (0=Off,1=Low,2=Medium,3=High) + var index = reader.GetInt32(); + return index switch { 1 => ThinkingLevel.Low, 2 => ThinkingLevel.Medium, 3 => ThinkingLevel.High, _ => ThinkingLevel.Off }; + } + return ThinkingLevel.Parse(reader.GetString()); + } public override void Write(Utf8JsonWriter writer, ThinkingLevel value, JsonSerializerOptions options) => writer.WriteStringValue(value.Serialize()); diff --git a/ai-dev.core/Features/Agent/AgentActivityItem.cs b/ai-dev.core/Features/Agent/AgentActivityItem.cs index b63ff1d..6cec24c 100644 --- a/ai-dev.core/Features/Agent/AgentActivityItem.cs +++ b/ai-dev.core/Features/Agent/AgentActivityItem.cs @@ -1,11 +1,37 @@ namespace AiDev.Features.Agent; +/// +/// Represents agent activity details for display and reporting. +/// public class AgentActivityItem { + /// + /// Gets or sets the unique agent slug. + /// public AgentSlug AgentSlug { get; set; } = new("unnamed"); + + /// + /// Gets or sets the display name of the agent. + /// public string AgentName { get; set; } = string.Empty; + + /// + /// Gets or sets the configured executor for the agent. + /// public AgentExecutorName? Executor { get; set; } + + /// + /// Gets or sets the model identifier used by the agent. + /// public string Model { get; set; } = string.Empty; + + /// + /// Gets or sets the number of messages sent by the agent. + /// public int MessagesSent { get; set; } + + /// + /// Gets or sets the number of messages received by the agent. + /// public int MessagesReceived { get; set; } } diff --git a/ai-dev.core/Features/Agent/AgentFailover.cs b/ai-dev.core/Features/Agent/AgentFailover.cs new file mode 100644 index 0000000..8872cc3 --- /dev/null +++ b/ai-dev.core/Features/Agent/AgentFailover.cs @@ -0,0 +1,8 @@ +namespace AiDev.Features.Agent; + +/// +/// Records when an agent falls back to a different executor. +/// +/// The executor selected during failover. +/// The UTC timestamp when the failover occurred. +public sealed record AgentFailover(AgentExecutorName Executor, DateTime OccurredAt); diff --git a/ai-dev.core/Features/Agent/AgentFailure.cs b/ai-dev.core/Features/Agent/AgentFailure.cs new file mode 100644 index 0000000..94b8e9e --- /dev/null +++ b/ai-dev.core/Features/Agent/AgentFailure.cs @@ -0,0 +1,8 @@ +namespace AiDev.Features.Agent; + +/// +/// Represents details about a failed agent run. +/// +/// The failure message. +/// The UTC timestamp when the failure occurred. +public sealed record AgentFailure(string Error, DateTime OccurredAt); diff --git a/ai-dev.core/Features/Agent/AgentInboxService.cs b/ai-dev.core/Features/Agent/AgentInboxService.cs index b1b0e95..a826464 100644 --- a/ai-dev.core/Features/Agent/AgentInboxService.cs +++ b/ai-dev.core/Features/Agent/AgentInboxService.cs @@ -1,12 +1,28 @@ namespace AiDev.Features.Agent; -public class AgentInboxService( +/// +/// Writes inbox messages for agents and notifies project state changes. +/// +public partial class AgentInboxService( WorkspacePaths paths, ProjectStateChangedNotifier projectStateChangedNotifier, ILogger logger) { private static readonly ActivitySource ActivitySource = new("AiDevNet.AgentRunner"); + /// + /// Writes a message into an agent inbox. + /// + /// The project that owns the agent. + /// The target agent slug. + /// The message source. + /// The message subject. + /// The message type. + /// The message priority. + /// The message body. + /// The optional associated task identifier. + /// The optional associated decision identifier. + /// The result of the write operation. public Result WriteInboxMessage(ProjectSlug projectSlug, AgentSlug agentSlug, MessageSource from, string re, MessageType type, Priority priority, string body, TaskId? taskId = null, DecisionId? decisionId = null) @@ -38,22 +54,27 @@ public Result WriteInboxMessage(ProjectSlug projectSlug, AgentSlug agentSl }; if (taskId != null) fields["task-id"] = taskId.ToString(); if (decisionId != null) fields["decision-id"] = decisionId.Value; + var content = FrontmatterParser.Stringify(fields, body); File.WriteAllText(filePath, content); projectStateChangedNotifier.Notify(projectSlug, ProjectStateChangeKind.Messages | ProjectStateChangeKind.Agents); activity?.SetTag("message.filename", unique); activity?.SetTag("message.success", true); - logger.LogInformation("[runner] Inbox message written: {Project}/{Agent} ← {From} ({Type}) [{File}]", - projectSlug, agentSlug, from, type, unique); + LogInboxMessageWritten(projectSlug, agentSlug, from, type, unique); return new Ok(Unit.Value); } catch (Exception ex) { activity?.SetTag("message.success", false); activity?.SetTag("message.error", ex.Message); - logger.LogError(ex, "[runner] Failed to write inbox message to {Project}/{Agent} from {From}: {Error}", - projectSlug, agentSlug, from, ex.Message); + LogInboxWriteFailed(ex, projectSlug, agentSlug, from, ex.Message); return new Err(new DomainError("INBOX_WRITE_FAILED", ex.Message)); } } + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Inbox message written: {Project}/{Agent} ← {From} ({Type}) [{File}]")] + private partial void LogInboxMessageWritten(ProjectSlug project, AgentSlug agent, MessageSource from, MessageType type, string file); + + [LoggerMessage(Level = LogLevel.Error, Message = "[runner] Failed to write inbox message to {Project}/{Agent} from {From}: {Error}")] + private partial void LogInboxWriteFailed(Exception ex, ProjectSlug project, AgentSlug agent, MessageSource from, string error); } diff --git a/ai-dev.core/Features/Agent/AgentInfo.cs b/ai-dev.core/Features/Agent/AgentInfo.cs index cfeb2e0..944a132 100644 --- a/ai-dev.core/Features/Agent/AgentInfo.cs +++ b/ai-dev.core/Features/Agent/AgentInfo.cs @@ -2,149 +2,81 @@ namespace AiDev.Features.Agent; -public sealed class AgentInfo +/// +/// Represents the shared metadata and derived status for an agent. +/// +public abstract record AgentInfo { - public AgentInfo( - AgentSlug slug, - string name, - string role, - string description, - string? model = null, - AgentStatus? status = null, - DateTime? lastRunAt = null, - int inboxCount = 0, - AgentExecutorName? executor = null, - IReadOnlyList? skills = null, - string? lastError = null, - DateTime? lastErrorAt = null, - ThinkingLevel thinkingLevel = default, - AgentExecutorName? failoverExecutor = null, - DateTime? failedOverAt = null) - { - ArgumentNullException.ThrowIfNull(slug); - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Agent name is required.", nameof(name)); - if (inboxCount < 0) - throw new ArgumentOutOfRangeException(nameof(inboxCount)); - - Slug = slug; - Name = name; - Role = role ?? string.Empty; - Description = description ?? string.Empty; - Model = string.IsNullOrWhiteSpace(model) ? "sonnet" : model; - Status = status ?? AgentStatus.Idle; - LastRunAt = lastRunAt; - InboxCount = inboxCount; - Executor = executor ?? AgentExecutorName.Default; - Skills = skills is null ? [] : [.. skills]; - LastError = string.IsNullOrWhiteSpace(lastError) ? null : lastError; - LastErrorAt = lastErrorAt; - ThinkingLevel = thinkingLevel; - FailoverExecutor = failoverExecutor; - FailedOverAt = failoverExecutor == null ? null : failedOverAt; - } - - public AgentSlug Slug { get; } - public string Name { get; private set; } - public string Role { get; private set; } - public string Model { get; private set; } - public AgentStatus Status { get; private set; } - public string Description { get; private set; } - public DateTime? LastRunAt { get; private set; } - public int InboxCount { get; private set; } - public AgentExecutorName Executor { get; private set; } - public string? LastError { get; private set; } - public DateTime? LastErrorAt { get; private set; } - public ThinkingLevel ThinkingLevel { get; private set; } - - /// - /// Skill keys enabled for this agent (e.g. ["git-read", "git-write"]). - /// Empty means the executor uses its own defaults (preserves behaviour for existing agents). - /// - public IReadOnlyList Skills { get; private set; } - - /// - /// The executor this agent was automatically failed over to, or null if no failover has occurred. - /// Set by AgentRunnerService when it detects an executor failure and switches the agent. - /// - public AgentExecutorName? FailoverExecutor { get; private set; } + /// Gets the agent slug. + public required AgentSlug Slug { get; init; } + /// Gets the agent display name. + public required string Name { get; init; } + /// Gets the agent role. + public required string Role { get; init; } + /// Gets the agent description. + public required string Description { get; init; } + /// Gets the configured model identifier. + public required string Model { get; init; } + /// Gets the configured executor. + public required AgentExecutorName Executor { get; init; } + /// Gets the enabled skills. + public required IReadOnlyList Skills { get; init; } + /// Gets the configured thinking level. + public required ThinkingLevel ThinkingLevel { get; init; } + /// Gets the current inbox count. + public required int InboxCount { get; init; } + /// Gets the optional failover metadata. + public AgentFailover? Failover { get; init; } + + // ── XAML-bindable projections ──────────────────────────────────────────── + // These delegate to the concrete type so existing bindings need no changes. /// - /// When the automatic failover occurred, or null if no failover has occurred. + /// Gets the derived runtime status of the agent. /// - public DateTime? FailedOverAt { get; private set; } - - /// - /// Updates editable agent metadata while keeping defaults and null handling consistent. - /// - public void UpdateMetadata(string name, string role, string description, string? model, AgentExecutorName? executor, IReadOnlyList? skills, ThinkingLevel thinkingLevel = default) + public AgentStatus Status => this switch { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Agent name is required.", nameof(name)); - - Name = name; - Role = role ?? string.Empty; - Description = description ?? string.Empty; - Model = string.IsNullOrWhiteSpace(model) ? "sonnet" : model; - Executor = executor ?? AgentExecutorName.Default; - Skills = skills is null ? [] : [.. skills]; - ThinkingLevel = thinkingLevel; - } + AgentInfoRunning => AgentStatus.Running, + AgentInfoFailed => AgentStatus.Error, + _ => AgentStatus.Idle, + }; /// - /// Marks the agent as actively running and records when that run started. + /// Gets the timestamp of the most recent run when available. /// - public void MarkRunning(DateTime startedAt) + public DateTime? LastRunAt => this switch { - Status = AgentStatus.Running; - LastRunAt = startedAt; - } - - /// - /// Marks the agent as idle after work completes or when loading idle state. - /// - public void MarkIdle() => Status = AgentStatus.Idle; - - /// - /// Marks the agent as faulted without exposing raw status mutation to callers. - /// - public void MarkError() => Status = AgentStatus.Error; + AgentInfoRunning r => r.StartedAt, + AgentInfoFailed f => f.PreviousRunAt, + AgentInfoIdle i => i.PreviousRunAt, + _ => null, + }; /// - /// Records the last session failure so the UI can guide the user. + /// Gets the last error message when the agent is failed. /// - public void SetLastError(string? error, DateTime? occurredAt) - { - LastError = string.IsNullOrWhiteSpace(error) ? null : error; - LastErrorAt = LastError == null ? null : occurredAt; - } - + public string? LastError => (this as AgentInfoFailed)?.Failure.Error; /// - /// Synchronizes inbox count with the current workspace state. + /// Gets the timestamp of the last error when the agent is failed. /// - public void SetInboxCount(int inboxCount) - { - if (inboxCount < 0) - throw new ArgumentOutOfRangeException(nameof(inboxCount)); + public DateTime? LastErrorAt => (this as AgentInfoFailed)?.Failure.OccurredAt; +} - InboxCount = inboxCount; - } +/// Agent is not currently running. PreviousRunAt records the last session start, if any. +public sealed record AgentInfoIdle : AgentInfo +{ + public DateTime? PreviousRunAt { get; init; } +} - /// - /// Records that this agent was automatically failed over to a fallback executor. - /// - public void RecordFailover(AgentExecutorName executor, DateTime at) - { - FailoverExecutor = executor; - FailedOverAt = at; - } +/// Agent session is active. StartedAt is always present. +public sealed record AgentInfoRunning : AgentInfo +{ + public required DateTime StartedAt { get; init; } +} - /// - /// Clears any recorded failover state (e.g. after the user manually reassigns the executor). - /// - public void ClearFailover() - { - FailoverExecutor = null; - FailedOverAt = null; - } +/// Agent's last session ended in failure. Failure details are always present. +public sealed record AgentInfoFailed : AgentInfo +{ + public required AgentFailure Failure { get; init; } + public DateTime? PreviousRunAt { get; init; } } diff --git a/ai-dev.core/Features/Agent/AgentPromptBuilder.cs b/ai-dev.core/Features/Agent/AgentPromptBuilder.cs index ec64710..bf35e14 100644 --- a/ai-dev.core/Features/Agent/AgentPromptBuilder.cs +++ b/ai-dev.core/Features/Agent/AgentPromptBuilder.cs @@ -7,7 +7,7 @@ namespace AiDev.Features.Agent; /// Assembles the effective prompt for an agent session by injecting KB context /// and playbook instructions ahead of the base prompt. /// -public class AgentPromptBuilder( +public partial class AgentPromptBuilder( KbService kbService, PlaybookService playbookService, ILogger logger) @@ -25,7 +25,7 @@ public string Build(ProjectSlug projectSlug, AgentSlug agentSlug, if (!string.IsNullOrEmpty(kbContext)) { effectivePrompt = kbContext + "\n\n---\n\n" + effectivePrompt; - logger.LogInformation("[runner] Injected KB context into prompt for {Key}", key); + LogKbContextInjected(key); } var playbookSlug = ExtractPlaybookSlug(inboxDir, inboxSnapshot); @@ -35,17 +35,26 @@ public string Build(ProjectSlug projectSlug, AgentSlug agentSlug, if (!string.IsNullOrEmpty(playbookContext)) { effectivePrompt = playbookContext + "\n\n---\n\n" + effectivePrompt; - logger.LogInformation("[runner] Injected playbook '{Slug}' into prompt for {Key}", playbookSlug, key); + LogPlaybookInjected(playbookSlug, key); } else { - logger.LogWarning("[runner] Playbook '{Slug}' specified in inbox message not found for {Key}", playbookSlug, key); + LogPlaybookNotFound(playbookSlug, key); } } return effectivePrompt; } + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Injected KB context into prompt for {Key}")] + private partial void LogKbContextInjected(string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Injected playbook '{Slug}' into prompt for {Key}")] + private partial void LogPlaybookInjected(string slug, string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Playbook '{Slug}' specified in inbox message not found for {Key}")] + private partial void LogPlaybookNotFound(string slug, string key); + private static string? ExtractPlaybookSlug(string inboxDir, string[] snapshot) { foreach (var filename in snapshot) diff --git a/ai-dev.core/Features/Agent/AgentRunnerService.cs b/ai-dev.core/Features/Agent/AgentRunnerService.cs index d82ad37..fff1cce 100644 --- a/ai-dev.core/Features/Agent/AgentRunnerService.cs +++ b/ai-dev.core/Features/Agent/AgentRunnerService.cs @@ -9,7 +9,7 @@ namespace AiDev.Features.Agent; /// Selects the appropriate IAgentExecutor, builds the ExecutorContext (prompt, skills, model), /// streams output to a transcript file, and handles inbox archiving and rate-limit suppression. /// -public class AgentRunnerService( +public partial class AgentRunnerService( WorkspacePaths paths, ModelResolver modelResolver, AgentStatusWriter statusWriter, @@ -59,17 +59,27 @@ private sealed class SessionInfo(CancellationTokenSource cts) public bool IsRunning(ProjectSlug projectSlug, AgentSlug agentSlug) => _sessions.ContainsKey(Key(projectSlug, agentSlug)); +/// +/// Returns true if the specified agent is currently rate-limited, meaning it has recently hit a rate limit and should suppress launches until the cooldown expires. Rate-limited agents are not automatically re-launched when new inbox messages arrive, but they can be manually launched by the user and will check the rate limit again at that time. +/// +/// The slug of the project. +/// The slug of the agent. +/// True if the agent is rate-limited; otherwise, false. public bool IsRateLimited(ProjectSlug projectSlug, AgentSlug agentSlug) => _rateLimitedUntil.TryGetValue(Key(projectSlug, agentSlug), out var until) && DateTime.UtcNow < until; +/// +/// Gets a list of currently running agent sessions, including their project slug, agent slug, process ID, and start time. This can be used to display active sessions in the UI or for monitoring purposes. +/// +/// A list of running sessions. public IReadOnlyList GetRunningSessions() => - _sessions.Values.Select(s => new RunningSession + [.. _sessions.Values.Select(s => new RunningSession { ProjectSlug = s.ProjectSlug, AgentSlug = s.AgentSlug, Pid = s.Pid, StartedAt = s.StartedAt, - }).ToList(); + })]; /// /// Resets any agent.json still showing status="running" that has no live session in @@ -90,12 +100,10 @@ public async Task RecoverStaleSessionsAsync(IEnumerable projects) try { var info = agentService.LoadAgent(project, slug); - if (info?.Status != AgentStatus.Running) continue; + if (info is not AgentInfoRunning) continue; if (_sessions.ContainsKey(Key(project, slug))) continue; - logger.LogWarning( - "[runner] Recovering stale running state for {Project}/{Agent} — resetting to idle", - project.Value, slug.Value); + LogRecoveringStaleSession(project.Value, slug.Value); await statusWriter.UpdateAsync(agentDir, new() { @@ -107,14 +115,12 @@ public async Task RecoverStaleSessionsAsync(IEnumerable projects) } catch (Exception ex) { - logger.LogWarning(ex, "[runner] Failed to inspect agent {Project}/{Agent} during stale-session recovery", - project.Value, slug.Value); + LogStaleSessionRecoveryFailed(ex, project.Value, slug.Value); } } } } - /// Launches an agent. Returns false if already running or rate-limited. /// The process runs in the background — this method returns quickly. /// @@ -123,13 +129,13 @@ public bool LaunchAgent(ProjectSlug projectSlug, AgentSlug agentSlug, AgentLaunc var key = Key(projectSlug, agentSlug); if (_sessions.ContainsKey(key)) { - logger.LogInformation("[runner] Agent already running: {Key}", key); + LogAgentAlreadyRunning(key); return false; } if (_rateLimitedUntil.TryGetValue(key, out var until) && DateTime.UtcNow < until) { - logger.LogInformation("[runner] Agent rate-limited until {Until}, skipping launch: {Key}", until, key); + LogAgentRateLimitedSkippingLaunch(until, key); return false; } @@ -157,7 +163,7 @@ public bool LaunchAgent(ProjectSlug projectSlug, AgentSlug agentSlug, AgentLaunc .ContinueWith(t => { var ex = t.Exception?.InnerException ?? t.Exception; - logger.LogError(ex, "[runner] RunSessionAsync faulted for {Key} before session try-catch", key); + LogRunSessionFaulted(ex, key); _sessions.TryRemove(key, out _); // Best-effort status update so the UI shows the error. @@ -183,7 +189,7 @@ public bool StopAgent(ProjectSlug projectSlug, AgentSlug agentSlug) { var key = Key(projectSlug, agentSlug); if (!_sessions.TryGetValue(key, out var info)) return false; - logger.LogInformation("[runner] Stopping agent: {Key}", key); + LogStoppingAgent(key); info.Cts.Cancel(); projectStateChangedNotifier.Notify(projectSlug, ProjectStateChangeKind.Agents); @@ -192,7 +198,7 @@ public bool StopAgent(ProjectSlug projectSlug, AgentSlug agentSlug) // agent can be re-launched. if (info.Pid == 0) { - logger.LogWarning("[runner] Session {Key} has no PID — forcibly removing orphaned session", key); + LogOrphanedSessionRemoved(key); _sessions.TryRemove(key, out _); var agentDir = paths.AgentDir(projectSlug, agentSlug); @@ -233,7 +239,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte var loadedInfo = agentService.LoadAgent(projectSlug, agentSlug); if (loadedInfo == null) { - logger.LogError("[runner] Agent {Key} has missing or malformed agent.json; aborting launch", key); + LogAgentJsonMissingOrMalformed(key); await statusWriter.UpdateAsync(agentDir, new() { ["lastError"] = "Missing or malformed agent.json; cannot determine executor and model.", @@ -254,8 +260,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte if (!_executors.TryGetValue(executorName, out var resolvedExecutor)) { var available = string.Join(", ", _executors.Keys); - logger.LogError("[runner] Agent {Key} requested executor '{Executor}' which is not registered. Available: {Available}", - key, executorName.Value, available); + LogExecutorNotRegistered(key, executorName.Value, available); await statusWriter.UpdateAsync(agentDir, new() { ["lastError"] = $"Executor '{executorName.Value}' is not registered. Available: {available}", @@ -272,9 +277,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte // health checks run, so this is advisory only — we do not block launch. if (modelRegistry.Find(executorName, modelId) == null) { - logger.LogWarning("[runner] Agent {Key}: model '{Model}' is not registered for executor '{Executor}'. " + - "This may cause a runtime failure if the model does not exist.", - key, modelId, executorName.Value); + LogModelNotRegistered(key, modelId, executorName.Value); } await statusWriter.UpdateAsync(agentDir, new() @@ -311,8 +314,8 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte // trigger a relaunch via completionProcessor. if (Directory.Exists(inboxDir)) { - try { inboxSnapshot = Directory.GetFiles(inboxDir, "*.md").Select(Path.GetFileName).OfType().OrderBy(f => f).ToArray(); } - catch (Exception ex) { logger.LogWarning(ex, "[runner] Failed to read inbox directory {InboxDir}", inboxDir); } + try { inboxSnapshot = [.. Directory.GetFiles(inboxDir, "*.md").Select(Path.GetFileName).OfType().OrderBy(f => f)]; } + catch (Exception ex) { LogReadInboxDirectoryFailed(ex, inboxDir); } } // Build prompt: inject KB context and playbook before the standard instruction. @@ -344,9 +347,9 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte { info.Pid = pid; _ = statusWriter.UpdateAsync(agentDir, new() { ["pid"] = pid }) - .ContinueWith(t => logger.LogWarning(t.Exception, "[runner] Failed to write PID for {Key}", key), + .ContinueWith(t => LogWritePidFailed(t.Exception, key), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); - logger.LogInformation("[runner] Launched {Key} PID={Pid}", key, pid); + LogLaunchedWithPid(key, pid); activity?.SetTag("agent.pid", pid); activity?.AddEvent(new("process.started")); }, @@ -355,7 +358,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte Secrets: secrets.Count > 0 ? secrets : null, ReportWarning: warning => { - logger.LogWarning("[runner] {Key}: {Warning}", key, warning); + LogAgentWarning(key, warning); _ = statusWriter.UpdateAsync(agentDir, new() { ["lastWarning"] = warning }); }); @@ -404,7 +407,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte ? $"Agent exited with code {exitCode}." : sessionError; - logger.LogError("[runner] Agent {Key} failed with exit code {Code}: {Error}", key, exitCode, sessionError); + LogAgentFailedWithExitCode(key, exitCode, sessionError); activity?.SetTag("agent.error", true); activity?.SetTag("agent.errorMessage", sessionError); activity?.SetStatus(ActivityStatusCode.Error, sessionError); @@ -414,7 +417,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte catch (OperationCanceledException) { exitCode = 130; - logger.LogInformation("[runner] Agent {Key} cancelled", key); + LogAgentCancelled(key); activity?.SetTag("agent.cancelled", true); activity?.AddEvent(new("process.cancelled")); } @@ -422,7 +425,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte { exitCode = 1; sessionError = ex.Message; - logger.LogError(ex, "[runner] Agent {Key} error", key); + LogAgentError(ex, key); activity?.SetTag("agent.error", true); activity?.SetTag("agent.errorMessage", ex.Message); activity?.SetStatus(ActivityStatusCode.Error, ex.Message); @@ -445,11 +448,11 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte outputChannel.Writer.TryWrite($"## Session ended at {exitedAt:o} (exit code: {exitCode})"); outputChannel.Writer.TryComplete(); try { await consumerTask; } - catch (Exception ex) { logger.LogWarning(ex, "[runner] Transcript flush faulted for {Key}", key); } + catch (Exception ex) { LogTranscriptFlushFaulted(ex, key); } activity?.SetTag("agent.finishedAt", exitedAt.ToString("o")); activity?.AddEvent(new("session.finished")); - logger.LogInformation("[runner] Agent {Key} finished (exit={Code}) at {Time}", key, exitCode, exitedAt); + LogAgentFinished(key, exitCode, exitedAt); // Write final status to agent.json — wrapped so a disk error can't abort // inbox archival or the relaunch check below. @@ -483,9 +486,7 @@ private async Task RunSessionAsync(string key, SessionInfo info, DateTime starte { var suppressUntil = DateTime.UtcNow.AddMinutes(30); _rateLimitedUntil[key] = suppressUntil; - logger.LogWarning( - "[runner] Agent {Key} hit a rate limit — inbox NOT archived, launches suppressed until {Until}", - key, suppressUntil); + LogAgentRateLimited(key, suppressUntil); } else { @@ -521,4 +522,64 @@ private static void ApplyTriggerTags(Activity? activity, AgentLaunchTrigger? tri if (!string.IsNullOrWhiteSpace(trigger.MessageFile)) activity.SetTag("message.file", trigger.MessageFile); } + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Recovering stale running state for {Project}/{Agent} — resetting to idle")] + private partial void LogRecoveringStaleSession(string project, string agent); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to inspect agent {Project}/{Agent} during stale-session recovery")] + private partial void LogStaleSessionRecoveryFailed(Exception ex, string project, string agent); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Agent already running: {Key}")] + private partial void LogAgentAlreadyRunning(string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Agent rate-limited until {Until}, skipping launch: {Key}")] + private partial void LogAgentRateLimitedSkippingLaunch(DateTime until, string key); + + [LoggerMessage(Level = LogLevel.Error, Message = "[runner] RunSessionAsync faulted for {Key} before session try-catch")] + private partial void LogRunSessionFaulted(Exception? ex, string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Stopping agent: {Key}")] + private partial void LogStoppingAgent(string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Session {Key} has no PID — forcibly removing orphaned session")] + private partial void LogOrphanedSessionRemoved(string key); + + [LoggerMessage(Level = LogLevel.Error, Message = "[runner] Agent {Key} has missing or malformed agent.json; aborting launch")] + private partial void LogAgentJsonMissingOrMalformed(string key); + + [LoggerMessage(Level = LogLevel.Error, Message = "[runner] Agent {Key} requested executor '{Executor}' which is not registered. Available: {Available}")] + private partial void LogExecutorNotRegistered(string key, string executor, string available); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Agent {Key}: model '{Model}' is not registered for executor '{Executor}'. This may cause a runtime failure if the model does not exist.")] + private partial void LogModelNotRegistered(string key, string model, string executor); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to read inbox directory {InboxDir}")] + private partial void LogReadInboxDirectoryFailed(Exception ex, string inboxDir); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to write PID for {Key}")] + private partial void LogWritePidFailed(Exception? ex, string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Launched {Key} PID={Pid}")] + private partial void LogLaunchedWithPid(string key, int pid); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] {Key}: {Warning}")] + private partial void LogAgentWarning(string key, string warning); + + [LoggerMessage(Level = LogLevel.Error, Message = "[runner] Agent {Key} failed with exit code {Code}: {Error}")] + private partial void LogAgentFailedWithExitCode(string key, int code, string? error); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Agent {Key} cancelled")] + private partial void LogAgentCancelled(string key); + + [LoggerMessage(Level = LogLevel.Error, Message = "[runner] Agent {Key} error")] + private partial void LogAgentError(Exception ex, string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Transcript flush faulted for {Key}")] + private partial void LogTranscriptFlushFaulted(Exception ex, string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Agent {Key} finished (exit={Code}) at {Time}")] + private partial void LogAgentFinished(string key, int code, DateTime time); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Agent {Key} hit a rate limit — inbox NOT archived, launches suppressed until {Until}")] + private partial void LogAgentRateLimited(string key, DateTime until); } diff --git a/ai-dev.core/Features/Agent/AgentService.cs b/ai-dev.core/Features/Agent/AgentService.cs index 23cdedd..e0eb64a 100644 --- a/ai-dev.core/Features/Agent/AgentService.cs +++ b/ai-dev.core/Features/Agent/AgentService.cs @@ -20,7 +20,10 @@ file class AgentJson public string? FailedOverAt { get; init; } } -public class AgentService( +/// +/// Provides agent discovery, persistence, and metadata management operations. +/// +public partial class AgentService( WorkspacePaths paths, AgentTemplatesService templates, AtomicFileWriter fileWriter, @@ -32,18 +35,28 @@ public class AgentService( private static readonly DomainError AgentNotFoundError = new("AGENT_NOT_FOUND", "Agent not found."); + /// + /// Lists agents for a project. + /// + /// The project whose agents should be listed. + /// The project agents ordered by name. public List ListAgents(ProjectSlug projectSlug) { var agentsDir = paths.AgentsDir(projectSlug); if (!agentsDir.Exists()) return []; - return Directory.GetDirectories(agentsDir) + return [.. Directory.GetDirectories(agentsDir) .Select(d => AgentSlug.TryParse(Path.GetFileName(d), out var a) ? LoadAgent(projectSlug, a) : null) .OfType() - .OrderBy(a => a.Name) - .ToList(); + .OrderBy(a => a.Name)]; } + /// + /// Loads agent metadata for a specific agent. + /// + /// The project that owns the agent. + /// The agent slug to load. + /// The loaded agent information, or when unavailable. public AgentInfo? LoadAgent(ProjectSlug projectSlug, AgentSlug agentSlug) { var jsonPath = paths.AgentJsonPath(projectSlug, agentSlug); @@ -61,26 +74,52 @@ public List ListAgents(ProjectSlug projectSlug) var inboxDir = paths.AgentInboxDir(projectSlug, agentSlug); var inboxCount = inboxDir.Exists() ? Directory.GetFiles(inboxDir, "*.md").Length : 0; - return new( - slug: data.Slug ?? agentSlug, - name: data.Name ?? agentSlug, - role: data.Role ?? string.Empty, - description: data.Description ?? string.Empty, - model: model, - status: AgentStatus.From(data.Status), - lastRunAt: DateTime.TryParse(data.LastRunAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var lastRun) ? lastRun : null, - inboxCount: inboxCount, - executor: executor, - skills: data.Skills ?? [], - lastError: data.LastError, - lastErrorAt: DateTime.TryParse(data.LastErrorAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var lastErrorAt) ? lastErrorAt : null, - thinkingLevel: ThinkingLevel.Parse(data.Thinking), - failoverExecutor: AgentExecutorName.TryParse(data.FailoverExecutor, out var failoverExecutor) ? failoverExecutor : null, - failedOverAt: DateTime.TryParse(data.FailedOverAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var failedOverAt) ? failedOverAt : null); + var slug2 = data.Slug ?? agentSlug; + var name = data.Name ?? (string)agentSlug; + var role = data.Role ?? string.Empty; + var description = data.Description ?? string.Empty; + var model2 = string.IsNullOrWhiteSpace(model) ? "sonnet" : model; + var thinking = ThinkingLevel.Parse(data.Thinking); + var skills = data.Skills ?? []; + + var previousRunAt = DateTime.TryParse(data.LastRunAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var lastRun) + ? lastRun : (DateTime?)null; + var failure = data.LastError is { Length: > 0 } lastErrStr + ? new AgentFailure(lastErrStr, DateTime.TryParse(data.LastErrorAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var lastErrAt) ? lastErrAt : DateTime.UtcNow) + : null; + var failover = AgentExecutorName.TryParse(data.FailoverExecutor, out var foExec) + ? new AgentFailover(foExec, DateTime.TryParse(data.FailedOverAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var foAt) ? foAt : DateTime.UtcNow) + : null; + + var status = AgentStatus.From(data.Status); + return status switch + { + { IsRunning: true } => new AgentInfoRunning + { + Slug = slug2, Name = name, Role = role, Description = description, + Model = model2, Executor = executor, Skills = skills, + ThinkingLevel = thinking, InboxCount = inboxCount, Failover = failover, + StartedAt = previousRunAt ?? DateTime.UtcNow, + }, + { IsError: true } when failure is not null => new AgentInfoFailed + { + Slug = slug2, Name = name, Role = role, Description = description, + Model = model2, Executor = executor, Skills = skills, + ThinkingLevel = thinking, InboxCount = inboxCount, Failover = failover, + Failure = failure, PreviousRunAt = previousRunAt, + }, + _ => new AgentInfoIdle + { + Slug = slug2, Name = name, Role = role, Description = description, + Model = model2, Executor = executor, Skills = skills, + ThinkingLevel = thinking, InboxCount = inboxCount, Failover = failover, + PreviousRunAt = previousRunAt, + }, + }; } catch (Exception ex) { - logger.LogWarning(ex, "[agent] Failed to load agent.json for {Project}/{Agent}", projectSlug, agentSlug); + LogFailedToLoadAgentJson(ex, projectSlug, agentSlug); return null; } } @@ -200,7 +239,13 @@ public Result SaveFailoverState(ProjectSlug projectSlug, AgentSlug agentSl }); } - public string GetClaudeMd(ProjectSlug projectSlug, AgentSlug agentSlug) +/// +/// Gets the CLAUDE.md content for the specified agent. +/// +/// The slug of the project. +/// The slug of the agent. +/// The CLAUDE.md content, or an empty string when the file does not exist. +public string GetClaudeMd(ProjectSlug projectSlug, AgentSlug agentSlug) { try { @@ -210,7 +255,14 @@ public string GetClaudeMd(ProjectSlug projectSlug, AgentSlug agentSlug) catch (ArgumentException) { return string.Empty; } } - public Result SaveClaudeMd(ProjectSlug projectSlug, AgentSlug agentSlug, string content) +/// +/// Saves CLAUDE.md content for the specified agent. +/// +/// The slug of the project. +/// The slug of the agent. +/// The CLAUDE.md content to save. +/// A result indicating success or failure. +public Result SaveClaudeMd(ProjectSlug projectSlug, AgentSlug agentSlug, string content) { return coordinator.Execute>(projectSlug, () => { @@ -225,7 +277,15 @@ public Result SaveClaudeMd(ProjectSlug projectSlug, AgentSlug agentSlug, s }); } - public Result CreateAgent(ProjectSlug projectSlug, string agentSlug, string name, string? templateSlug) +/// +/// Creates a new agent from an optional template. +/// +/// The slug of the project. +/// The slug of the agent. +/// The name of the agent. +/// The optional template slug used for the initial configuration. +/// A result indicating success or failure. +public Result CreateAgent(ProjectSlug projectSlug, string agentSlug, string name, string? templateSlug) { if (!AgentSlug.TryParse(agentSlug, out var slug)) return new Err(new DomainError("AGENT_SLUG_FORMAT", "Slug must contain only lowercase letters, digits, and hyphens, and cannot start or end with a hyphen.")); @@ -289,23 +349,35 @@ public Result CreateAgent(ProjectSlug projectSlug, string agentSlug, strin }); } - public TranscriptDate[] ListTranscriptDates(ProjectSlug projectSlug, AgentSlug agentSlug) +/// +/// Lists available transcript dates for an agent. +/// +/// The slug of the project. +/// The slug of the agent. +/// The available transcript dates sorted in descending order. +public TranscriptDate[] ListTranscriptDates(ProjectSlug projectSlug, AgentSlug agentSlug) { try { var dir = paths.AgentTranscriptsDir(projectSlug, agentSlug); if (!dir.Exists()) return []; - return Directory.GetFiles(dir, "????-??-??.md") + return [.. Directory.GetFiles(dir, "????-??-??.md") .Select(f => Path.GetFileNameWithoutExtension(f)!) .Select(s => TranscriptDate.TryParse(s, out var d) ? d : null) .OfType() - .OrderDescending() - .ToArray(); + .OrderDescending()]; } catch (ArgumentException) { return []; } } - public string ReadTranscript(ProjectSlug projectSlug, AgentSlug agentSlug, TranscriptDate date) +/// +/// Reads transcript content for an agent on a specific date. +/// +/// The slug of the project. +/// The slug of the agent. +/// The transcript date. +/// The transcript content, or an empty string when unavailable. +public string ReadTranscript(ProjectSlug projectSlug, AgentSlug agentSlug, TranscriptDate date) { var path = paths.TranscriptPath(projectSlug, agentSlug, date); if (!path.Exists()) return string.Empty; @@ -315,9 +387,12 @@ public string ReadTranscript(ProjectSlug projectSlug, AgentSlug agentSlug, Trans } /// - /// Reads the AI-generated insight result for the given session date, - /// or returns null if no insights file exists or it cannot be parsed. + /// Reads the AI-generated insight result for a session date. /// + /// The slug of the project. + /// The slug of the agent. + /// The transcript date. + /// The insight result, or when unavailable. public InsightResult? ReadInsights(ProjectSlug projectSlug, AgentSlug agentSlug, TranscriptDate date) { var path = paths.InsightPath(projectSlug, agentSlug, date); @@ -329,4 +404,7 @@ public string ReadTranscript(ProjectSlug projectSlug, AgentSlug agentSlug, Trans } catch { return null; } } + + [LoggerMessage(Level = LogLevel.Warning, Message = "[agent] Failed to load agent.json for {Project}/{Agent}")] + private partial void LogFailedToLoadAgentJson(Exception ex, ProjectSlug project, AgentSlug agent); } diff --git a/ai-dev.core/Features/Agent/AgentStatusWriter.cs b/ai-dev.core/Features/Agent/AgentStatusWriter.cs index 3ba7db5..409f56a 100644 --- a/ai-dev.core/Features/Agent/AgentStatusWriter.cs +++ b/ai-dev.core/Features/Agent/AgentStatusWriter.cs @@ -1,7 +1,16 @@ namespace AiDev.Features.Agent; -public class AgentStatusWriter(ILogger logger) +/// +/// Writes partial status updates into an agent metadata file. +/// +public partial class AgentStatusWriter(ILogger logger) { + /// + /// Applies status field updates to an agent metadata file. + /// + /// The agent directory containing agent.json. + /// The status fields to merge into the existing metadata. + /// A task that completes when the update finishes. public async Task UpdateAsync(string agentDir, Dictionary updates) { var path = Path.Combine(agentDir, "agent.json"); @@ -27,6 +36,9 @@ public async Task UpdateAsync(string agentDir, Dictionary updat await File.WriteAllTextAsync(path, JsonSerializer.Serialize(merged, JsonDefaults.Write)); } - catch (Exception ex) { logger.LogWarning(ex, "[runner] Failed to update agent status in {AgentDir}", agentDir); } + catch (Exception ex) { LogStatusUpdateFailed(ex, agentDir); } } + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to update agent status in {AgentDir}")] + private partial void LogStatusUpdateFailed(Exception ex, string agentDir); } diff --git a/ai-dev.core/Features/Agent/AgentTemplate.cs b/ai-dev.core/Features/Agent/AgentTemplate.cs index e1dffce..6505156 100644 --- a/ai-dev.core/Features/Agent/AgentTemplate.cs +++ b/ai-dev.core/Features/Agent/AgentTemplate.cs @@ -2,16 +2,29 @@ namespace AiDev.Features.Agent; +/// +/// Represents an agent template and its editable metadata. +/// public class AgentTemplate { + /// Gets or sets the template slug. public AgentSlug Slug { get; set; } = null!; + /// Gets or sets the template display name. public string Name { get; set; } = ""; + /// Gets or sets the template role. public string Role { get; set; } = ""; + /// Gets or sets the default model identifier. public string Model { get; set; } = "claude-sonnet-4-6"; + /// Gets or sets the optional executor identifier. public string? Executor { get; set; } + /// Gets or sets the configured skills. public List Skills { get; set; } = []; + /// Gets or sets the template description. public string Description { get; set; } = ""; + /// Gets or sets the main template content. public string Content { get; set; } = ""; + /// Gets or sets the compact template content. public string CompactContent { get; set; } = ""; + /// Gets or sets the default thinking level. public ThinkingLevel ThinkingLevel { get; set; } } diff --git a/ai-dev.core/Features/Agent/AgentTemplatesService.cs b/ai-dev.core/Features/Agent/AgentTemplatesService.cs index be677d8..4075a19 100644 --- a/ai-dev.core/Features/Agent/AgentTemplatesService.cs +++ b/ai-dev.core/Features/Agent/AgentTemplatesService.cs @@ -1,19 +1,25 @@ namespace AiDev.Features.Agent; +/// +/// Loads, saves, creates, and deletes agent templates. +/// public class AgentTemplatesService(WorkspacePaths paths) { private readonly TemplateComposer _composer = new(); + /// + /// Lists the available agent templates. + /// + /// The available templates ordered by name. public List ListTemplates() { var dir = paths.AgentTemplatesDir; if (!Directory.Exists(dir)) return []; - return Directory.GetFiles(dir, "*.json") + return [.. Directory.GetFiles(dir, "*.json") .Select(LoadTemplateFile) .OfType() - .OrderBy(t => t.Name) - .ToList(); + .OrderBy(t => t.Name)]; } private IReadOnlyDictionary LoadPartials() @@ -52,6 +58,11 @@ private IReadOnlyDictionary LoadPartials() catch { return null; } } + /// + /// Gets an agent template by slug. + /// + /// The template slug. + /// The matching template, or when not found. public AgentTemplate? GetTemplate(string slug) { if (!AgentSlug.TryParse(slug, out _)) return null; @@ -59,6 +70,10 @@ private IReadOnlyDictionary LoadPartials() return jsonPath != null && File.Exists(jsonPath.Value) ? LoadTemplateFile(jsonPath.Value) : null; } + /// + /// Saves an agent template. + /// + /// The template to persist. public void SaveTemplate(AgentTemplate template) { var jsonPath = paths.SafeTemplatePath(template.Slug, ".json"); @@ -87,6 +102,11 @@ public void SaveTemplate(AgentTemplate template) File.WriteAllText(jsonPath.Value, JsonSerializer.Serialize(meta, JsonDefaults.Write)); } + /// + /// Creates and saves a new agent template. + /// + /// The template to create. + /// The created template. public AgentTemplate CreateTemplate(AgentTemplate template) { if (string.IsNullOrEmpty(template.Model)) @@ -96,6 +116,10 @@ public AgentTemplate CreateTemplate(AgentTemplate template) return template; } + /// + /// Deletes an agent template by slug. + /// + /// The template slug. public void DeleteTemplate(string slug) { var jsonPath = paths.SafeTemplatePath(slug, ".json"); diff --git a/ai-dev.core/Features/Agent/AgentTranscriptService.cs b/ai-dev.core/Features/Agent/AgentTranscriptService.cs index 8f9dea3..7322d5b 100644 --- a/ai-dev.core/Features/Agent/AgentTranscriptService.cs +++ b/ai-dev.core/Features/Agent/AgentTranscriptService.cs @@ -2,8 +2,17 @@ namespace AiDev.Features.Agent; +/// +/// Reads usage data persisted alongside agent transcripts. +/// public class AgentTranscriptService(WorkspacePaths paths) { + /// + /// Gets the most recent persisted session usage for an agent. + /// + /// The project that owns the agent. + /// The agent slug. + /// The most recent session usage, or when unavailable. public TokenUsage? GetLastSessionUsage(ProjectSlug projectSlug, AgentSlug agentSlug) { var transcriptDir = paths.AgentTranscriptsDir(projectSlug, agentSlug); @@ -23,6 +32,13 @@ public class AgentTranscriptService(WorkspacePaths paths) catch { return null; } } + /// + /// Gets persisted session usage for a specific transcript date. + /// + /// The project that owns the agent. + /// The agent slug. + /// The transcript date. + /// The session usage for the specified date, or when unavailable. public TokenUsage? GetSessionUsage(ProjectSlug projectSlug, AgentSlug agentSlug, TranscriptDate date) { var transcriptDir = paths.AgentTranscriptsDir(projectSlug, agentSlug); diff --git a/ai-dev.core/Features/Agent/IAgentRunnerService.cs b/ai-dev.core/Features/Agent/IAgentRunnerService.cs index 62ee5c4..472777f 100644 --- a/ai-dev.core/Features/Agent/IAgentRunnerService.cs +++ b/ai-dev.core/Features/Agent/IAgentRunnerService.cs @@ -2,12 +2,54 @@ namespace AiDev.Features.Agent; +/// +/// Defines operations for launching, stopping, and inspecting agent runner sessions. +/// public interface IAgentRunnerService { + /// + /// Determines whether the specified agent is currently running. + /// + /// The project that owns the agent. + /// The agent to inspect. + /// when the agent is running; otherwise, . bool IsRunning(ProjectSlug projectSlug, AgentSlug agentSlug); + + /// + /// Determines whether the specified agent is currently rate limited. + /// + /// The project that owns the agent. + /// The agent to inspect. + /// when the agent is rate limited; otherwise, . bool IsRateLimited(ProjectSlug projectSlug, AgentSlug agentSlug); + + /// + /// Gets the currently running agent sessions. + /// + /// The active running sessions. IReadOnlyList GetRunningSessions(); + + /// + /// Attempts to recover stale sessions for the specified projects. + /// + /// The projects whose sessions should be inspected. + /// A task that completes when stale session recovery finishes. Task RecoverStaleSessionsAsync(IEnumerable projects); + + /// + /// Launches the specified agent. + /// + /// The project that owns the agent. + /// The agent to launch. + /// The optional launch trigger metadata. + /// when the agent was launched; otherwise, . bool LaunchAgent(ProjectSlug projectSlug, AgentSlug agentSlug, AgentLaunchTrigger? trigger = null); + + /// + /// Stops the specified agent. + /// + /// The project that owns the agent. + /// The agent to stop. + /// when the agent was stopped; otherwise, . bool StopAgent(ProjectSlug projectSlug, AgentSlug agentSlug); } diff --git a/ai-dev.core/Features/Agent/ILocalAgentHook.cs b/ai-dev.core/Features/Agent/ILocalAgentHook.cs index 7ac7e74..dc4a485 100644 --- a/ai-dev.core/Features/Agent/ILocalAgentHook.cs +++ b/ai-dev.core/Features/Agent/ILocalAgentHook.cs @@ -2,16 +2,39 @@ namespace AiDev.Features.Agent; +/// +/// Defines a hook that can intercept or augment local agent execution. +/// public interface ILocalAgentHook { + /// + /// Determines whether the hook applies to the given executor. + /// + /// The executor name to evaluate. + /// when the hook applies; otherwise, . bool IsApplicable(string executorName); + /// + /// Runs the hook for the provided execution context. + /// + /// The local agent hook context. + /// The output channel for hook messages. + /// The cancellation token for the hook execution. + /// The hook result. Task RunAsync( LocalAgentHookContext context, ChannelWriter output, CancellationToken ct); } +/// +/// Provides context for running a local agent hook. +/// +/// The goal being executed. +/// The working directory for the session. +/// The model identifier selected for execution. +/// The executor name for the session. +/// The unique session identifier. public sealed record LocalAgentHookContext( string Goal, AgentDir WorkingDir, @@ -19,6 +42,11 @@ public sealed record LocalAgentHookContext( string ExecutorName, Guid SessionId); +/// +/// Represents the outcome of running a local agent hook. +/// +/// Indicates whether the hook completed successfully. +/// The error message when the hook fails. public sealed record LocalAgentHookResult( bool Succeeded, string? ErrorMessage = null); diff --git a/ai-dev.core/Features/Agent/ModelResolver.cs b/ai-dev.core/Features/Agent/ModelResolver.cs index 700e60f..7574ac8 100644 --- a/ai-dev.core/Features/Agent/ModelResolver.cs +++ b/ai-dev.core/Features/Agent/ModelResolver.cs @@ -1,7 +1,16 @@ namespace AiDev.Features.Agent; +/// +/// Resolves model aliases to concrete model identifiers. +/// public class ModelResolver(StudioSettingsService settings) { + /// + /// Resolves the provided model alias or identifier for the given executor. + /// + /// The configured model alias or raw model identifier. + /// The executor for which the model should be resolved. + /// The resolved concrete model identifier, or the original value when no mapping exists. public string Resolve(string modelOrAlias, AgentExecutorName executor) { if (string.IsNullOrWhiteSpace(modelOrAlias)) diff --git a/ai-dev.core/Features/Agent/SessionCompletionProcessor.cs b/ai-dev.core/Features/Agent/SessionCompletionProcessor.cs index c68da14..d91e35f 100644 --- a/ai-dev.core/Features/Agent/SessionCompletionProcessor.cs +++ b/ai-dev.core/Features/Agent/SessionCompletionProcessor.cs @@ -8,7 +8,7 @@ namespace AiDev.Features.Agent; /// Handles all post-session cleanup: usage file accumulation, result.json processing, /// inbox archival, and conditional relaunch. /// -public class SessionCompletionProcessor( +public partial class SessionCompletionProcessor( WorkspacePaths paths, BoardService boardService, InsightsService insightsService, @@ -56,18 +56,16 @@ public async Task ProcessAsync( } var usageJson = System.Text.Json.JsonSerializer.Serialize(sessionUsage, JsonDefaults.Write); await File.WriteAllTextAsync(usagePath, usageJson).ConfigureAwait(false); - logger.LogInformation( - "[runner] Usage — {In} in / {Out} out tokens (daily total)", - sessionUsage.InputTokens, sessionUsage.OutputTokens); + LogUsageDailyTotal(sessionUsage.InputTokens, sessionUsage.OutputTokens); } finally { usageLock.Release(); } } else { - logger.LogWarning("[runner] Usage-lock timed out for {Key} — usage file not updated", key); + LogUsageLockTimeout(key); } } - catch (Exception ex) { logger.LogWarning(ex, "[runner] Failed to write usage file"); } + catch (Exception ex) { LogUsageWriteFailed(ex); } } // Process result.json — read SessionResult from outbox, persist alongside transcript, @@ -84,25 +82,24 @@ public async Task ProcessAsync( { var persistedResultPath = Path.Combine(transcriptDir, $"{transcriptDate.Value}.result.json"); await File.WriteAllTextAsync(persistedResultPath, resultJson).ConfigureAwait(false); - logger.LogInformation("[runner] Persisted result.json for {Key} → {Path}", key, persistedResultPath); + LogResultPersisted(key, persistedResultPath); try { File.Delete(resultPath); } - catch (Exception delEx) { logger.LogWarning(delEx, "[runner] Failed to delete result.json from outbox for {Key}", key); } + catch (Exception delEx) { LogResultDeleteFailed(delEx, key); } // Prefer result.taskId; fall back to trigger task id if it was passed in the result. if (!string.IsNullOrWhiteSpace(sessionResult.TaskId) && TaskId.TryParse(sessionResult.TaskId, out var resultTaskId)) { boardService.CompleteTaskFromResult(projectSlug, resultTaskId, sessionResult); - logger.LogInformation("[runner] Auto-completed board task {TaskId} from result.json for {Key}", - resultTaskId.Value, key); + LogTaskAutoCompleted(resultTaskId.Value, key); } } } } catch (Exception ex) { - logger.LogWarning(ex, "[runner] Failed to process result.json for {Key}", key); + LogResultProcessFailed(ex, key); } // Generate AI insights (fire-and-forget so insights finish even after session CT is cancelled). @@ -111,8 +108,7 @@ public async Task ProcessAsync( .ContinueWith(t => { if (t.Exception != null) - logger.LogWarning(t.Exception, "[runner] Insights generation faulted for {Project}/{Agent}", - projectSlug.Value, agentSlug.Value); + LogInsightsFaulted(t.Exception, projectSlug.Value, agentSlug.Value); }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); // Inbox archival / relaunch. @@ -122,8 +118,7 @@ public async Task ProcessAsync( } else if (preserveInbox) { - logger.LogWarning( - "[runner] Agent {Key} ended with a recoverable configuration error — inbox preserved for retry", key); + LogInboxPreservedForRetry(key); if (inboxSnapshot.Length > 0) projectStateChangedNotifier.Notify(projectSlug, ProjectStateChangeKind.Messages); } else @@ -140,9 +135,7 @@ public async Task ProcessAsync( if (pendingAfterSession > 0) { - logger.LogInformation( - "[runner] {Count} message(s) arrived during session for {Key} — re-launching", - pendingAfterSession, key); + LogRelaunchingForPendingMessages(pendingAfterSession, key); relaunch(projectSlug, agentSlug, null); } } @@ -161,11 +154,47 @@ private void ArchiveInbox(string inboxDir, string[] snapshot) var dst = Path.Combine(processedDir, filename); if (File.Exists(src)) File.Move(src, dst, overwrite: true); } - logger.LogInformation("[runner] Archived {Count} inbox file(s)", snapshot.Length); + LogInboxArchived(snapshot.Length); } catch (Exception ex) { - logger.LogWarning(ex, "[runner] Failed to archive inbox"); + LogInboxArchiveFailed(ex); } } + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Usage — {In} in / {Out} out tokens (daily total)")] + private partial void LogUsageDailyTotal(long @in, long @out); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Usage-lock timed out for {Key} — usage file not updated")] + private partial void LogUsageLockTimeout(string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to write usage file")] + private partial void LogUsageWriteFailed(Exception ex); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Persisted result.json for {Key} → {Path}")] + private partial void LogResultPersisted(string key, string path); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to delete result.json from outbox for {Key}")] + private partial void LogResultDeleteFailed(Exception ex, string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Auto-completed board task {TaskId} from result.json for {Key}")] + private partial void LogTaskAutoCompleted(string taskId, string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to process result.json for {Key}")] + private partial void LogResultProcessFailed(Exception ex, string key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Insights generation faulted for {Project}/{Agent}")] + private partial void LogInsightsFaulted(Exception ex, string project, string agent); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Agent {Key} ended with a recoverable configuration error — inbox preserved for retry")] + private partial void LogInboxPreservedForRetry(string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] {Count} message(s) arrived during session for {Key} — re-launching")] + private partial void LogRelaunchingForPendingMessages(int count, string key); + + [LoggerMessage(Level = LogLevel.Information, Message = "[runner] Archived {Count} inbox file(s)")] + private partial void LogInboxArchived(int count); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[runner] Failed to archive inbox")] + private partial void LogInboxArchiveFailed(Exception ex); } diff --git a/ai-dev.core/Features/Agent/TaskAssignedHandler.cs b/ai-dev.core/Features/Agent/TaskAssignedHandler.cs index 78d27d2..8415d26 100644 --- a/ai-dev.core/Features/Agent/TaskAssignedHandler.cs +++ b/ai-dev.core/Features/Agent/TaskAssignedHandler.cs @@ -1,6 +1,6 @@ namespace AiDev.Features.Agent; -internal sealed class TaskAssignedHandler( +internal sealed partial class TaskAssignedHandler( AgentInboxService inbox, IAgentRunnerService runner, ILogger logger) : IDomainEventHandler @@ -24,13 +24,11 @@ public Task Handle(TaskAssigned domainEvent, CancellationToken ct = default) // Log but do not throw — the agent is still launched below so it can discover // the task via the board. Throwing here would surface to the dispatcher and // swallow the event silently, leaving the task orphaned until OverwatchService. - logger.LogError("[board] Failed to write inbox message for {Assignee} task {TaskId}: {Error}", - domainEvent.Assignee, domainEvent.TaskId, err.Error.Message); + LogInboxWriteFailed(domainEvent.Assignee, domainEvent.TaskId, err.Error.Message); } else { - logger.LogInformation("[board] Dispatched TaskAssigned to {Assignee} for task {TaskId} ({Title})", - domainEvent.Assignee, domainEvent.TaskId, domainEvent.Title); + LogTaskAssignedDispatched(domainEvent.Assignee, domainEvent.TaskId, domainEvent.Title); } // Always launch — the DispatcherService FSW will also fire if the inbox write @@ -47,4 +45,10 @@ public Task Handle(TaskAssigned domainEvent, CancellationToken ct = default) return Task.CompletedTask; } + + [LoggerMessage(Level = LogLevel.Error, Message = "[board] Failed to write inbox message for {Assignee} task {TaskId}: {Error}")] + private partial void LogInboxWriteFailed(AgentSlug assignee, TaskId taskId, string error); + + [LoggerMessage(Level = LogLevel.Information, Message = "[board] Dispatched TaskAssigned to {Assignee} for task {TaskId} ({Title})")] + private partial void LogTaskAssignedDispatched(AgentSlug assignee, TaskId taskId, string title); } diff --git a/ai-dev.core/Features/Agent/TemplateComposer.cs b/ai-dev.core/Features/Agent/TemplateComposer.cs index e56f98a..a3b5e78 100644 --- a/ai-dev.core/Features/Agent/TemplateComposer.cs +++ b/ai-dev.core/Features/Agent/TemplateComposer.cs @@ -1,7 +1,17 @@ namespace AiDev.Features.Agent; +/// +/// Composes agent templates by expanding named partial includes. +/// public partial class TemplateComposer { + /// + /// Replaces all partial include markers in the template with the provided partial content. + /// + /// The template text to compose. + /// The partial content keyed by partial name. + /// The composed template text. + /// Thrown when the template references an unknown partial. public string Compose(string template, IReadOnlyDictionary partials) { foreach (var (key, content) in partials) diff --git a/ai-dev.core/Features/Board/Board.cs b/ai-dev.core/Features/Board/Board.cs index 3675506..565de7c 100644 --- a/ai-dev.core/Features/Board/Board.cs +++ b/ai-dev.core/Features/Board/Board.cs @@ -1,5 +1,8 @@ namespace AiDev.Features.Board; +/// +/// Represents a project board containing columns, tasks, and pending domain events. +/// public sealed class Board { private static readonly DomainError UnknownColumnError = new("BOARD_UNKNOWN_COLUMN", "Column not found."); @@ -11,8 +14,13 @@ public sealed class Board private readonly List _columns; private readonly Dictionary _tasks; [JsonIgnore] private readonly List _domainEvents = []; - [JsonIgnore] public ProjectSlug ProjectSlug { get; } + /// + /// Initializes a board for the specified project. + /// + /// The project slug that owns the board. + /// The existing board columns, or to create defaults. + /// The existing board tasks, or to start empty. public Board(ProjectSlug projectSlug, List? columns = null, Dictionary? tasks = null) { ArgumentNullException.ThrowIfNull(projectSlug); @@ -21,7 +29,19 @@ public Board(ProjectSlug projectSlug, List? columns = null, Diction _tasks = tasks ?? new(); } + /// + /// Gets the project slug that owns the board. + /// + [JsonIgnore] public ProjectSlug ProjectSlug { get; } + + /// + /// Gets the board columns in display order. + /// public IReadOnlyList Columns => _columns.AsReadOnly(); + + /// + /// Gets the tasks keyed by task identifier. + /// public IReadOnlyDictionary Tasks => new System.Collections.ObjectModel.ReadOnlyDictionary(_tasks); /// diff --git a/ai-dev.core/Features/Board/BoardColumn.cs b/ai-dev.core/Features/Board/BoardColumn.cs index 73bbef6..0ff2fbf 100644 --- a/ai-dev.core/Features/Board/BoardColumn.cs +++ b/ai-dev.core/Features/Board/BoardColumn.cs @@ -1,5 +1,8 @@ namespace AiDev.Features.Board; +/// +/// Represents a column on a board and the tasks assigned to it. +/// public sealed class BoardColumn { private readonly List _taskIds; @@ -18,8 +21,19 @@ public BoardColumn(ColumnId id, string title, List? taskIds = null) _taskIds = taskIds ?? []; } + /// + /// Gets the unique identifier for the column. + /// public ColumnId Id { get; } + + /// + /// Gets the display title of the column. + /// public string Title { get; } + + /// + /// Gets the task identifiers currently assigned to the column. + /// public IReadOnlyList TaskIds => _taskIds.AsReadOnly(); /// diff --git a/ai-dev.core/Features/Board/BoardService.cs b/ai-dev.core/Features/Board/BoardService.cs index 910f386..785e63a 100644 --- a/ai-dev.core/Features/Board/BoardService.cs +++ b/ai-dev.core/Features/Board/BoardService.cs @@ -27,7 +27,10 @@ internal sealed class BoardTaskState public DateTime? NudgedAt { get; init; } } - public class BoardService( + /// + /// Provides persistence and mutation operations for project boards. + /// + public partial class BoardService( WorkspacePaths paths, IDomainEventDispatcher dispatcher, AtomicFileWriter fileWriter, @@ -38,6 +41,11 @@ public class BoardService( private static readonly DomainError InvalidColumnError = new("BOARD_INVALID_COLUMN", "Column id is invalid."); private static readonly TimeSpan DispatchTimeout = TimeSpan.FromSeconds(10); + /// + /// Loads the board for a project. + /// + /// The project whose board should be loaded. + /// The loaded board, or a default empty board when none exists. public Board LoadBoard(ProjectSlug projectSlug) { var path = paths.BoardPath(projectSlug); @@ -54,8 +62,7 @@ public Board LoadBoard(ProjectSlug projectSlug) { var backupPath = path + $".corrupt.{DateTime.UtcNow:yyyyMMddHHmmss}"; try { File.Move(path, backupPath); } catch { /* best-effort */ } - logger.LogError(ex, "[board] Corrupt board.json for {ProjectSlug}; backed up to {Backup} and reset to default", - projectSlug, backupPath); + LogCorruptBoardJson(ex, projectSlug, backupPath); return new Board(projectSlug); } } @@ -65,12 +72,12 @@ public void SaveBoard(ProjectSlug projectSlug, Board board) var path = paths.BoardPath(projectSlug); var state = new BoardState { - Columns = board.Columns.Select(column => new BoardColumnState + Columns = [.. board.Columns.Select(column => new BoardColumnState { Id = column.Id.Value, Title = column.Title, TaskIds = [.. column.TaskIds.Select(taskId => taskId.Value)], - }).ToList(), + })], Tasks = board.Tasks.ToDictionary( kv => kv.Key.Value, kv => new BoardTaskState @@ -283,7 +290,7 @@ private Result CreateBoardTask(string title, string priority, string? } catch (Exception ex) { - logger.LogWarning(ex, "[board] Failed to read allowed-tags.json for {ProjectSlug}", projectSlug.Value); + LogFailedToReadAllowedTags(ex, projectSlug.Value); return null; } } @@ -319,8 +326,7 @@ public void CompleteTaskFromResult(ProjectSlug projectSlug, TaskId taskId, Sessi var board = LoadBoard(projectSlug); if (!board.Tasks.TryGetValue(taskId, out var task)) { - logger.LogWarning("[board] CompleteTaskFromResult: task {TaskId} not found in {ProjectSlug} — result.json may reference a stale or mistyped task ID", - taskId.Value, projectSlug.Value); + LogCompleteTaskNotFound(taskId.Value, projectSlug.Value); return Unit.Value; } @@ -358,5 +364,14 @@ private async Task> DispatchBoardEventsAsync(IReadOnlyList +/// Represents a task tracked on a project board. +/// public sealed class BoardTask { /// @@ -36,13 +39,44 @@ public BoardTask( private List _tags; + /// + /// Gets the unique identifier of the task. + /// public TaskId Id { get; } + + /// + /// Gets the current task title. + /// public string Title { get; private set; } + + /// + /// Gets the current task priority. + /// public Priority Priority { get; private set; } + + /// + /// Gets the optional task description. + /// public string? Description { get; private set; } + + /// + /// Gets the optional assigned agent slug. + /// public string? Assignee { get; private set; } + + /// + /// Gets the normalized task tags. + /// public IReadOnlyList Tags => _tags; + + /// + /// Gets the timestamp when the task was created. + /// public DateTime? CreatedAt { get; private set; } + + /// + /// Gets the timestamp when the task was completed. + /// public DateTime? CompletedAt { get; private set; } /// Timestamp when the task last moved to its current column. Used by overwatch for stall detection. public DateTime? MovedAt { get; private set; } @@ -103,9 +137,8 @@ private static Priority NormalizePriority(Priority? priority) private static List NormalizeTags(List? tags) { if (tags == null || tags.Count == 0) return []; - return tags.Select(t => t?.Trim() ?? string.Empty) + return [.. tags.Select(t => t?.Trim() ?? string.Empty) .Where(t => t.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + .Distinct(StringComparer.OrdinalIgnoreCase)]; } } diff --git a/ai-dev.core/Features/Decision/DecisionChatService.cs b/ai-dev.core/Features/Decision/DecisionChatService.cs index 611592c..fdacbef 100644 --- a/ai-dev.core/Features/Decision/DecisionChatService.cs +++ b/ai-dev.core/Features/Decision/DecisionChatService.cs @@ -7,7 +7,7 @@ namespace AiDev.Features.Decision; /// Chat history is persisted as append-only JSONL at decisions/chats/{decisionId}.jsonl. /// Human messages are routed into the agent's inbox; agent replies arrive via outbox. /// -public class DecisionChatService( +public partial class DecisionChatService( WorkspacePaths paths, IAgentRunnerService runner, AgentInboxService inbox, @@ -30,11 +30,11 @@ public IReadOnlyList GetMessages(ProjectSlug projectSlug, D } catch (IOException ex) { - logger.LogDebug(ex, "[decision-chat] Failed to read chat for {DecisionId}", decisionId); + LogReadChatFailed(ex, decisionId); } catch (UnauthorizedAccessException ex) { - logger.LogWarning(ex, "[decision-chat] Failed to read chat for {DecisionId}", decisionId); + LogReadChatUnauthorized(ex, decisionId); } return []; @@ -77,7 +77,7 @@ public IReadOnlyList GetMessages(ProjectSlug projectSlug, D if (inboxResult is Err inboxErr) { - logger.LogWarning("[decision-chat] Inbox write failed for {DecisionId}: {Error}", decisionId, inboxErr.Error.Message); + LogInboxWriteFailed(decisionId, inboxErr.Error.Message); return $"Failed to deliver message to agent: {inboxErr.Error.Message}"; } @@ -139,7 +139,7 @@ public bool FlushAgentReplies(ProjectSlug projectSlug, DecisionId decisionId, Ag var appendError = AppendMessage(projectSlug, decisionId, msg); if (appendError != null) { - logger.LogWarning("[decision-chat] Failed to append agent reply: {Error}", appendError); + LogAppendAgentReplyFailed(appendError); // Move back to outbox so it can be retried. try { File.Move(claimedPath, file); } catch { /* best-effort */ } claimedPath = null; @@ -155,7 +155,7 @@ public bool FlushAgentReplies(ProjectSlug projectSlug, DecisionId decisionId, Ag } catch (Exception ex) { - logger.LogWarning(ex, "[decision-chat] Error processing outbox file {File}", file); + LogOutboxFileProcessingError(ex, file); // Return claimed file to outbox on unexpected error. if (claimedPath != null && File.Exists(claimedPath)) try { File.Move(claimedPath, file); } catch { /* best-effort */ } @@ -183,7 +183,7 @@ public bool FlushAgentReplies(ProjectSlug projectSlug, DecisionId decisionId, Ag } catch (Exception ex) { - logger.LogError(ex, "[decision-chat] Failed to append message to {DecisionId}", decisionId); + LogAppendMessageFailed(ex, decisionId); return ex.Message; } } @@ -216,12 +216,32 @@ private IReadOnlyList ParseMessages(string json, DecisionId } catch (JsonException ex) { - logger.LogWarning(ex, "[decision-chat] Failed to parse chat history for {DecisionId}; returning {MessageCount} message(s)", - decisionId, messages.Count); + LogParseChatHistoryFailed(ex, decisionId, messages.Count); break; } } return messages; } + + [LoggerMessage(Level = LogLevel.Debug, Message = "[decision-chat] Failed to read chat for {DecisionId}")] + private partial void LogReadChatFailed(Exception ex, DecisionId decisionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[decision-chat] Failed to read chat for {DecisionId}")] + private partial void LogReadChatUnauthorized(Exception ex, DecisionId decisionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[decision-chat] Inbox write failed for {DecisionId}: {Error}")] + private partial void LogInboxWriteFailed(DecisionId decisionId, string error); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[decision-chat] Failed to append agent reply: {Error}")] + private partial void LogAppendAgentReplyFailed(string error); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[decision-chat] Error processing outbox file {File}")] + private partial void LogOutboxFileProcessingError(Exception ex, string file); + + [LoggerMessage(Level = LogLevel.Error, Message = "[decision-chat] Failed to append message to {DecisionId}")] + private partial void LogAppendMessageFailed(Exception ex, DecisionId decisionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[decision-chat] Failed to parse chat history for {DecisionId}; returning {MessageCount} message(s)")] + private partial void LogParseChatHistoryFailed(Exception ex, DecisionId decisionId, int messageCount); } diff --git a/ai-dev.core/Features/Decision/DecisionItem.cs b/ai-dev.core/Features/Decision/DecisionItem.cs index 84a5686..318cb7c 100644 --- a/ai-dev.core/Features/Decision/DecisionItem.cs +++ b/ai-dev.core/Features/Decision/DecisionItem.cs @@ -1,5 +1,8 @@ namespace AiDev.Features.Decision; +/// +/// Represents a decision request and its eventual resolution state. +/// public sealed class DecisionItem { [JsonIgnore] private readonly List _domainEvents = []; @@ -45,17 +48,64 @@ public DecisionItem( Response = NormalizeOptional(response); } + /// + /// Gets the backing filename for the decision. + /// public string Filename { get; } + + /// + /// Gets the unique decision identifier. + /// public DecisionId Id { get; } + + /// + /// Gets the decision source. + /// public string From { get; } + + /// + /// Gets the decision creation timestamp. + /// public DateTime? Date { get; } + + /// + /// Gets the decision priority. + /// public Priority Priority { get; private set; } + + /// + /// Gets the decision subject. + /// public string Subject { get; private set; } + + /// + /// Gets the current decision status. + /// public DecisionStatus Status { get; private set; } + + /// + /// Gets the optional blocker reference. + /// public string? Blocks { get; private set; } + + /// + /// Gets the timestamp when the decision was resolved. + /// public DateTime? ResolvedAt { get; private set; } + + /// + /// Gets the actor who resolved the decision. + /// public string? ResolvedBy { get; private set; } + + /// + /// Gets the decision body content. + /// public string Body { get; private set; } + + /// + /// Gets the resolution response content. + /// public string? Response { get; private set; } /// diff --git a/ai-dev.core/Features/Decision/DecisionsService.cs b/ai-dev.core/Features/Decision/DecisionsService.cs index d75e125..83a7c73 100644 --- a/ai-dev.core/Features/Decision/DecisionsService.cs +++ b/ai-dev.core/Features/Decision/DecisionsService.cs @@ -1,5 +1,8 @@ namespace AiDev.Features.Decision; +/// +/// Provides creation, lookup, and resolution operations for project decisions. +/// public class DecisionsService( WorkspacePaths paths, IDomainEventDispatcher dispatcher, @@ -10,6 +13,16 @@ public class DecisionsService( private const string ResponseSeparator = "\n\n---\n\n## Human Response\n\n"; private static readonly TimeSpan DispatchTimeout = TimeSpan.FromSeconds(10); + /// + /// Creates a new decision request. + /// + /// The project that owns the decision. + /// The decision source. + /// The decision subject. + /// The decision priority. + /// The optional blocker reference. + /// The decision body content. + /// The result of the create operation. public Result CreateDecision(ProjectSlug projectSlug, string from, string subject, Priority priority, string? blocks, string body) { @@ -51,6 +64,12 @@ public Result CreateDecision(ProjectSlug projectSlug, string from, string }); } + /// + /// Lists decisions for a project filtered by status. + /// + /// The project that owns the decisions. + /// The optional status filter. + /// The matching decisions. public List ListDecisions(ProjectSlug projectSlug, DecisionStatus? status = null) { var effectiveStatus = status ?? DecisionStatus.Pending; @@ -71,6 +90,12 @@ public List ListDecisions(ProjectSlug projectSlug, DecisionStatus? return results; } + /// + /// Gets a decision by identifier. + /// + /// The project that owns the decision. + /// The decision identifier. + /// The decision item, or when not found. public DecisionItem? GetDecision(ProjectSlug projectSlug, DecisionId id) { var filename = $"{id.Value}.md"; @@ -81,9 +106,24 @@ public List ListDecisions(ProjectSlug projectSlug, DecisionStatus? return null; } + /// + /// Resolves a decision with a human response. + /// + /// The project that owns the decision. + /// The decision identifier. + /// The response used to resolve the decision. + /// The result of the resolve operation. public Task> ResolveDecisionAsync(ProjectSlug projectSlug, DecisionId id, string response) => ResolveDecisionAsync(projectSlug, id, response, CancellationToken.None); + /// + /// Resolves a decision with a human response. + /// + /// The project that owns the decision. + /// The decision identifier. + /// The response used to resolve the decision. + /// The cancellation token for the operation. + /// The result of the resolve operation. public Task> ResolveDecisionAsync(ProjectSlug projectSlug, DecisionId id, string response, CancellationToken cancellationToken) => coordinator.ExecuteAsync(projectSlug, async () => { diff --git a/ai-dev.core/Features/Digest/DigestData.cs b/ai-dev.core/Features/Digest/DigestData.cs index 89e5332..c5fc309 100644 --- a/ai-dev.core/Features/Digest/DigestData.cs +++ b/ai-dev.core/Features/Digest/DigestData.cs @@ -2,11 +2,33 @@ namespace AiDev.Features.Digest; +/// +/// Represents the daily digest data for a project. +/// public class DigestData { - public required string Date { get; set; } + /// + /// Gets or sets the date covered by the digest. + /// + public required DateOnly Date { get; set; } + + /// + /// Gets or sets the total number of messages received for the date. + /// public int TotalMessages { get; set; } + + /// + /// Gets or sets the number of pending decisions. + /// public int PendingDecisions { get; set; } + + /// + /// Gets or sets the number of resolved decisions. + /// public int ResolvedDecisions { get; set; } + + /// + /// Gets or sets the per-agent activity included in the digest. + /// public List AgentActivity { get; set; } = []; } diff --git a/ai-dev.core/Features/Digest/DigestService.cs b/ai-dev.core/Features/Digest/DigestService.cs index 7aa48fa..444bb8b 100644 --- a/ai-dev.core/Features/Digest/DigestService.cs +++ b/ai-dev.core/Features/Digest/DigestService.cs @@ -2,9 +2,18 @@ namespace AiDev.Features.Digest; +/// +/// Builds daily digest summaries for a project. +/// public class DigestService(WorkspacePaths paths, AgentService agentService) { - public DigestData GetDigest(ProjectSlug projectSlug, string date) + /// + /// Gets the digest data for the specified project and date. + /// + /// The project slug to summarize. + /// The date to summarize. + /// The digest data for the requested project and date. + public DigestData GetDigest(ProjectSlug projectSlug, DateOnly date) { var agentsDir = paths.AgentsDir(projectSlug); var pendingDir = paths.DecisionsPendingDir(projectSlug); @@ -47,15 +56,14 @@ public DigestData GetDigest(ProjectSlug projectSlug, string date) TotalMessages = totalMessages, PendingDecisions = pendingCount, ResolvedDecisions = resolvedCount, - AgentActivity = agentActivity.OrderBy(a => a.AgentName).ToList(), + AgentActivity = [.. agentActivity.OrderBy(a => a.AgentName)], }; } - private static int CountFilesForDate(DirPath dir, string date) + private static int CountFilesForDate(DirPath dir, DateOnly date) { if (!dir.Exists()) return 0; - // Files named YYYYMMDD-HHMMSS-*.md — date prefix is first 8 chars of the compact date - var prefix = date.Replace("-", ""); // "2026-03-27" → "20260327" + var prefix = date.ToString("yyyyMMdd"); return Directory.GetFiles(dir.Value, "*.md") .Count(f => Path.GetFileName(f).StartsWith(prefix)); } diff --git a/ai-dev.core/Features/Git/GitCommit.cs b/ai-dev.core/Features/Git/GitCommit.cs index 8548e94..d91111c 100644 --- a/ai-dev.core/Features/Git/GitCommit.cs +++ b/ai-dev.core/Features/Git/GitCommit.cs @@ -1,11 +1,37 @@ namespace AiDev.Features.Git; +/// +/// Represents summary information for a Git commit. +/// public class GitCommit { + /// + /// Gets or sets the full commit hash. + /// public required string Hash { get; set; } + + /// + /// Gets or sets the abbreviated commit hash. + /// public required string ShortHash { get; set; } + + /// + /// Gets or sets the commit subject line. + /// public required string Subject { get; set; } + + /// + /// Gets or sets the author name. + /// public required string Author { get; set; } + + /// + /// Gets or sets the author email address. + /// public required string AuthorEmail { get; set; } + + /// + /// Gets or sets the commit date in Git output format. + /// public required string Date { get; set; } } diff --git a/ai-dev.core/Features/Git/GitCommitDetail.cs b/ai-dev.core/Features/Git/GitCommitDetail.cs index 6c20d61..31ea5bb 100644 --- a/ai-dev.core/Features/Git/GitCommitDetail.cs +++ b/ai-dev.core/Features/Git/GitCommitDetail.cs @@ -1,8 +1,22 @@ namespace AiDev.Features.Git; +/// +/// Represents detailed information for a Git commit. +/// public class GitCommitDetail { + /// + /// Gets or sets the commit summary. + /// public required GitCommit Commit { get; set; } + + /// + /// Gets or sets the commit body message. + /// public required string Body { get; set; } + + /// + /// Gets or sets the diff output for the commit. + /// public required string Diff { get; set; } } diff --git a/ai-dev.core/Features/Git/GitService.cs b/ai-dev.core/Features/Git/GitService.cs index 7b0e256..df1dc65 100644 --- a/ai-dev.core/Features/Git/GitService.cs +++ b/ai-dev.core/Features/Git/GitService.cs @@ -1,11 +1,19 @@ namespace AiDev.Features.Git; +/// +/// Provides read-only Git operations for repository inspection. +/// public partial class GitService(ILogger? logger = null) { // Only allow hex commit hashes (4–64 chars). Rejects any flag injection. private static readonly Regex ValidHashRegex = MyGitHashRegex(); + /// + /// Determines whether the specified path is inside a Git working tree. + /// + /// The repository path to inspect. + /// when the path is a Git repository; otherwise, . public bool IsGitRepo(string repoPath) { if (!Directory.Exists(repoPath)) return false; @@ -13,6 +21,12 @@ public bool IsGitRepo(string repoPath) return result.ExitCode == 0 && result.Output.Trim() == "true"; } + /// + /// Gets recent commits from the repository log. + /// + /// The repository path to inspect. + /// The maximum number of commits to return. + /// The recent commit summaries. public List GetLog(string repoPath, int count = 50) { var sep = "\x1f"; @@ -37,6 +51,12 @@ public List GetLog(string repoPath, int count = 50) return commits; } + /// + /// Gets detailed information for a specific commit hash. + /// + /// The repository path to inspect. + /// The commit hash to retrieve. + /// The commit details, or when the commit cannot be read. public GitCommitDetail? GetCommit(string repoPath, string hash) { if (!ValidHashRegex.IsMatch(hash)) return null; @@ -90,11 +110,14 @@ public List GetLog(string repoPath, int count = 50) } catch (Exception ex) { - logger?.LogWarning(ex, "[git] Failed to run git {Args} in {Dir}", string.Join(' ', args), workingDir); + if (logger is not null) LogGitCommandFailed(ex, args, workingDir); return (-1, string.Empty); } } + [LoggerMessage(Level = LogLevel.Warning, Message = "[git] Failed to run git {Args} in {Dir}")] + private partial void LogGitCommandFailed(Exception ex, string[] args, string dir); + [GeneratedRegex(@"^[0-9a-f]{4,64}$", RegexOptions.Compiled)] private static partial Regex MyGitHashRegex(); diff --git a/ai-dev.core/Features/Insights/InsightsService.cs b/ai-dev.core/Features/Insights/InsightsService.cs index dea12cf..b2df876 100644 --- a/ai-dev.core/Features/Insights/InsightsService.cs +++ b/ai-dev.core/Features/Insights/InsightsService.cs @@ -16,7 +16,7 @@ namespace AiDev.Features.Insights; /// Insights are written alongside the transcript as {date}.insights.json. /// Generation is opt-in: set InsightsExecutor to enable. /// -public class InsightsService( +public partial class InsightsService( IEnumerable executors, StudioSettingsService settingsService, ILogger logger) @@ -60,16 +60,14 @@ Keep each issue description under 120 characters. if (!AgentExecutorName.TryParse(studioSettings.InsightsExecutor, out var insightsExecutor) || !_executors.TryGetValue(insightsExecutor, out var executor)) { - logger.LogWarning("[insights] Executor '{Executor}' not registered — skipping insights generation", - studioSettings.InsightsExecutor); + LogExecutorNotRegistered(studioSettings.InsightsExecutor); return null; } var modelId = studioSettings.InsightsModel ?? executor.KnownModels.FirstOrDefault()?.Id; if (string.IsNullOrWhiteSpace(modelId)) { - logger.LogWarning("[insights] No model configured and no known models for executor '{Executor}'", - studioSettings.InsightsExecutor); + LogNoModelConfigured(studioSettings.InsightsExecutor); return null; } @@ -80,14 +78,29 @@ Keep each issue description under 120 characters. } catch (Exception ex) { - logger.LogWarning(ex, "[insights] Could not read transcript at {Path}", transcriptPath); + LogCouldNotReadTranscript(ex, transcriptPath); return null; } if (string.IsNullOrWhiteSpace(transcriptContent)) return null; - logger.LogInformation("[insights] Generating insights using {Executor}/{Model}", executor.Name, modelId); + // Insights uses a small system prompt (~50 tokens) and needs output room (~512 tokens). + // The transcript is the only variable-size input. Cap it so the total fits a 4096-token + // model — the smallest common local model context. Keeps the tail (most recent output). + const int MaxTranscriptChars = 12_000; // ~3000 tokens, leaves headroom in a 4096 ctx + var estimatedTokens = (transcriptContent.Length + 3) / 4; + if (transcriptContent.Length > MaxTranscriptChars) + { + LogTranscriptTruncated(estimatedTokens, transcriptContent.Length, MaxTranscriptChars); + transcriptContent = transcriptContent[^MaxTranscriptChars..]; + } + else + { + LogTranscriptSize(estimatedTokens, transcriptContent.Length); + } + + LogGeneratingInsights(executor.Name, modelId); // Create an isolated working directory so we can control the system prompt (CLAUDE.md) // without affecting any real agent. The directory structure mimics a workspace tree so @@ -136,7 +149,7 @@ Keep each issue description under 120 characters. var json = ExtractJson(outputLines); if (string.IsNullOrWhiteSpace(json)) { - logger.LogWarning("[insights] No JSON found in executor output"); + LogNoJsonInOutput(); return null; } @@ -144,23 +157,23 @@ Keep each issue description under 120 characters. if (result == null) return null; await File.WriteAllTextAsync(insightPath, JsonSerializer.Serialize(result, JsonDefaults.Write), ct); - logger.LogInformation("[insights] Insights written to {Path}", insightPath); + LogInsightsWritten(insightPath); return result; } catch (OperationCanceledException) { - logger.LogInformation("[insights] Insights generation was cancelled"); + LogInsightsCancelled(); return null; } catch (Exception ex) { - logger.LogWarning(ex, "[insights] Failed to generate or save insights for {Path}", transcriptPath); + LogFailedToGenerateInsights(ex, transcriptPath); return null; } finally { try { Directory.Delete(tempRoot, recursive: true); } - catch (Exception ex) { logger.LogDebug(ex, "[insights] Could not clean up temp dir {Dir}", tempRoot); } + catch (Exception ex) { LogCouldNotCleanUpTempDir(ex, tempRoot); } } } @@ -218,10 +231,10 @@ private static string ExtractJson(IEnumerable lines) var root = doc.RootElement; var classification = root.TryGetProperty("taskClassification", out var tc) - ? tc.GetString() ?? "other" : "other"; + ? TaskClassification.From(tc.GetString()) : TaskClassification.Other; var sizeRating = root.TryGetProperty("sessionSizeRating", out var sr) - ? sr.GetString() ?? "medium" : "medium"; + ? SessionSizeRating.From(sr.GetString()) : SessionSizeRating.Medium; List issues = []; if (root.TryGetProperty("issues", out var issuesEl) && issuesEl.ValueKind == JsonValueKind.Array) @@ -237,7 +250,7 @@ private static string ExtractJson(IEnumerable lines) List gaps = []; if (root.TryGetProperty("knowledgeGaps", out var gapsEl) && gapsEl.ValueKind == JsonValueKind.Array) - gaps = gapsEl.EnumerateArray().Select(g => g.GetString() ?? string.Empty).Where(g => g.Length > 0).ToList(); + gaps = [.. gapsEl.EnumerateArray().Select(g => g.GetString() ?? string.Empty).Where(g => g.Length > 0)]; var suggestion = root.TryGetProperty("improvedPromptSuggestion", out var ips) ? ips.GetString() ?? string.Empty : string.Empty; @@ -246,8 +259,44 @@ private static string ExtractJson(IEnumerable lines) } catch (Exception ex) { - logger.LogWarning(ex, "[insights] Failed to parse executor output as InsightResult"); + LogFailedToParseInsightResult(ex); return null; } } + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] Executor '{Executor}' not registered — skipping insights generation")] + private partial void LogExecutorNotRegistered(string executor); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] No model configured and no known models for executor '{Executor}'")] + private partial void LogNoModelConfigured(string executor); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] Could not read transcript at {Path}")] + private partial void LogCouldNotReadTranscript(Exception ex, string path); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] Transcript is ~{Tokens} tokens ({Chars} chars) — truncating to last {Max} chars to fit model context")] + private partial void LogTranscriptTruncated(int tokens, int chars, int max); + + [LoggerMessage(Level = LogLevel.Information, Message = "[insights] Transcript is ~{Tokens} tokens ({Chars} chars)")] + private partial void LogTranscriptSize(int tokens, int chars); + + [LoggerMessage(Level = LogLevel.Information, Message = "[insights] Generating insights using {Executor}/{Model}")] + private partial void LogGeneratingInsights(AgentExecutorName executor, string model); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] No JSON found in executor output")] + private partial void LogNoJsonInOutput(); + + [LoggerMessage(Level = LogLevel.Information, Message = "[insights] Insights written to {Path}")] + private partial void LogInsightsWritten(string path); + + [LoggerMessage(Level = LogLevel.Information, Message = "[insights] Insights generation was cancelled")] + private partial void LogInsightsCancelled(); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] Failed to generate or save insights for {Path}")] + private partial void LogFailedToGenerateInsights(Exception ex, string path); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[insights] Could not clean up temp dir {Dir}")] + private partial void LogCouldNotCleanUpTempDir(Exception ex, string dir); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[insights] Failed to parse executor output as InsightResult")] + private partial void LogFailedToParseInsightResult(Exception ex); } diff --git a/ai-dev.core/Features/Journal/JournalEntry.cs b/ai-dev.core/Features/Journal/JournalEntry.cs index 3db61c5..ee578c0 100644 --- a/ai-dev.core/Features/Journal/JournalEntry.cs +++ b/ai-dev.core/Features/Journal/JournalEntry.cs @@ -1,7 +1,17 @@ namespace AiDev.Features.Journal; +/// +/// Represents a journal entry file summary. +/// public class JournalEntry { + /// + /// Gets or sets the journal entry date key. + /// public required string Date { get; set; } + + /// + /// Gets or sets the journal entry filename. + /// public required string Filename { get; set; } } diff --git a/ai-dev.core/Features/Journal/JournalsService.cs b/ai-dev.core/Features/Journal/JournalsService.cs index db7e1cc..dc78b08 100644 --- a/ai-dev.core/Features/Journal/JournalsService.cs +++ b/ai-dev.core/Features/Journal/JournalsService.cs @@ -1,22 +1,37 @@ namespace AiDev.Features.Journal; +/// +/// Provides access to agent journal entries. +/// public class JournalsService(WorkspacePaths paths) { + /// + /// Lists journal entries for the specified agent. + /// + /// The project slug that owns the agent. + /// The agent slug whose journal is being read. + /// The journal entries ordered by descending date. public List ListDates(ProjectSlug projectSlug, AgentSlug agentSlug) { var dir = paths.AgentJournalDir(projectSlug, agentSlug); if (!Directory.Exists(dir)) return []; - return Directory.GetFiles(dir, "*.md") + return [.. Directory.GetFiles(dir, "*.md") .Select(f => { var name = Path.GetFileNameWithoutExtension(f); return new JournalEntry { Date = name, Filename = Path.GetFileName(f) }; }) - .OrderByDescending(e => e.Date) - .ToList(); + .OrderByDescending(e => e.Date)]; } + /// + /// Gets the content of a journal entry for the specified date. + /// + /// The project slug that owns the agent. + /// The agent slug whose journal is being read. + /// The journal entry date key. + /// The journal entry content, or an empty string when the entry does not exist. public string GetEntry(ProjectSlug projectSlug, AgentSlug agentSlug, string date) { var path = Path.Combine(paths.AgentJournalDir(projectSlug, agentSlug), $"{date}.md"); diff --git a/ai-dev.core/Features/KnowledgeBase/KbArticle.cs b/ai-dev.core/Features/KnowledgeBase/KbArticle.cs index feec78e..c1b18c4 100644 --- a/ai-dev.core/Features/KnowledgeBase/KbArticle.cs +++ b/ai-dev.core/Features/KnowledgeBase/KbArticle.cs @@ -1,9 +1,20 @@ namespace AiDev.Features.KnowledgeBase; +/// +/// Represents a knowledge base article summary. +/// public class KbArticle { + /// + /// Gets or sets the article slug. + /// public required string Slug { get; set; } + + /// + /// Gets or sets the article title. + /// public required string Title { get; set; } + /// /// Optional trigger phrase from frontmatter. When set, this article is only injected /// into the agent prompt if a trigger word appears in the inbox message body. diff --git a/ai-dev.core/Features/KnowledgeBase/KbService.cs b/ai-dev.core/Features/KnowledgeBase/KbService.cs index 4fa326d..8cf32fe 100644 --- a/ai-dev.core/Features/KnowledgeBase/KbService.cs +++ b/ai-dev.core/Features/KnowledgeBase/KbService.cs @@ -1,13 +1,21 @@ namespace AiDev.Features.KnowledgeBase; +/// +/// Provides CRUD and prompt-injection operations for project knowledge base articles. +/// public class KbService(WorkspacePaths paths, AtomicFileWriter fileWriter, ProjectMutationCoordinator coordinator) { + /// + /// Lists the knowledge base articles for the specified project. + /// + /// The project slug to inspect. + /// The project knowledge base articles ordered by title. public List ListArticles(ProjectSlug projectSlug) { var dir = paths.KbDir(projectSlug); if (!Directory.Exists(dir)) return []; - return Directory.GetFiles(dir, "*.md") + return [.. Directory.GetFiles(dir, "*.md") .Select(f => { var content = File.ReadAllText(f); @@ -17,8 +25,7 @@ public List ListArticles(ProjectSlug projectSlug) var trigger = fields.TryGetValue("trigger", out var t) && !string.IsNullOrWhiteSpace(t) ? t : null; return new KbArticle { Slug = slug, Title = title, Trigger = trigger }; }) - .OrderBy(a => a.Title) - .ToList(); + .OrderBy(a => a.Title)]; } /// @@ -67,12 +74,25 @@ public List ListArticles(ProjectSlug projectSlug) return "## Knowledge Base\n\n" + sb.ToString(); } + /// + /// Gets the raw content of a knowledge base article. + /// + /// The project slug that owns the article. + /// The article slug. + /// The article content, or an empty string when the article does not exist. public string GetContent(ProjectSlug projectSlug, string slug) { var path = paths.SafeKbArticlePath(projectSlug, slug); return path != null && File.Exists(path.Value) ? File.ReadAllText(path.Value) : string.Empty; } + /// + /// Saves content to a knowledge base article. + /// + /// The project slug that owns the article. + /// The article slug. + /// The article content to save. + /// The result of the save operation. public Result Save(ProjectSlug projectSlug, string slug, string content) { var path = paths.SafeKbArticlePath(projectSlug, slug); @@ -89,6 +109,12 @@ public Result Save(ProjectSlug projectSlug, string slug, string content) }); } + /// + /// Creates a new knowledge base article with default content. + /// + /// The project slug that will own the article. + /// The article slug. + /// The result of the create operation. public Result Create(ProjectSlug projectSlug, string slug) { if (string.IsNullOrWhiteSpace(slug)) return new Err(new DomainError("KB_SLUG_REQUIRED", "Slug is required.")); @@ -100,6 +126,11 @@ public Result Create(ProjectSlug projectSlug, string slug) return Save(projectSlug, slug, $"# {slug}\n\n"); } + /// + /// Deletes a knowledge base article when it exists. + /// + /// The project slug that owns the article. + /// The article slug. public void Delete(ProjectSlug projectSlug, string slug) { var path = paths.SafeKbArticlePath(projectSlug, slug); diff --git a/ai-dev.core/Features/Planning/Models/ConversationMessage.cs b/ai-dev.core/Features/Planning/Models/ConversationMessage.cs index ade6498..b6ffc08 100644 --- a/ai-dev.core/Features/Planning/Models/ConversationMessage.cs +++ b/ai-dev.core/Features/Planning/Models/ConversationMessage.cs @@ -1,13 +1,27 @@ namespace AiDev.Features.Planning.Models; +/// +/// Represents a message in a planning conversation transcript. +/// public sealed class ConversationMessage { - /// "user" or "assistant" + /// + /// Gets the message role, such as user or assistant. + /// public string Role { get; init; } = string.Empty; + /// + /// Gets the message content. + /// public string Content { get; init; } = string.Empty; + + /// + /// Gets the timestamp when the message was recorded. + /// public DateTime Timestamp { get; init; } - /// True when an EC-6 filter blocked the original response; Content contains the substitute prompt. + /// + /// Gets a value indicating whether an EC-6 filter blocked the original response and substituted the content. + /// public bool WasFiltered { get; init; } } diff --git a/ai-dev.core/Features/Planning/Models/PlanningLlmResponse.cs b/ai-dev.core/Features/Planning/Models/PlanningLlmResponse.cs index da28910..ce8b6fe 100644 --- a/ai-dev.core/Features/Planning/Models/PlanningLlmResponse.cs +++ b/ai-dev.core/Features/Planning/Models/PlanningLlmResponse.cs @@ -1,3 +1,9 @@ namespace AiDev.Features.Planning.Models; +/// +/// Represents the content and token usage returned from a planning model call. +/// +/// The response content returned by the model. +/// The number of input tokens consumed by the request. +/// The number of output tokens generated in the response. public sealed record PlanningLlmResponse(string Content, int InputTokens, int OutputTokens); diff --git a/ai-dev.core/Features/Planning/Models/PlanningPhase.cs b/ai-dev.core/Features/Planning/Models/PlanningPhase.cs index b58c901..59d3640 100644 --- a/ai-dev.core/Features/Planning/Models/PlanningPhase.cs +++ b/ai-dev.core/Features/Planning/Models/PlanningPhase.cs @@ -1,8 +1,22 @@ namespace AiDev.Features.Planning.Models; +/// +/// Represents the business-to-implementation planning phases. +/// public enum PlanningPhase { + /// + /// Focuses on business discovery and requirement clarification. + /// Phase1BusinessDiscovery = 1, + + /// + /// Focuses on shaping the candidate solution. + /// Phase2SolutionShaping = 2, + + /// + /// Focuses on decomposing the solution into implementation work. + /// Phase3PlanningDecomposition = 3, } diff --git a/ai-dev.core/Features/Planning/Models/PlanningSessionManifest.cs b/ai-dev.core/Features/Planning/Models/PlanningSessionManifest.cs index 27a83db..d2eadeb 100644 --- a/ai-dev.core/Features/Planning/Models/PlanningSessionManifest.cs +++ b/ai-dev.core/Features/Planning/Models/PlanningSessionManifest.cs @@ -1,16 +1,47 @@ namespace AiDev.Features.Planning.Models; +/// +/// Represents persisted metadata for a planning session. +/// public sealed class PlanningSessionManifest { + /// + /// Gets the planning session identifier. + /// public string SessionId { get; init; } = string.Empty; + + /// + /// Gets the project name associated with the session. + /// public string ProjectName { get; init; } = string.Empty; + + /// + /// Gets the timestamp when the session was created. + /// public DateTime CreatedAt { get; init; } + + /// + /// Gets or sets the current planning phase. + /// public PlanningPhase CurrentPhase { get; set; } = PlanningPhase.Phase1BusinessDiscovery; - /// Phases whose DSL has been locked (immutable). Ordered by phase number. + /// + /// Gets the phases whose DSL has been locked in read-only form, ordered by phase number. + /// public List LockedPhases { get; init; } = []; + /// + /// Gets a value indicating whether phase 1 has been locked. + /// public bool IsPhase1Locked => LockedPhases.Contains(PlanningPhase.Phase1BusinessDiscovery); + + /// + /// Gets a value indicating whether phase 2 has been locked. + /// public bool IsPhase2Locked => LockedPhases.Contains(PlanningPhase.Phase2SolutionShaping); + + /// + /// Gets a value indicating whether phase 3 has been locked. + /// public bool IsPhase3Locked => LockedPhases.Contains(PlanningPhase.Phase3PlanningDecomposition); } diff --git a/ai-dev.core/Features/Planning/PlanningChatService.cs b/ai-dev.core/Features/Planning/PlanningChatService.cs index 599a32f..3190dd3 100644 --- a/ai-dev.core/Features/Planning/PlanningChatService.cs +++ b/ai-dev.core/Features/Planning/PlanningChatService.cs @@ -11,7 +11,7 @@ namespace AiDev.Features.Planning; /// matches the analyst agent's configured executor for the project. /// If no analyst exists, defaults to the standard executor and model. /// -public sealed class PlanningChatService( +public sealed partial class PlanningChatService( AgentService agentService, IEnumerable llmClients, ILogger logger) : IPlanningChatService @@ -39,7 +39,7 @@ public async Task SendPhase1MessageAsync( if (wasFiltered) { - logger.LogInformation("[planning-chat] EC-6 filter triggered — term: {Term}", firstMatch); + LogEc6FilterTriggered(firstMatch); const string blockedMessage = "I notice I was about to mention some technical implementation details, which aren't appropriate for this phase. " + "Let's keep our focus on the business problem. Could you rephrase your last message in purely business terms? " + @@ -150,9 +150,7 @@ public async Task GeneratePlanDslAsync( "Planning requires a client matching the analyst's configured executor."); } - logger.LogWarning( - "[planning-chat] No analyst agent found and default executor '{Executor}' is unavailable.", - executorName); + LogNoAnalystAgentFound(executorName); client = clientMap.Values.FirstOrDefault() ?? throw new InvalidOperationException("No IPlanningLlmClient implementations are registered for planning chat."); @@ -160,7 +158,7 @@ public async Task GeneratePlanDslAsync( executorName = client.ExecutorName; } - logger.LogDebug("[planning-chat] Using executor '{Executor}' model '{Model}'", executorName, modelId); + LogUsingExecutor(executorName, modelId); return (client, modelId); } @@ -169,11 +167,11 @@ public async Task GeneratePlanDslAsync( // ------------------------------------------------------------------------- private static IReadOnlyList ToMessages(IReadOnlyList turns) => - turns.Select(t => new ConversationMessage + [.. turns.Select(t => new ConversationMessage { Role = t.Role.ApiRole, Content = t.Content, - }).ToList(); + })]; // ------------------------------------------------------------------------- // System prompts @@ -410,4 +408,13 @@ You have two read-only context documents provided below. Do not allow the user t dependencies: [] estimated_size: <"XS"|"S"|"M"|"L"> """; + + [LoggerMessage(Level = LogLevel.Information, Message = "[planning-chat] EC-6 filter triggered — term: {Term}")] + private partial void LogEc6FilterTriggered(string? term); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning-chat] No analyst agent found and default executor '{Executor}' is unavailable.")] + private partial void LogNoAnalystAgentFound(AgentExecutorName executor); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[planning-chat] Using executor '{Executor}' model '{Model}'")] + private partial void LogUsingExecutor(AgentExecutorName executor, string model); } diff --git a/ai-dev.core/Features/Planning/PlanningSessionService.cs b/ai-dev.core/Features/Planning/PlanningSessionService.cs index 6982872..e077ae6 100644 --- a/ai-dev.core/Features/Planning/PlanningSessionService.cs +++ b/ai-dev.core/Features/Planning/PlanningSessionService.cs @@ -21,7 +21,7 @@ namespace AiDev.Features.Planning; /// Solution.dsl /// Plan.dsl /// -public sealed class PlanningSessionService( +public sealed partial class PlanningSessionService( WorkspacePaths paths, AtomicFileWriter fileWriter, ILogger logger) : IPlanningSessionService @@ -62,8 +62,7 @@ public async Task CreateSessionAsync( await SaveMetadataAsync(projectSlug, sessionId, metadata, ct).ConfigureAwait(false); - logger.LogInformation("[planning] Created session {SessionId} for project {ProjectSlug}", - sessionId, projectSlug.Value); + LogCreatedSession(sessionId, projectSlug.Value); return metadata; } @@ -112,9 +111,7 @@ public IReadOnlyList GetConversation(ProjectSlug projectSlug, public IReadOnlyList GetConversationForPhase( ProjectSlug projectSlug, SessionId sessionId, SessionPhase phase) - => GetConversation(projectSlug, sessionId) - .Where(t => t.Phase == phase) - .ToList(); + => [.. GetConversation(projectSlug, sessionId).Where(t => t.Phase == phase)]; public async Task AppendTurnAsync( ProjectSlug projectSlug, SessionId sessionId, ConversationTurn turn, CancellationToken ct = default) @@ -146,9 +143,33 @@ public async Task SaveDraftDslAsync( paths.PlanningSessionDraftsDir(projectSlug, sessionId).Create(); await WriteYamlAsync(draftPath.Value, yamlContent, ct).ConfigureAwait(false); - logger.LogDebug("[planning] Saved draft {Phase} DSL for session {SessionId}", phase, sessionId); + LogSavedDraftDsl(phase, sessionId); } + [LoggerMessage(Level = LogLevel.Information, Message = "[planning] Created session {SessionId} for project {ProjectSlug}")] + private partial void LogCreatedSession(SessionId sessionId, string projectSlug); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[planning] Saved draft {Phase} DSL for session {SessionId}")] + private partial void LogSavedDraftDsl(SessionPhase phase, SessionId sessionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Solution.dsl validation failed for session {SessionId}: {Errors}")] + private partial void LogSolutionDslValidationFailed(SessionId sessionId, string errors); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Attempt to overwrite locked {Phase} DSL for session {SessionId} — rejected")] + private partial void LogAttemptToOverwriteLockedDsl(SessionPhase phase, SessionId sessionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Failed to read locked {Phase} DSL for session {SessionId}")] + private partial void LogFailedToReadLockedDsl(Exception ex, SessionPhase phase, SessionId sessionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Failed to read draft {Phase} DSL for session {SessionId}")] + private partial void LogFailedToReadDraftDsl(Exception ex, SessionPhase phase, SessionId sessionId); + + [LoggerMessage(Level = LogLevel.Information, Message = "[planning] Session directory created with user-only permissions: {Path}")] + private partial void LogSessionDirectoryPermissionsApplied(string path); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Could not set user-only ACL on session directory {Path} — proceeding without ACL")] + private partial void LogSessionDirectoryAclFailed(Exception ex, string path); + public async Task> LockPhaseAsync( ProjectSlug projectSlug, SessionId sessionId, SessionPhase phase, string yamlContent, CancellationToken ct = default) @@ -173,8 +194,7 @@ public async Task> LockPhaseAsync( var validationResult = SolutionDslValidator.Validate(yamlContent); if (!validationResult.IsValid) { - logger.LogWarning("[planning] Solution.dsl validation failed for session {SessionId}: {Errors}", - sessionId, string.Join("; ", validationResult.Errors.Select(e => e.Message))); + LogSolutionDslValidationFailed(sessionId, string.Join("; ", validationResult.Errors.Select(e => e.Message))); return new Err(SolutionDslInvalidError with { Message = string.Join(" | ", validationResult.Errors.Select(e => e.Message)), @@ -188,8 +208,7 @@ public async Task> LockPhaseAsync( if (lockedPath.Exists()) { // Locked file already exists — reject silently to preserve immutability. - logger.LogWarning("[planning] Attempt to overwrite locked {Phase} DSL for session {SessionId} — rejected", - phase, sessionId); + LogAttemptToOverwriteLockedDsl(phase, sessionId); return new Err(PhaseAlreadyLockedError); } @@ -220,10 +239,16 @@ public async Task> LockPhaseAsync( await SaveMetadataAsync(projectSlug, sessionId, metadata, ct).ConfigureAwait(false); - logger.LogInformation("[planning] Locked {Phase} for session {SessionId}", phase, sessionId); + LogLockedPhase(phase, sessionId); return new Ok(Unit.Value); } + [LoggerMessage(Level = LogLevel.Information, Message = "[planning] Locked {Phase} for session {SessionId}")] + private partial void LogLockedPhase(SessionPhase phase, SessionId sessionId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Failed to lock {Phase} for session {SessionId}: {Error}")] + private partial void LogFailedToLockPhase(SessionPhase phase, SessionId sessionId, string error); + public async Task UpdateTokenCountAsync( ProjectSlug projectSlug, SessionId sessionId, SessionPhase phase, int inputTokens, CancellationToken ct = default) @@ -244,7 +269,7 @@ public async Task UpdateTokenCountAsync( try { return File.ReadAllText(path.Value, Encoding.UTF8); } catch (Exception ex) { - logger.LogWarning(ex, "[planning] Failed to read locked {Phase} DSL for session {SessionId}", phase, sessionId); + LogFailedToReadLockedDsl(ex, phase, sessionId); return null; } } @@ -256,7 +281,7 @@ public async Task UpdateTokenCountAsync( try { return File.ReadAllText(path.Value, Encoding.UTF8); } catch (Exception ex) { - logger.LogWarning(ex, "[planning] Failed to read draft {Phase} DSL for session {SessionId}", phase, sessionId); + LogFailedToReadDraftDsl(ex, phase, sessionId); return null; } } @@ -323,10 +348,13 @@ 3. Pause planning and defer to a later phase. await File.WriteAllTextAsync(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct) .ConfigureAwait(false); - logger.LogInformation("[planning] Created EC-4 escalation decision file: {FilePath}", filePath); + LogCreatedEc4EscalationDecisionFile(filePath); return filePath; } + [LoggerMessage(Level = LogLevel.Information, Message = "[planning] Created EC-4 escalation decision file: {FilePath}")] + private partial void LogCreatedEc4EscalationDecisionFile(string filePath); + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- @@ -352,11 +380,14 @@ private async Task SaveMetadataAsync( } catch (Exception ex) { - logger.LogWarning(ex, "[planning] Failed to read session metadata from {Path}", filePath); + LogFailedToReadSessionMetadata(filePath, ex.Message); return null; } } + [LoggerMessage(Level = LogLevel.Warning, Message = "[planning] Failed to read session metadata from {Path}: {Error}")] + private partial void LogFailedToReadSessionMetadata(string path, string error); + private static IReadOnlyList ReadConversation(string filePath) { var turns = new List(); @@ -407,12 +438,12 @@ private void ApplySessionDirectoryPermissions(string directoryPath) AccessControlType.Allow)); dirInfo.SetAccessControl(dirSecurity); - logger.LogInformation("[planning] Session directory created with user-only permissions: {Path}", directoryPath); + LogSessionDirectoryPermissionsApplied(directoryPath); } catch (Exception ex) { // Non-critical: log warning and continue (e.g. network drives do not support ACLs). - logger.LogWarning(ex, "[planning] Could not set user-only ACL on session directory {Path} — proceeding without ACL", directoryPath); + LogSessionDirectoryAclFailed(ex, directoryPath); } } diff --git a/ai-dev.core/Features/Planning/SessionPhase.cs b/ai-dev.core/Features/Planning/SessionPhase.cs index e2ffcd7..f59efd6 100644 --- a/ai-dev.core/Features/Planning/SessionPhase.cs +++ b/ai-dev.core/Features/Planning/SessionPhase.cs @@ -31,6 +31,9 @@ private SessionPhase( ShortLabel = shortLabel; } + /// + /// Represents phase 1 business discovery. + /// public static readonly SessionPhase Phase1BusinessDiscovery = new( key: "Phase1BusinessDiscovery", dslFileName: FilePathConstants.BusinessDslFileName, @@ -41,6 +44,9 @@ private SessionPhase( reviewTitle: "Phase 1 — Business Discovery (Read-Only)", shortLabel: "Ph1"); + /// + /// Represents phase 2 solution shaping. + /// public static readonly SessionPhase Phase2SolutionShaping = new( key: "Phase2SolutionShaping", dslFileName: FilePathConstants.SolutionDslFileName, @@ -51,6 +57,9 @@ private SessionPhase( reviewTitle: "Phase 2 — Solution Shaping (Read-Only)", shortLabel: "Ph2"); + /// + /// Represents phase 3 planning and decomposition. + /// public static readonly SessionPhase Phase3PlanningDecomposition = new( key: "Phase3PlanningDecomposition", dslFileName: FilePathConstants.PlanDslFileName, @@ -82,8 +91,17 @@ private SessionPhase( /// Compact label used in the session list (e.g. "Ph1"). public string ShortLabel { get; } + /// + /// Serializes the phase to its persisted key. + /// + /// The serialized phase key. public string Serialize() => _key ?? "Phase1BusinessDiscovery"; + /// + /// Parses a persisted phase key. + /// + /// The serialized phase key. + /// The parsed phase, defaulting to phase 1 when the value is unknown. public static SessionPhase Parse(string? value) => value switch { "Phase1BusinessDiscovery" => Phase1BusinessDiscovery, diff --git a/ai-dev.core/Features/Planning/TokenStatus.cs b/ai-dev.core/Features/Planning/TokenStatus.cs index 639b1c8..fbe8534 100644 --- a/ai-dev.core/Features/Planning/TokenStatus.cs +++ b/ai-dev.core/Features/Planning/TokenStatus.cs @@ -1,17 +1,31 @@ namespace AiDev.Features.Planning; +/// +/// Represents the warning level derived from planning token usage. +/// public enum TokenWarningLevel { + /// + /// No warning threshold has been reached. + /// None, - /// Soft warning threshold reached (32,000 tokens). User should consider generating DSL soon. + + /// + /// Soft warning threshold reached (32,000 tokens). User should consider generating DSL soon. + /// Soft, - /// Hard limit reached (40,000 tokens). Further input is blocked. + + /// + /// Hard limit reached (40,000 tokens). Further input is blocked. + /// Hard, } /// /// Represents the current token usage status for a phase conversation. /// +/// The total input tokens currently used. +/// The warning level derived from the current token count. public sealed record TokenStatus(int TotalTokens, TokenWarningLevel WarningLevel) { /// Soft warning threshold: 32,000 input tokens. @@ -20,8 +34,15 @@ public sealed record TokenStatus(int TotalTokens, TokenWarningLevel WarningLevel /// Hard limit threshold: 40,000 input tokens. Input is blocked beyond this. public const int HardLimitThreshold = 40_000; + /// + /// Gets a value indicating whether the hard token limit has been reached. + /// public bool IsAtHardLimit => WarningLevel == TokenWarningLevel.Hard; - public bool IsWarning => WarningLevel != TokenWarningLevel.None; + + /// + /// Gets a value indicating whether any warning threshold has been reached. + /// + public bool IsWarning => WarningLevel != TokenWarningLevel.None; /// Derives a from the raw token count. public static TokenStatus From(int totalTokens) => new( diff --git a/ai-dev.core/Features/Playbook/PlaybookItem.cs b/ai-dev.core/Features/Playbook/PlaybookItem.cs index cc04392..3a6f287 100644 --- a/ai-dev.core/Features/Playbook/PlaybookItem.cs +++ b/ai-dev.core/Features/Playbook/PlaybookItem.cs @@ -1,9 +1,20 @@ namespace AiDev.Features.Playbook; +/// +/// Represents a playbook summary. +/// public class PlaybookItem { + /// + /// Gets or sets the playbook slug. + /// public required string Slug { get; set; } + + /// + /// Gets or sets the playbook title. + /// public required string Title { get; set; } + /// /// Optional macro shorthand from frontmatter (e.g. !deploy-check). /// diff --git a/ai-dev.core/Features/Playbook/PlaybookService.cs b/ai-dev.core/Features/Playbook/PlaybookService.cs index 8eaddf5..e197cd1 100644 --- a/ai-dev.core/Features/Playbook/PlaybookService.cs +++ b/ai-dev.core/Features/Playbook/PlaybookService.cs @@ -1,13 +1,21 @@ namespace AiDev.Features.Playbook; +/// +/// Provides CRUD and prompt-injection operations for project playbooks. +/// public class PlaybookService(WorkspacePaths paths, AtomicFileWriter fileWriter, ProjectMutationCoordinator coordinator) { + /// + /// Lists the playbooks for the specified project. + /// + /// The project slug to inspect. + /// The playbooks ordered by title. public List ListPlaybooks(ProjectSlug projectSlug) { var dir = paths.PlaybooksDir(projectSlug); if (!Directory.Exists(dir)) return []; - return Directory.GetFiles(dir, "*.md") + return [.. Directory.GetFiles(dir, "*.md") .Select(f => { var content = File.ReadAllText(f); @@ -17,8 +25,7 @@ public List ListPlaybooks(ProjectSlug projectSlug) var macro = fields.TryGetValue("macro", out var m) && !string.IsNullOrWhiteSpace(m) ? m : null; return new PlaybookItem { Slug = slug, Title = title, Macro = macro }; }) - .OrderBy(p => p.Title) - .ToList(); + .OrderBy(p => p.Title)]; } /// @@ -38,12 +45,25 @@ public List ListPlaybooks(ProjectSlug projectSlug) return $"## Playbook: {title}\n\n{playbookBody.TrimEnd()}"; } + /// + /// Gets the raw content of a playbook. + /// + /// The project slug that owns the playbook. + /// The playbook slug. + /// The playbook content, or an empty string when the playbook does not exist. public string GetContent(ProjectSlug projectSlug, string slug) { var path = paths.SafePlaybookPath(projectSlug, slug); return path != null && File.Exists(path.Value) ? File.ReadAllText(path.Value) : string.Empty; } + /// + /// Saves content to a playbook. + /// + /// The project slug that owns the playbook. + /// The playbook slug. + /// The playbook content to save. + /// The result of the save operation. public Result Save(ProjectSlug projectSlug, string slug, string content) { var path = paths.SafePlaybookPath(projectSlug, slug); @@ -60,6 +80,12 @@ public Result Save(ProjectSlug projectSlug, string slug, string content) }); } + /// + /// Creates a new playbook with default content. + /// + /// The project slug that will own the playbook. + /// The playbook slug. + /// The result of the create operation. public Result Create(ProjectSlug projectSlug, string slug) { if (string.IsNullOrWhiteSpace(slug)) return new Err(new DomainError("PLAYBOOK_SLUG_REQUIRED", "Slug is required.")); @@ -72,6 +98,11 @@ public Result Create(ProjectSlug projectSlug, string slug) return Save(projectSlug, slug, $"# {slug}\n\n"); } + /// + /// Deletes a playbook when it exists. + /// + /// The project slug that owns the playbook. + /// The playbook slug. public void Delete(ProjectSlug projectSlug, string slug) { var path = paths.SafePlaybookPath(projectSlug, slug); diff --git a/ai-dev.core/Features/Secrets/SecretsService.cs b/ai-dev.core/Features/Secrets/SecretsService.cs index 5526277..853ec2a 100644 --- a/ai-dev.core/Features/Secrets/SecretsService.cs +++ b/ai-dev.core/Features/Secrets/SecretsService.cs @@ -24,7 +24,7 @@ private sealed record SecretEntry(string Name, string Encrypted); public IReadOnlyList ListSecrets(ProjectSlug projectSlug) { var entries = ReadEntries(projectSlug); - return entries.Select(e => e.Name).ToList(); + return [.. entries.Select(e => e.Name)]; } /// Adds or replaces a secret. The value is encrypted before persisting. diff --git a/ai-dev.core/Features/Workspace/WorkspaceProject.cs b/ai-dev.core/Features/Workspace/WorkspaceProject.cs index 8bbc9e7..3287b7e 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceProject.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceProject.cs @@ -1,5 +1,8 @@ namespace AiDev.Features.Workspace; +/// +/// Represents summary information about a workspace project. +/// public sealed class WorkspaceProject { /// @@ -20,10 +23,29 @@ public WorkspaceProject(ProjectSlug slug, string name, string? description = nul AgentCount = agentCount; } + /// + /// Gets the unique project slug. + /// public ProjectSlug Slug { get; } + + /// + /// Gets the display name of the project. + /// public string Name { get; } + + /// + /// Gets the optional project description. + /// public string? Description { get; } + + /// + /// Gets the project creation timestamp when available. + /// public DateTime? CreatedAt { get; } + + /// + /// Gets the number of agents associated with the project. + /// public int AgentCount { get; } private static string? NormalizeOptional(string? value) diff --git a/ai-dev.core/Features/Workspace/WorkspaceRegistry.cs b/ai-dev.core/Features/Workspace/WorkspaceRegistry.cs index c22ef39..54d237b 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceRegistry.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceRegistry.cs @@ -1,6 +1,12 @@ namespace AiDev.Features.Workspace; +/// +/// Represents the persisted registry of workspace projects. +/// public class WorkspaceRegistry { + /// + /// Gets or sets the registered workspace projects. + /// public List Projects { get; set; } = []; } diff --git a/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs b/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs index d386576..4b5a456 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceRegistryEntry.cs @@ -1,8 +1,22 @@ namespace AiDev.Features.Workspace; +/// +/// Represents a project entry stored in the workspace registry. +/// public class WorkspaceRegistryEntry { + /// + /// Gets or sets the project slug. + /// public required string Slug { get; set; } + + /// + /// Gets or sets the relative project path. + /// public required string Path { get; set; } + + /// + /// Gets or sets the display name of the project. + /// public required string Name { get; set; } } diff --git a/ai-dev.core/Features/Workspace/WorkspaceService.cs b/ai-dev.core/Features/Workspace/WorkspaceService.cs index dbf5bcb..b78411f 100644 --- a/ai-dev.core/Features/Workspace/WorkspaceService.cs +++ b/ai-dev.core/Features/Workspace/WorkspaceService.cs @@ -6,11 +6,18 @@ file class ProjectJson public string? CreatedAt { get; set; } } +/// +/// Provides workspace project management and metadata operations. +/// public class WorkspaceService(WorkspacePaths paths, AtomicFileWriter fileWriter, ILogger? logger = null) { private static readonly DomainError InvalidProjectSlugError = new("WORKSPACE_INVALID_SLUG", "Project slug is invalid."); private static readonly DomainError ProjectNotFoundError = new("WORKSPACE_NOT_FOUND", "Project not found."); + /// + /// Lists the registered workspace projects. + /// + /// The registered workspace projects. public List ListProjects() { if (!File.Exists(paths.RegistryPath)) return []; @@ -155,6 +162,11 @@ public Result CreateProject(string slug, string name, string? description, } } + /// + /// Gets detailed metadata for a project. + /// + /// The project slug to read. + /// The project detail, or when the project does not exist. public ProjectDetail? GetProject(ProjectSlug projectSlug) { var jsonPath = paths.ProjectJsonPath(projectSlug); @@ -181,6 +193,14 @@ public Result CreateProject(string slug, string name, string? description, } } + /// + /// Updates editable metadata for an existing project. + /// + /// The project slug to update. + /// The new project name. + /// The new project description. + /// The optional codebase path associated with the project. + /// The result of the update operation. public Result UpdateProject(ProjectSlug projectSlug, string name, string? description, string? codebasePath) { if (string.IsNullOrWhiteSpace(name)) diff --git a/ai-dev.core/Models/AppFeatureFlags.cs b/ai-dev.core/Models/AppFeatureFlags.cs index 986e2cf..a174b1a 100644 --- a/ai-dev.core/Models/AppFeatureFlags.cs +++ b/ai-dev.core/Models/AppFeatureFlags.cs @@ -1,6 +1,12 @@ namespace AiDev.Models; +/// +/// Represents application-level feature flag settings. +/// public class AppFeatureFlags { + /// + /// Gets or sets a value indicating whether local-only functionality is enabled. + /// public bool LocalFunctionalityEnabled { get; set; } } diff --git a/ai-dev.core/Models/ConsistencyFinding.cs b/ai-dev.core/Models/ConsistencyFinding.cs new file mode 100644 index 0000000..1b26123 --- /dev/null +++ b/ai-dev.core/Models/ConsistencyFinding.cs @@ -0,0 +1,18 @@ +namespace AiDev.Models; + +/// +/// Represents a single consistency finding reported during validation. +/// +/// The machine-readable finding code. +/// The severity of the finding. +/// The human-readable finding message. +/// The fix classification for the finding. +/// The optional project slug associated with the finding. +/// The optional resource identifier associated with the finding. +public sealed record ConsistencyFinding( + string Code, + ConsistencySeverity Severity, + string Message, + ConsistencyFixType FixType = ConsistencyFixType.None, + string? ProjectSlug = null, + string? ResourceId = null); diff --git a/ai-dev.core/Models/ConsistencyFixType.cs b/ai-dev.core/Models/ConsistencyFixType.cs new file mode 100644 index 0000000..d6ec09d --- /dev/null +++ b/ai-dev.core/Models/ConsistencyFixType.cs @@ -0,0 +1,22 @@ +namespace AiDev.Models; + +/// +/// Represents how a consistency issue can be addressed. +/// +public enum ConsistencyFixType +{ + /// + /// No fix was required or available. + /// + None, + + /// + /// The issue was repaired automatically. + /// + AutoRepaired, + + /// + /// The issue requires manual action. + /// + ManualActionRequired, +} diff --git a/ai-dev.core/Models/ConsistencyModels.cs b/ai-dev.core/Models/ConsistencyModels.cs deleted file mode 100644 index 07a2376..0000000 --- a/ai-dev.core/Models/ConsistencyModels.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace AiDev.Models; - -public enum ConsistencySeverity -{ - Info, - Warning, - Error, -} - -public enum ConsistencyFixType -{ - None, - AutoRepaired, - ManualActionRequired, -} - -public sealed record ConsistencyFinding( - string Code, - ConsistencySeverity Severity, - string Message, - ConsistencyFixType FixType = ConsistencyFixType.None, - string? ProjectSlug = null, - string? ResourceId = null); - -public sealed class ProjectConsistencyReport(ProjectSlug projectSlug, IReadOnlyList findings) -{ - public ProjectSlug ProjectSlug { get; } = projectSlug; - public IReadOnlyList Findings { get; } = findings; - - public bool HasErrors => Findings.Any(f => f.Severity == ConsistencySeverity.Error); - public bool HasWarnings => Findings.Any(f => f.Severity == ConsistencySeverity.Warning); -} - -public sealed class WorkspaceConsistencyReport(IReadOnlyList projects) -{ - public IReadOnlyList Projects { get; } = projects; - - public int ErrorCount => Projects.Sum(p => p.Findings.Count(f => f.Severity == ConsistencySeverity.Error)); - public int WarningCount => Projects.Sum(p => p.Findings.Count(f => f.Severity == ConsistencySeverity.Warning)); -} diff --git a/ai-dev.core/Models/ConsistencySeverity.cs b/ai-dev.core/Models/ConsistencySeverity.cs new file mode 100644 index 0000000..75ad48f --- /dev/null +++ b/ai-dev.core/Models/ConsistencySeverity.cs @@ -0,0 +1,22 @@ +namespace AiDev.Models; + +/// +/// Represents the severity of a consistency finding. +/// +public enum ConsistencySeverity +{ + /// + /// Informational finding. + /// + Info, + + /// + /// Warning finding. + /// + Warning, + + /// + /// Error finding. + /// + Error, +} diff --git a/ai-dev.core/Models/DecisionResolved.cs b/ai-dev.core/Models/DecisionResolved.cs new file mode 100644 index 0000000..5c5420d --- /dev/null +++ b/ai-dev.core/Models/DecisionResolved.cs @@ -0,0 +1,12 @@ +namespace AiDev.Models; + +/// +/// Raised when a decision is resolved. +/// +/// The resolved decision identifier. +/// The actor that resolved the decision. +/// The UTC timestamp when the decision was resolved. +public sealed record DecisionResolved( + DecisionId DecisionId, + string ResolvedBy, + DateTime OccurredAt) : DomainEvent(OccurredAt); diff --git a/ai-dev.core/Models/DomainEvents.cs b/ai-dev.core/Models/DomainEvents.cs index 4f21627..ae12a14 100644 --- a/ai-dev.core/Models/DomainEvents.cs +++ b/ai-dev.core/Models/DomainEvents.cs @@ -1,17 +1,7 @@ namespace AiDev.Models; +/// +/// Base type for domain events raised by aggregates and services. +/// +/// The UTC timestamp when the event occurred. public abstract record DomainEvent(DateTime OccurredAt); - -public sealed record TaskAssigned( - ProjectSlug ProjectSlug, - TaskId TaskId, - AgentSlug Assignee, - string Title, - string? Description, - Priority Priority, - DateTime OccurredAt) : DomainEvent(OccurredAt); - -public sealed record DecisionResolved( - DecisionId DecisionId, - string ResolvedBy, - DateTime OccurredAt) : DomainEvent(OccurredAt); diff --git a/ai-dev.core/Models/IDomainEventDispatcher.cs b/ai-dev.core/Models/IDomainEventDispatcher.cs index 208c028..c01d515 100644 --- a/ai-dev.core/Models/IDomainEventDispatcher.cs +++ b/ai-dev.core/Models/IDomainEventDispatcher.cs @@ -1,6 +1,15 @@ namespace AiDev.Models; +/// +/// Dispatches domain events to registered handlers. +/// public interface IDomainEventDispatcher { + /// + /// Dispatches the provided domain events. + /// + /// The events to dispatch. + /// The cancellation token for the dispatch operation. + /// The result of the dispatch operation. Task> Dispatch(IReadOnlyList events, CancellationToken ct = default); } diff --git a/ai-dev.core/Models/IDomainEventHandler.cs b/ai-dev.core/Models/IDomainEventHandler.cs index 86df293..023b813 100644 --- a/ai-dev.core/Models/IDomainEventHandler.cs +++ b/ai-dev.core/Models/IDomainEventHandler.cs @@ -1,6 +1,15 @@ namespace AiDev.Models; +/// +/// Handles a specific type of domain event. +/// +/// The domain event type handled by this handler. public interface IDomainEventHandler where TEvent : DomainEvent { + /// + /// Handles the provided domain event. + /// + /// The domain event to handle. + /// The cancellation token for the handler operation. Task Handle(TEvent domainEvent, CancellationToken ct = default); } diff --git a/ai-dev.core/Models/InsightIssue.cs b/ai-dev.core/Models/InsightIssue.cs new file mode 100644 index 0000000..9032209 --- /dev/null +++ b/ai-dev.core/Models/InsightIssue.cs @@ -0,0 +1,4 @@ +namespace AiDev.Models; + +/// Represents a single issue or problem encountered during an agent session. +public record InsightIssue(string Description, string Impact); // Impact: high / medium / low diff --git a/ai-dev.core/Models/InsightResult.cs b/ai-dev.core/Models/InsightResult.cs index c57f75e..2293057 100644 --- a/ai-dev.core/Models/InsightResult.cs +++ b/ai-dev.core/Models/InsightResult.cs @@ -1,15 +1,12 @@ namespace AiDev.Models; -/// Represents a single issue or problem encountered during an agent session. -public record InsightIssue(string Description, string Impact); // Impact: high / medium / low - /// /// AI-generated qualitative analysis of a completed agent session. /// Written as {date}.insights.json alongside the transcript file. /// public record InsightResult( - string TaskClassification, // feature / bug / refactor / investigation / other - string SessionSizeRating, // small / medium / large + TaskClassification TaskClassification, + SessionSizeRating SessionSizeRating, IReadOnlyList Issues, IReadOnlyList KnowledgeGaps, string ImprovedPromptSuggestion); diff --git a/ai-dev.core/Models/MessageItem.cs b/ai-dev.core/Models/MessageItem.cs index 134f044..83b12e5 100644 --- a/ai-dev.core/Models/MessageItem.cs +++ b/ai-dev.core/Models/MessageItem.cs @@ -1,5 +1,8 @@ namespace AiDev.Models; +/// +/// Represents a message stored in an agent inbox or processed archive. +/// public sealed class MessageItem { /// @@ -43,17 +46,64 @@ public MessageItem( Playbook = NormalizeOptional(playbook); } + /// + /// Gets the backing filename for the message. + /// public string Filename { get; } + + /// + /// Gets the agent slug that owns the message. + /// public AgentSlug AgentSlug { get; } + + /// + /// Gets the message source. + /// public MessageSource From { get; } + + /// + /// Gets the message recipient. + /// public string To { get; } + + /// + /// Gets the message timestamp. + /// public DateTime? Date { get; } + + /// + /// Gets the message priority. + /// public Priority Priority { get; } + + /// + /// Gets the message subject. + /// public string Re { get; } + + /// + /// Gets the message type. + /// public MessageType Type { get; } + + /// + /// Gets the message body. + /// public string Body { get; } + + /// + /// Gets a value indicating whether the message has been processed. + /// public bool IsProcessed { get; } + + /// + /// Gets the optional associated task identifier. + /// public TaskId? TaskId { get; } + + /// + /// Gets the optional associated playbook slug. + /// public string? Playbook { get; } private static Priority NormalizePriority(Priority? priority) diff --git a/ai-dev.core/Models/ProjectConsistencyReport.cs b/ai-dev.core/Models/ProjectConsistencyReport.cs new file mode 100644 index 0000000..b32913c --- /dev/null +++ b/ai-dev.core/Models/ProjectConsistencyReport.cs @@ -0,0 +1,27 @@ +namespace AiDev.Models; + +/// +/// Represents the consistency findings for a single project. +/// +public sealed class ProjectConsistencyReport(ProjectSlug projectSlug, IReadOnlyList findings) +{ + /// + /// Gets the project slug associated with the report. + /// + public ProjectSlug ProjectSlug { get; } = projectSlug; + + /// + /// Gets the findings reported for the project. + /// + public IReadOnlyList Findings { get; } = findings; + + /// + /// Gets a value indicating whether the report contains any error findings. + /// + public bool HasErrors => Findings.Any(f => f.Severity == ConsistencySeverity.Error); + + /// + /// Gets a value indicating whether the report contains any warning findings. + /// + public bool HasWarnings => Findings.Any(f => f.Severity == ConsistencySeverity.Warning); +} diff --git a/ai-dev.core/Models/ProjectDetail.cs b/ai-dev.core/Models/ProjectDetail.cs index 8aa62fb..ed36b3d 100644 --- a/ai-dev.core/Models/ProjectDetail.cs +++ b/ai-dev.core/Models/ProjectDetail.cs @@ -1,10 +1,32 @@ namespace AiDev.Models; +/// +/// Represents detailed metadata for a project. +/// public class ProjectDetail { + /// + /// Gets or sets the project slug. + /// public required ProjectSlug Slug { get; set; } + + /// + /// Gets or sets the project display name. + /// public required string Name { get; set; } + + /// + /// Gets or sets the project description. + /// public required string Description { get; set; } + + /// + /// Gets or sets the optional codebase path. + /// public string? CodebasePath { get; set; } + + /// + /// Gets or sets the project creation timestamp. + /// public DateTime? CreatedAt { get; set; } } diff --git a/ai-dev.core/Models/ProjectStateChangeKind.cs b/ai-dev.core/Models/ProjectStateChangeKind.cs index a04a762..94dad14 100644 --- a/ai-dev.core/Models/ProjectStateChangeKind.cs +++ b/ai-dev.core/Models/ProjectStateChangeKind.cs @@ -1,11 +1,33 @@ namespace AiDev.Models; +/// +/// Represents the categories of project state that can change. +/// [Flags] public enum ProjectStateChangeKind { + /// + /// No state change. + /// None = 0, + + /// + /// Message-related state changed. + /// Messages = 1 << 0, + + /// + /// Decision-related state changed. + /// Decisions = 1 << 1, + + /// + /// Board-related state changed. + /// Board = 1 << 2, + + /// + /// Agent-related state changed. + /// Agents = 1 << 3, } diff --git a/ai-dev.core/Models/ProjectStateChangedEvent.cs b/ai-dev.core/Models/ProjectStateChangedEvent.cs index 13433b3..e066b96 100644 --- a/ai-dev.core/Models/ProjectStateChangedEvent.cs +++ b/ai-dev.core/Models/ProjectStateChangedEvent.cs @@ -1,5 +1,11 @@ namespace AiDev.Models; +/// +/// Represents a project state change notification. +/// +/// The project whose state changed. +/// The kind of state that changed. +/// The UTC timestamp when the change occurred. public sealed record ProjectStateChangedEvent( ProjectSlug ProjectSlug, ProjectStateChangeKind Kind, diff --git a/ai-dev.core/Models/ProjectStateSnapshot.cs b/ai-dev.core/Models/ProjectStateSnapshot.cs index e97163a..847def3 100644 --- a/ai-dev.core/Models/ProjectStateSnapshot.cs +++ b/ai-dev.core/Models/ProjectStateSnapshot.cs @@ -1,5 +1,14 @@ namespace AiDev.Models; +/// +/// Represents a summarized snapshot of project activity state. +/// +/// The project represented by the snapshot. +/// The number of unread messages. +/// The number of pending decisions. +/// The number of open board tasks. +/// The number of currently running agents. +/// The number of agents with pending inbox items. public sealed record ProjectStateSnapshot( ProjectSlug ProjectSlug, int UnreadMessageCount, diff --git a/ai-dev.core/Models/Result.cs b/ai-dev.core/Models/Result.cs index 1e5dd89..c5a2e4f 100644 --- a/ai-dev.core/Models/Result.cs +++ b/ai-dev.core/Models/Result.cs @@ -1,20 +1,56 @@ namespace AiDev.Models; +/// +/// Represents the result of an operation that can either succeed or fail. +/// +/// The success value type. public abstract record Result; +/// +/// Represents a successful result. +/// +/// The success value type. +/// The successful value. public sealed record Ok(T Value) : Result; +/// +/// Represents a failed result. +/// +/// The success value type. +/// The failure details. public sealed record Err(DomainError Error) : Result; +/// +/// Represents a domain-level error. +/// +/// The machine-readable error code. +/// The human-readable error message. public sealed record DomainError(string Code, string Message); +/// +/// Represents the absence of a meaningful value. +/// public readonly record struct Unit { + /// + /// Gets the singleton unit value. + /// public static readonly Unit Value = new(); } +/// +/// Provides helpers for composing and projecting values. +/// public static class ResultExtensions { + /// + /// Chains an asynchronous result into another asynchronous result. + /// + /// The input success type. + /// The output success type. + /// The source asynchronous result. + /// The continuation to invoke when the source succeeds. + /// The composed asynchronous result. public static async Task> Then( this Task> resultTask, Func>> next) @@ -29,6 +65,14 @@ public static async Task> Then( }; } + /// + /// Chains a synchronous result into another synchronous result. + /// + /// The input success type. + /// The output success type. + /// The source result. + /// The continuation to invoke when the source succeeds. + /// The composed result. public static Result Then( this Result result, Func> next) @@ -39,6 +83,14 @@ public static Result Then( _ => throw new UnreachableException(), }; + /// + /// Chains a synchronous result into an asynchronous result. + /// + /// The input success type. + /// The output success type. + /// The source result. + /// The asynchronous continuation to invoke when the source succeeds. + /// The composed asynchronous result. public static Task> Then( this Result result, Func>> next) @@ -49,6 +101,15 @@ public static Task> Then( _ => throw new UnreachableException(), }; + /// + /// Projects a result into a single output value. + /// + /// The input success type. + /// The projected output type. + /// The result to project. + /// The projection for a successful result. + /// The projection for a failed result. + /// The projected value. public static T Match( this Result result, Func onOk, @@ -60,6 +121,12 @@ public static T Match( _ => throw new UnreachableException(), }; + /// + /// Extracts the error message from a failed result. + /// + /// The success value type. + /// The result to inspect. + /// The error message for a failed result; otherwise, . public static string? ToErrorMessage(this Result result) => result.Match(_ => (string?)null, err => err.Message); } diff --git a/ai-dev.core/Models/SessionResult.cs b/ai-dev.core/Models/SessionResult.cs index 01fc779..e3cf63d 100644 --- a/ai-dev.core/Models/SessionResult.cs +++ b/ai-dev.core/Models/SessionResult.cs @@ -7,10 +7,11 @@ namespace AiDev.Models; /// public record SessionResult( string? TaskId, - string? Status, // completed | failed | partial + [property: JsonPropertyName("status")] SessionStatus? SessionStatus, string? Summary, string? PullRequestUrl, IReadOnlyList FilesChanged, - string? TestOutcome, // passed | failed | skipped | null + TestOutcome? TestOutcome, DateTime? CompletedAt, IReadOnlyList? Tags = null); + diff --git a/ai-dev.core/Models/StudioSettings.cs b/ai-dev.core/Models/StudioSettings.cs index 0c2ee3e..25726ab 100644 --- a/ai-dev.core/Models/StudioSettings.cs +++ b/ai-dev.core/Models/StudioSettings.cs @@ -1,7 +1,13 @@ namespace AiDev.Models; +/// +/// Represents persisted application settings for executor and model configuration. +/// public class StudioSettings { + /// + /// Gets or sets legacy model aliases retained only for migration. + /// public Dictionary Models { get; set; } = new(); // Legacy — kept for migration only; not used for model resolution. /// Base URL for the Ollama HTTP API. Defaults to http://localhost:11434. diff --git a/ai-dev.core/Models/TaskAssigned.cs b/ai-dev.core/Models/TaskAssigned.cs new file mode 100644 index 0000000..0cd700a --- /dev/null +++ b/ai-dev.core/Models/TaskAssigned.cs @@ -0,0 +1,20 @@ +namespace AiDev.Models; + +/// +/// Raised when a task is assigned to an agent. +/// +/// The project that owns the task. +/// The assigned task identifier. +/// The agent assigned to the task. +/// The task title. +/// The optional task description. +/// The task priority. +/// The UTC timestamp when the assignment occurred. +public sealed record TaskAssigned( + ProjectSlug ProjectSlug, + TaskId TaskId, + AgentSlug Assignee, + string Title, + string? Description, + Priority Priority, + DateTime OccurredAt) : DomainEvent(OccurredAt); diff --git a/ai-dev.core/Models/Types/AgentExecutorName.cs b/ai-dev.core/Models/Types/AgentExecutorName.cs index 444f152..fcd929e 100644 --- a/ai-dev.core/Models/Types/AgentExecutorName.cs +++ b/ai-dev.core/Models/Types/AgentExecutorName.cs @@ -6,11 +6,34 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(AgentExecutorNameJsonConverter))] public sealed record AgentExecutorName { + /// + /// The persisted value for Claude CLI. + /// public const string ClaudeValue = "claude"; + + /// + /// The persisted value for the Anthropic API executor. + /// public const string AnthropicValue = "anthropic"; + + /// + /// The persisted value for Ollama. + /// public const string OllamaValue = "ollama"; + + /// + /// The persisted value for GitHub Models. + /// public const string GitHubModelsValue = "github-models"; + + /// + /// The persisted value for LM Studio. + /// public const string LmStudioValue = "lmstudio"; + + /// + /// The persisted value for Copilot CLI. + /// public const string CopilotCliValue = "copilot-cli"; private AgentExecutorName(string value, string displayName) @@ -19,19 +42,62 @@ private AgentExecutorName(string value, string displayName) DisplayName = displayName; } + /// + /// Gets the persisted executor value. + /// public string Value { get; } + + /// + /// Gets the user-facing executor display name. + /// public string DisplayName { get; } + /// + /// Gets the Claude CLI executor. + /// public static AgentExecutorName Claude { get; } = new(ClaudeValue, "Claude CLI"); + + /// + /// Gets the Anthropic API executor. + /// public static AgentExecutorName Anthropic { get; } = new(AnthropicValue, "Anthropic API"); + + /// + /// Gets the Ollama executor. + /// public static AgentExecutorName Ollama { get; } = new(OllamaValue, "Ollama"); + + /// + /// Gets the GitHub Models executor. + /// public static AgentExecutorName GitHubModels { get; } = new(GitHubModelsValue, "GitHub Models"); + + /// + /// Gets the LM Studio executor. + /// public static AgentExecutorName LmStudio { get; } = new(LmStudioValue, "LM Studio"); + + /// + /// Gets the Copilot CLI executor. + /// public static AgentExecutorName CopilotCli { get; } = new(CopilotCliValue, "Copilot CLI"); + /// + /// Gets the default executor. + /// public static AgentExecutorName Default => Claude; + + /// + /// Gets the supported executor set. + /// public static IReadOnlyList Supported { get; } = [Claude, Anthropic, Ollama, GitHubModels, LmStudio, CopilotCli]; + /// + /// Attempts to parse a persisted executor value. + /// + /// The raw executor value. + /// The parsed executor when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse(string? value, [NotNullWhen(true)] out AgentExecutorName? executor) { executor = value switch @@ -48,19 +114,11 @@ public static bool TryParse(string? value, [NotNullWhen(true)] out AgentExecutor return executor is not null; } + /// + /// Converts the executor to its persisted string value. + /// + /// The executor to convert. public static implicit operator string(AgentExecutorName executor) => executor.Value; public override string ToString() => Value; } - -internal sealed class AgentExecutorNameJsonConverter : JsonConverter -{ - public override AgentExecutorName? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - return AgentExecutorName.TryParse(value, out var executor) ? executor : null; - } - - public override void Write(Utf8JsonWriter writer, AgentExecutorName value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/AgentExecutorNameJsonConverter.cs b/ai-dev.core/Models/Types/AgentExecutorNameJsonConverter.cs new file mode 100644 index 0000000..25cc70e --- /dev/null +++ b/ai-dev.core/Models/Types/AgentExecutorNameJsonConverter.cs @@ -0,0 +1,13 @@ +namespace AiDev.Models.Types; + +internal sealed class AgentExecutorNameJsonConverter : JsonConverter +{ + public override AgentExecutorName? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return AgentExecutorName.TryParse(value, out var executor) ? executor : null; + } + + public override void Write(Utf8JsonWriter writer, AgentExecutorName value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/AgentSlug.cs b/ai-dev.core/Models/Types/AgentSlug.cs index 55f8413..bf01a35 100644 --- a/ai-dev.core/Models/Types/AgentSlug.cs +++ b/ai-dev.core/Models/Types/AgentSlug.cs @@ -7,6 +7,9 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(AgentSlugJsonConverter))] public sealed partial record AgentSlug : IParsable { + /// + /// Gets the validated slug value. + /// public string Value { get; } public AgentSlug(string value) @@ -18,6 +21,12 @@ public AgentSlug(string value) Value = value; } + /// + /// Attempts to parse a validated agent slug. + /// + /// The raw slug value. + /// The parsed slug when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse(string? value, [NotNullWhen(true)] out AgentSlug? slug) { if (!IsValid(value)) { slug = null; return false; } @@ -45,15 +54,3 @@ private static bool IsValid([NotNullWhen(true)] string? value) => [GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")] private static partial Regex SlugPattern(); } - -internal sealed class AgentSlugJsonConverter : JsonConverter -{ - public override AgentSlug? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - return AgentSlug.TryParse(value, out var slug) ? slug : null; - } - - public override void Write(Utf8JsonWriter writer, AgentSlug value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/AgentSlugJsonConverter.cs b/ai-dev.core/Models/Types/AgentSlugJsonConverter.cs new file mode 100644 index 0000000..9e5efe2 --- /dev/null +++ b/ai-dev.core/Models/Types/AgentSlugJsonConverter.cs @@ -0,0 +1,13 @@ +namespace AiDev.Models.Types; + +internal sealed class AgentSlugJsonConverter : JsonConverter +{ + public override AgentSlug? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return AgentSlug.TryParse(value, out var slug) ? slug : null; + } + + public override void Write(Utf8JsonWriter writer, AgentSlug value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/AgentStatus.cs b/ai-dev.core/Models/Types/AgentStatus.cs index 6f87406..7a6f9a9 100644 --- a/ai-dev.core/Models/Types/AgentStatus.cs +++ b/ai-dev.core/Models/Types/AgentStatus.cs @@ -1,15 +1,37 @@ namespace AiDev.Models.Types; +/// +/// Represents the current execution status of an agent. +/// public readonly record struct AgentStatus { + /// + /// Represents an idle agent. + /// public static readonly AgentStatus Idle = new("idle"); + + /// + /// Represents a running agent. + /// public static readonly AgentStatus Running = new("running"); + + /// + /// Represents an agent in an error state. + /// public static readonly AgentStatus Error = new("error"); + /// + /// Gets the persisted status value. + /// public string Value { get; } private AgentStatus(string value) => Value = value; + /// + /// Creates an from a raw persisted value. + /// + /// The raw status value. + /// The parsed status, defaulting to . public static AgentStatus From(string? value) => value?.ToLowerInvariant() switch { "running" => Running, @@ -17,10 +39,24 @@ public readonly record struct AgentStatus _ => Idle, }; + /// + /// Gets a value indicating whether the status is idle. + /// public bool IsIdle => this == Idle; + + /// + /// Gets a value indicating whether the status is running. + /// public bool IsRunning => this == Running; + + /// + /// Gets a value indicating whether the status is error. + /// public bool IsError => this == Error; + /// + /// Gets the display name for the status. + /// public string DisplayName => Value switch { "running" => "Running", @@ -28,6 +64,9 @@ public readonly record struct AgentStatus _ => "Idle", }; + /// + /// Gets the color associated with the status. + /// public string ColorHex => Value switch { "running" => "#22C55E", @@ -35,6 +74,9 @@ public readonly record struct AgentStatus _ => "#6B7280", }; + /// + /// Gets the UI badge CSS classes for the status. + /// public (string DotClass, string TextClass) BadgeClasses => Value switch { "running" => ("bg-emerald-400 animate-pulse", "text-emerald-400"), diff --git a/ai-dev.core/Models/Types/ColumnId.cs b/ai-dev.core/Models/Types/ColumnId.cs index 7faf5d2..e1368d9 100644 --- a/ai-dev.core/Models/Types/ColumnId.cs +++ b/ai-dev.core/Models/Types/ColumnId.cs @@ -1,13 +1,34 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(ColumnIdJsonConverter))] +/// +/// Represents a validated board column identifier. +/// public sealed partial record ColumnId : IParsable { + /// + /// The backlog column identifier. + /// public static readonly ColumnId Backlog = new("backlog"); + + /// + /// The in-progress column identifier. + /// public static readonly ColumnId InProgress = new("in-progress"); + + /// + /// The review column identifier. + /// public static readonly ColumnId Review = new("review"); + + /// + /// The done column identifier. + /// public static readonly ColumnId Done = new("done"); + /// + /// Gets the validated column identifier value. + /// public string Value { get; } public ColumnId(string value) @@ -19,6 +40,11 @@ public ColumnId(string value) Value = value; } + /// + /// Creates a from a raw persisted value. + /// + /// The raw column identifier value. + /// The parsed column identifier. public static ColumnId From(string? value) => value?.ToLowerInvariant() switch { @@ -30,6 +56,12 @@ public static ColumnId From(string? value) _ => throw new ArgumentException("Column id is required.", nameof(value)), }; + /// + /// Attempts to parse a validated column identifier. + /// + /// The raw column identifier value. + /// The parsed identifier when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse(string? value, [NotNullWhen(true)] out ColumnId? columnId) { if (!IsValid(value)) @@ -58,17 +90,3 @@ private static bool IsValid([NotNullWhen(true)] string? value) [GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")] private static partial Regex ColumnIdPattern(); } - -internal sealed class ColumnIdJsonConverter : JsonConverter -{ - public override ColumnId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString() ?? throw new JsonException("Expected string for ColumnId."); - if (!ColumnId.TryParse(value, out var columnId)) - throw new JsonException($"Invalid ColumnId: '{value}'."); - return columnId; - } - - public override void Write(Utf8JsonWriter writer, ColumnId value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/ColumnIdJsonConverter.cs b/ai-dev.core/Models/Types/ColumnIdJsonConverter.cs new file mode 100644 index 0000000..4823975 --- /dev/null +++ b/ai-dev.core/Models/Types/ColumnIdJsonConverter.cs @@ -0,0 +1,15 @@ +namespace AiDev.Models.Types; + +internal sealed class ColumnIdJsonConverter : JsonConverter +{ + public override ColumnId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString() ?? throw new JsonException("Expected string for ColumnId."); + if (!ColumnId.TryParse(value, out var columnId)) + throw new JsonException($"Invalid ColumnId: '{value}'."); + return columnId; + } + + public override void Write(Utf8JsonWriter writer, ColumnId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/DecisionId.cs b/ai-dev.core/Models/Types/DecisionId.cs index 97ac072..12531d3 100644 --- a/ai-dev.core/Models/Types/DecisionId.cs +++ b/ai-dev.core/Models/Types/DecisionId.cs @@ -7,6 +7,9 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(DecisionIdJsonConverter))] public sealed record DecisionId { + /// + /// Gets the validated decision identifier value. + /// public string Value { get; } public DecisionId(string value) @@ -16,6 +19,12 @@ public DecisionId(string value) Value = value.Trim(); } + /// + /// Attempts to parse a decision identifier. + /// + /// The raw identifier value. + /// The parsed identifier when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true)] out DecisionId? id) { if (string.IsNullOrWhiteSpace(value)) { id = null; return false; } @@ -23,20 +32,8 @@ public static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true return true; } - public static implicit operator string(DecisionId id) => id.Value; + public static implicit operator string(DecisionId id) => id?.Value ?? string.Empty; public static implicit operator DecisionId(string value) => new(value); public override string ToString() => Value; } - -internal sealed class DecisionIdJsonConverter : JsonConverter -{ - public override DecisionId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var s = reader.GetString() ?? throw new JsonException("Expected string for DecisionId."); - return new DecisionId(s); - } - - public override void Write(Utf8JsonWriter writer, DecisionId value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/DecisionIdJsonConverter.cs b/ai-dev.core/Models/Types/DecisionIdJsonConverter.cs new file mode 100644 index 0000000..e5b1bc5 --- /dev/null +++ b/ai-dev.core/Models/Types/DecisionIdJsonConverter.cs @@ -0,0 +1,13 @@ +namespace AiDev.Models.Types; + +internal sealed class DecisionIdJsonConverter : JsonConverter +{ + public override DecisionId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString() ?? throw new JsonException("Expected string for DecisionId."); + return new DecisionId(s); + } + + public override void Write(Utf8JsonWriter writer, DecisionId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/DecisionStatus.cs b/ai-dev.core/Models/Types/DecisionStatus.cs index ef8ec7b..d5c2dd0 100644 --- a/ai-dev.core/Models/Types/DecisionStatus.cs +++ b/ai-dev.core/Models/Types/DecisionStatus.cs @@ -1,32 +1,48 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(DecisionStatusJsonConverter))] +/// +/// Represents the lifecycle status of a decision. +/// public readonly record struct DecisionStatus { + /// + /// Represents a pending decision. + /// public static readonly DecisionStatus Pending = new("pending"); + + /// + /// Represents a resolved decision. + /// public static readonly DecisionStatus Resolved = new("resolved"); + /// + /// Gets the persisted status value. + /// public string Value { get; } private DecisionStatus(string value) => Value = value; + /// + /// Creates a from a raw persisted value. + /// + /// The raw status value. + /// The parsed status, defaulting to . public static DecisionStatus From(string? value) => value?.ToLowerInvariant() switch { "resolved" => Resolved, _ => Pending, }; + /// + /// Gets a value indicating whether the decision is pending. + /// public bool IsPending => this == Pending; + + /// + /// Gets a value indicating whether the decision is resolved. + /// public bool IsResolved => this == Resolved; public override string ToString() => Value; } - -internal sealed class DecisionStatusJsonConverter : JsonConverter -{ - public override DecisionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => DecisionStatus.From(reader.GetString()); - - public override void Write(Utf8JsonWriter writer, DecisionStatus value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/DecisionStatusJsonConverter.cs b/ai-dev.core/Models/Types/DecisionStatusJsonConverter.cs new file mode 100644 index 0000000..7b65e4e --- /dev/null +++ b/ai-dev.core/Models/Types/DecisionStatusJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class DecisionStatusJsonConverter : JsonConverter +{ + public override DecisionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DecisionStatus.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, DecisionStatus value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/MessageSource.cs b/ai-dev.core/Models/Types/MessageSource.cs index aac404d..8405208 100644 --- a/ai-dev.core/Models/Types/MessageSource.cs +++ b/ai-dev.core/Models/Types/MessageSource.cs @@ -6,12 +6,30 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(MessageSourceJsonConverter))] public readonly record struct MessageSource { - public static readonly MessageSource Board = new("board"); - public static readonly MessageSource Human = new("human"); + /// + /// Represents a message originating from the board. + /// + public static readonly MessageSource Board = new("board"); + + /// + /// Represents a message originating from a human. + /// + public static readonly MessageSource Human = new("human"); + + /// + /// Represents a message originating from overwatch. + /// public static readonly MessageSource Overwatch = new("overwatch"); + /// + /// Gets the persisted message source value. + /// public string Value { get; } + /// + /// Initializes a message source from a validated value. + /// + /// The message source value. public MessageSource(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -19,24 +37,39 @@ public MessageSource(string value) Value = value.Trim(); } + /// + /// Creates a from a raw persisted value. + /// + /// The raw message source value. + /// The parsed message source, defaulting to . public static MessageSource From(string? value) => string.IsNullOrWhiteSpace(value) ? Board : new(value); - public bool IsBoard => Value == "board"; - public bool IsHuman => Value == "human"; + /// + /// Gets a value indicating whether the source is board. + /// + public bool IsBoard => Value == "board"; + + /// + /// Gets a value indicating whether the source is human. + /// + public bool IsHuman => Value == "human"; + + /// + /// Gets a value indicating whether the source is overwatch. + /// public bool IsOverwatch => Value == "overwatch"; + /// + /// Converts the message source to its persisted string value. + /// + /// The message source to convert. public static implicit operator string(MessageSource s) => s.Value; + /// + /// Converts a raw value to a message source. + /// + /// The raw message source value. public static implicit operator MessageSource(string value) => new(value); public override string ToString() => Value; } - -internal sealed class MessageSourceJsonConverter : JsonConverter -{ - public override MessageSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => MessageSource.From(reader.GetString()); - - public override void Write(Utf8JsonWriter writer, MessageSource value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/MessageSourceJsonConverter.cs b/ai-dev.core/Models/Types/MessageSourceJsonConverter.cs new file mode 100644 index 0000000..62fc4e9 --- /dev/null +++ b/ai-dev.core/Models/Types/MessageSourceJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class MessageSourceJsonConverter : JsonConverter +{ + public override MessageSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => MessageSource.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, MessageSource value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/MessageType.cs b/ai-dev.core/Models/Types/MessageType.cs index db8b6d1..5bf5962 100644 --- a/ai-dev.core/Models/Types/MessageType.cs +++ b/ai-dev.core/Models/Types/MessageType.cs @@ -6,11 +6,29 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(MessageTypeJsonConverter))] public readonly record struct MessageType { - public static readonly MessageType TaskAssigned = new("task-assigned"); - public static readonly MessageType DecisionChat = new("decision-chat"); - public static readonly MessageType DecisionReply = new("decision-reply"); - public static readonly MessageType OverwatchNudge = new("overwatch-nudge"); - + /// + /// Represents a task assignment message. + /// + public static readonly MessageType TaskAssigned = new("task-assigned"); + + /// + /// Represents a decision chat message. + /// + public static readonly MessageType DecisionChat = new("decision-chat"); + + /// + /// Represents a decision reply message. + /// + public static readonly MessageType DecisionReply = new("decision-reply"); + + /// + /// Represents an overwatch nudge message. + /// + public static readonly MessageType OverwatchNudge = new("overwatch-nudge"); + + /// + /// Gets the persisted message type value. + /// public string Value { get; } public MessageType(string value) @@ -20,6 +38,11 @@ public MessageType(string value) Value = value.Trim().ToLowerInvariant(); } + /// + /// Creates a from a raw persisted value. + /// + /// The raw message type value. + /// The parsed message type, defaulting to unknown. public static MessageType From(string? value) => string.IsNullOrWhiteSpace(value) ? new("unknown") : new(value); @@ -28,12 +51,3 @@ public static MessageType From(string? value) => public override string ToString() => Value; } - -internal sealed class MessageTypeJsonConverter : JsonConverter -{ - public override MessageType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => MessageType.From(reader.GetString()); - - public override void Write(Utf8JsonWriter writer, MessageType value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/MessageTypeJsonConverter.cs b/ai-dev.core/Models/Types/MessageTypeJsonConverter.cs new file mode 100644 index 0000000..812a167 --- /dev/null +++ b/ai-dev.core/Models/Types/MessageTypeJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class MessageTypeJsonConverter : JsonConverter +{ + public override MessageType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => MessageType.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, MessageType value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/Priority.cs b/ai-dev.core/Models/Types/Priority.cs index 18661a8..0798001 100644 --- a/ai-dev.core/Models/Types/Priority.cs +++ b/ai-dev.core/Models/Types/Priority.cs @@ -1,17 +1,43 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(PriorityJsonConverter))] +/// +/// Represents the priority assigned to a task, message, or decision. +/// public readonly record struct Priority { + /// + /// Represents low priority. + /// public static readonly Priority Low = new("low"); + + /// + /// Represents normal priority. + /// public static readonly Priority Normal = new("normal"); + + /// + /// Represents high priority. + /// public static readonly Priority High = new("high"); + + /// + /// Represents critical priority. + /// public static readonly Priority Critical = new("critical"); + /// + /// Gets the persisted priority value. + /// public string Value { get; } private Priority(string value) => Value = value; + /// + /// Creates a from a raw persisted value. + /// + /// The raw priority value. + /// The parsed priority, defaulting to . public static Priority From(string? value) => value?.ToLowerInvariant() switch { "low" => Low, @@ -20,36 +46,52 @@ public readonly record struct Priority _ => Normal, }; + /// + /// Gets a value indicating whether the priority is low. + /// public bool IsLow => this == Low; + + /// + /// Gets a value indicating whether the priority is normal. + /// public bool IsNormal => this == Normal; + + /// + /// Gets a value indicating whether the priority is high. + /// public bool IsHigh => this == High; + + /// + /// Gets a value indicating whether the priority is critical. + /// public bool IsCritical => this == Critical; + + /// + /// Gets a value indicating whether the priority should be treated as urgent. + /// public bool IsUrgent => IsHigh || IsCritical; + /// + /// Gets the display name for the priority. + /// public string DisplayName => Value switch { "critical" => "Critical", - "high" => "High", - "normal" => "Normal", - _ => "Low", + "high" => "High", + "normal" => "Normal", + _ => "Low", }; + /// + /// Gets the color associated with the priority. + /// public string ColorHex => Value switch { "critical" => "#EF4444", - "high" => "#F59E0B", - "normal" => "#3B82F6", - _ => "#6B7280", + "high" => "#F59E0B", + "normal" => "#3B82F6", + _ => "#6B7280", }; public override string ToString() => Value; } - -internal sealed class PriorityJsonConverter : JsonConverter -{ - public override Priority Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => Priority.From(reader.GetString()); - - public override void Write(Utf8JsonWriter writer, Priority value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/PriorityJsonConverter.cs b/ai-dev.core/Models/Types/PriorityJsonConverter.cs new file mode 100644 index 0000000..5f28a13 --- /dev/null +++ b/ai-dev.core/Models/Types/PriorityJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class PriorityJsonConverter : JsonConverter +{ + public override Priority Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Priority.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, Priority value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/ProjectSlug.cs b/ai-dev.core/Models/Types/ProjectSlug.cs index d0dd32f..cf9b5ea 100644 --- a/ai-dev.core/Models/Types/ProjectSlug.cs +++ b/ai-dev.core/Models/Types/ProjectSlug.cs @@ -7,8 +7,15 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(ProjectSlugJsonConverter))] public sealed partial record ProjectSlug : IParsable { + /// + /// Gets the validated slug value. + /// public string Value { get; } + /// + /// Initializes a project slug from a validated value. + /// + /// The project slug value. public ProjectSlug(string value) { if (!IsValid(value)) @@ -18,6 +25,12 @@ public ProjectSlug(string value) Value = value; } + /// + /// Attempts to parse a validated project slug. + /// + /// The raw slug value. + /// The parsed slug when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse(string? value, [NotNullWhen(true)] out ProjectSlug? slug) { if (!IsValid(value)) { slug = null; return false; } @@ -30,7 +43,7 @@ public static bool TryParse(string? value, [NotNullWhen(true)] out ProjectSlug? static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out ProjectSlug result) => TryParse(s, out result); - public static implicit operator string(ProjectSlug slug) => slug.Value; + public static implicit operator string(ProjectSlug slug) => slug?.Value ?? string.Empty; public static implicit operator ProjectSlug(string value) => new(value); public override string ToString() => Value; @@ -45,15 +58,3 @@ private static bool IsValid([NotNullWhen(true)] string? value) => [System.Text.RegularExpressions.GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")] private static partial System.Text.RegularExpressions.Regex SlugPattern(); } - -internal sealed class ProjectSlugJsonConverter : JsonConverter -{ - public override ProjectSlug? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - return ProjectSlug.TryParse(value, out var slug) ? slug : null; - } - - public override void Write(Utf8JsonWriter writer, ProjectSlug value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); -} diff --git a/ai-dev.core/Models/Types/ProjectSlugJsonConverter.cs b/ai-dev.core/Models/Types/ProjectSlugJsonConverter.cs new file mode 100644 index 0000000..4eb3dc6 --- /dev/null +++ b/ai-dev.core/Models/Types/ProjectSlugJsonConverter.cs @@ -0,0 +1,13 @@ +namespace AiDev.Models.Types; + +internal sealed class ProjectSlugJsonConverter : JsonConverter +{ + public override ProjectSlug? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return ProjectSlug.TryParse(value, out var slug) ? slug : null; + } + + public override void Write(Utf8JsonWriter writer, ProjectSlug value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/SessionId.cs b/ai-dev.core/Models/Types/SessionId.cs index da79f29..c3b2712 100644 --- a/ai-dev.core/Models/Types/SessionId.cs +++ b/ai-dev.core/Models/Types/SessionId.cs @@ -6,6 +6,9 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(SessionIdJsonConverter))] public sealed partial record SessionId { + /// + /// Gets the validated session identifier value. + /// public string Value { get; } public SessionId(string value) @@ -20,6 +23,12 @@ public SessionId(string value) /// Generates a new unique session ID. public static SessionId New() => new(Guid.CreateVersion7().ToString("N")); + /// + /// Attempts to parse a validated session identifier. + /// + /// The raw identifier value. + /// The parsed identifier when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true)] out SessionId? id) { if (!IsValid(value)) { id = null; return false; } diff --git a/ai-dev.core/Models/Types/SessionSizeRating.cs b/ai-dev.core/Models/Types/SessionSizeRating.cs new file mode 100644 index 0000000..a3b9741 --- /dev/null +++ b/ai-dev.core/Models/Types/SessionSizeRating.cs @@ -0,0 +1,72 @@ +namespace AiDev.Models.Types; + +/// +/// Relative size estimate for an analyzed session. +/// +[JsonConverter(typeof(SessionSizeRatingJsonConverter))] +public readonly record struct SessionSizeRating +{ + /// + /// Represents a small session. + /// + public static readonly SessionSizeRating Small = new("small"); + + /// + /// Represents a medium session. + /// + public static readonly SessionSizeRating Medium = new("medium"); + + /// + /// Represents a large session. + /// + public static readonly SessionSizeRating Large = new("large"); + + /// + /// Gets the persisted size rating value. + /// + public string Value { get; } + + /// + /// Initializes a session size rating from a validated value. + /// + /// The size rating value. + public SessionSizeRating(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Session size rating cannot be empty.", nameof(value)); + + Value = value.Trim().ToLowerInvariant(); + } + + /// + /// Creates a from a raw persisted value. + /// + /// The raw size rating value. + /// The parsed size rating, defaulting to . + public static SessionSizeRating From(string? value) => value?.Trim().ToLowerInvariant() switch + { + "small" => Small, + "large" => Large, + _ => Medium, + }; + + /// + /// Gets a value indicating whether the rating is small. + /// + public bool IsSmall => this == Small; + + /// + /// Gets a value indicating whether the rating is medium. + /// + public bool IsMedium => this == Medium; + + /// + /// Gets a value indicating whether the rating is large. + /// + public bool IsLarge => this == Large; + + public static implicit operator string(SessionSizeRating sizeRating) => sizeRating.Value; + public static implicit operator SessionSizeRating(string value) => From(value); + + public override string ToString() => Value; +} diff --git a/ai-dev.core/Models/Types/SessionSizeRatingJsonConverter.cs b/ai-dev.core/Models/Types/SessionSizeRatingJsonConverter.cs new file mode 100644 index 0000000..c447d61 --- /dev/null +++ b/ai-dev.core/Models/Types/SessionSizeRatingJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class SessionSizeRatingJsonConverter : JsonConverter +{ + public override SessionSizeRating Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => SessionSizeRating.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, SessionSizeRating value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/SessionStatus.cs b/ai-dev.core/Models/Types/SessionStatus.cs new file mode 100644 index 0000000..bb562c1 --- /dev/null +++ b/ai-dev.core/Models/Types/SessionStatus.cs @@ -0,0 +1,81 @@ +namespace AiDev.Models.Types; + +/// +/// Overall completion status reported by a finished session. +/// +[JsonConverter(typeof(SessionStatusJsonConverter))] +public readonly record struct SessionStatus +{ + /// + /// Represents a completed session. + /// + public static readonly SessionStatus Completed = new("completed"); + + /// + /// Represents a failed session. + /// + public static readonly SessionStatus Failed = new("failed"); + + /// + /// Represents a partially completed session. + /// + public static readonly SessionStatus Partial = new("partial"); + + /// + /// Gets the persisted session status value. + /// + public string Value { get; } + + /// + /// Initializes a session status from a validated value. + /// + /// The session status value. + public SessionStatus(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Session status cannot be empty.", nameof(value)); + + Value = value.Trim().ToLowerInvariant(); + } + + /// + /// Creates a from a raw persisted value. + /// + /// The raw session status value. + /// The parsed session status. + public static SessionStatus From(string? value) => value?.Trim().ToLowerInvariant() switch + { + "completed" => Completed, + "failed" => Failed, + "partial" => Partial, + _ => throw new ArgumentException("Session status must be one of: completed, failed, partial.", nameof(value)), + }; + + /// + /// Gets a value indicating whether the status is completed. + /// + public bool IsCompleted => this == Completed; + + /// + /// Gets a value indicating whether the status is failed. + /// + public bool IsFailed => this == Failed; + + /// + /// Gets a value indicating whether the status is partial. + /// + public bool IsPartial => this == Partial; + + /// + /// Converts the session status to its persisted string value. + /// + /// The session status to convert. + public static implicit operator string(SessionStatus sessionStatus) => sessionStatus.Value; + /// + /// Converts a raw value to a session status. + /// + /// The raw session status value. + public static implicit operator SessionStatus(string value) => From(value); + + public override string ToString() => Value; +} diff --git a/ai-dev.core/Models/Types/SessionStatusJsonConverter.cs b/ai-dev.core/Models/Types/SessionStatusJsonConverter.cs new file mode 100644 index 0000000..f5a4339 --- /dev/null +++ b/ai-dev.core/Models/Types/SessionStatusJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class SessionStatusJsonConverter : JsonConverter +{ + public override SessionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => SessionStatus.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, SessionStatus value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/TaskClassification.cs b/ai-dev.core/Models/Types/TaskClassification.cs new file mode 100644 index 0000000..b1ce427 --- /dev/null +++ b/ai-dev.core/Models/Types/TaskClassification.cs @@ -0,0 +1,103 @@ +namespace AiDev.Models.Types; + +/// +/// Classifies the kind of work represented by an insights session. +/// +[JsonConverter(typeof(TaskClassificationJsonConverter))] +public readonly record struct TaskClassification +{ + /// + /// Represents feature work. + /// + public static readonly TaskClassification Feature = new("feature"); + + /// + /// Represents bug-fix work. + /// + public static readonly TaskClassification Bug = new("bug"); + + /// + /// Represents refactoring work. + /// + public static readonly TaskClassification Refactor = new("refactor"); + + /// + /// Represents investigation work. + /// + public static readonly TaskClassification Investigation = new("investigation"); + + /// + /// Represents uncategorized work. + /// + public static readonly TaskClassification Other = new("other"); + + /// + /// Gets the persisted task classification value. + /// + public string Value { get; } + + /// + /// Initializes a task classification from a validated value. + /// + /// The task classification value. + public TaskClassification(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Task classification cannot be empty.", nameof(value)); + + Value = value.Trim().ToLowerInvariant(); + } + + /// + /// Creates a from a raw persisted value. + /// + /// The raw task classification value. + /// The parsed classification, defaulting to . + public static TaskClassification From(string? value) => value?.Trim().ToLowerInvariant() switch + { + "feature" => Feature, + "bug" => Bug, + "refactor" => Refactor, + "investigation" => Investigation, + "other" => Other, + _ => Other, + }; + + /// + /// Gets a value indicating whether the classification is feature work. + /// + public bool IsFeature => this == Feature; + + /// + /// Gets a value indicating whether the classification is bug work. + /// + public bool IsBug => this == Bug; + + /// + /// Gets a value indicating whether the classification is refactor work. + /// + public bool IsRefactor => this == Refactor; + + /// + /// Gets a value indicating whether the classification is investigation work. + /// + public bool IsInvestigation => this == Investigation; + + /// + /// Gets a value indicating whether the classification is other work. + /// + public bool IsOther => this == Other; + + /// + /// Converts the classification to its persisted string value. + /// + /// The classification to convert. + public static implicit operator string(TaskClassification classification) => classification.Value; + /// + /// Converts a raw value to a task classification. + /// + /// The raw classification value. + public static implicit operator TaskClassification(string value) => From(value); + + public override string ToString() => Value; +} diff --git a/ai-dev.core/Models/Types/TaskClassificationJsonConverter.cs b/ai-dev.core/Models/Types/TaskClassificationJsonConverter.cs new file mode 100644 index 0000000..4cd932d --- /dev/null +++ b/ai-dev.core/Models/Types/TaskClassificationJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class TaskClassificationJsonConverter : JsonConverter +{ + public override TaskClassification Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => TaskClassification.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, TaskClassification value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/Types/TaskId.cs b/ai-dev.core/Models/Types/TaskId.cs index 6f2710d..2d6b0b3 100644 --- a/ai-dev.core/Models/Types/TaskId.cs +++ b/ai-dev.core/Models/Types/TaskId.cs @@ -6,6 +6,9 @@ namespace AiDev.Models.Types; [JsonConverter(typeof(TaskIdJsonConverter))] public sealed partial record TaskId : IParsable { + /// + /// Gets the validated task identifier value. + /// public string Value { get; } public TaskId(string value) @@ -21,6 +24,12 @@ public TaskId(string value) public static TaskId New() => new($"task-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{Guid.NewGuid().ToString("N")[..5]}"); + /// + /// Attempts to parse a validated task identifier. + /// + /// The raw identifier value. + /// The parsed identifier when successful. + /// when parsing succeeds; otherwise, . public static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true)] out TaskId? id) { if (!IsValid(value)) { id = null; return false; } @@ -33,7 +42,7 @@ public static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true static bool IParsable.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TaskId result) => TryParse(s, out result); - public static implicit operator string(TaskId id) => id.Value; + public static implicit operator string(TaskId id) => id?.Value ?? string.Empty; public static implicit operator TaskId(string value) => new(value); public override string ToString() => Value; @@ -44,23 +53,3 @@ private static bool IsValid([NotNullWhen(true)] string? value) => [System.Text.RegularExpressions.GeneratedRegex(@"^task-\d+-[a-f0-9]{5}$")] private static partial System.Text.RegularExpressions.Regex TaskIdPattern(); } - -internal sealed class TaskIdJsonConverter : JsonConverter -{ - public override TaskId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var s = reader.GetString() ?? throw new JsonException("Expected string for TaskId."); - if (!TaskId.TryParse(s, out var id)) throw new JsonException($"Invalid TaskId: '{s}'."); - return id; - } - - public override void Write(Utf8JsonWriter writer, TaskId value, JsonSerializerOptions options) - => writer.WriteStringValue(value.Value); - - // Required for Dictionary key serialisation - public override TaskId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => Read(ref reader, typeToConvert, options); - - public override void WriteAsPropertyName(Utf8JsonWriter writer, TaskId value, JsonSerializerOptions options) - => writer.WritePropertyName(value.Value); -} diff --git a/ai-dev.core/Models/Types/TaskIdJsonConverter.cs b/ai-dev.core/Models/Types/TaskIdJsonConverter.cs new file mode 100644 index 0000000..377e65b --- /dev/null +++ b/ai-dev.core/Models/Types/TaskIdJsonConverter.cs @@ -0,0 +1,21 @@ +namespace AiDev.Models.Types; + +internal sealed class TaskIdJsonConverter : JsonConverter +{ + public override TaskId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString() ?? throw new JsonException("Expected string for TaskId."); + if (!TaskId.TryParse(s, out var id)) throw new JsonException($"Invalid TaskId: '{s}'."); + return id; + } + + public override void Write(Utf8JsonWriter writer, TaskId value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); + + // Required for Dictionary key serialisation + public override TaskId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Read(ref reader, typeToConvert, options); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, TaskId value, JsonSerializerOptions options) + => writer.WritePropertyName(value.Value); +} diff --git a/ai-dev.core/Models/Types/TestOutcome.cs b/ai-dev.core/Models/Types/TestOutcome.cs new file mode 100644 index 0000000..8f3cfe6 --- /dev/null +++ b/ai-dev.core/Models/Types/TestOutcome.cs @@ -0,0 +1,81 @@ +namespace AiDev.Models.Types; + +/// +/// Represents the test execution result reported by a completed session. +/// +[JsonConverter(typeof(TestOutcomeJsonConverter))] +public readonly record struct TestOutcome +{ + /// + /// Represents a passed test outcome. + /// + public static readonly TestOutcome Passed = new("passed"); + + /// + /// Represents a failed test outcome. + /// + public static readonly TestOutcome Failed = new("failed"); + + /// + /// Represents a skipped test outcome. + /// + public static readonly TestOutcome Skipped = new("skipped"); + + /// + /// Gets the persisted test outcome value. + /// + public string Value { get; } + + /// + /// Initializes a test outcome from a validated value. + /// + /// The test outcome value. + public TestOutcome(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Test outcome cannot be empty.", nameof(value)); + + Value = value.Trim().ToLowerInvariant(); + } + + /// + /// Creates a from a raw persisted value. + /// + /// The raw test outcome value. + /// The parsed test outcome. + public static TestOutcome From(string? value) => value?.Trim().ToLowerInvariant() switch + { + "passed" => Passed, + "failed" => Failed, + "skipped" => Skipped, + _ => throw new ArgumentException("Test outcome must be one of: passed, failed, skipped.", nameof(value)), + }; + + /// + /// Gets a value indicating whether the outcome is passed. + /// + public bool IsPassed => this == Passed; + + /// + /// Gets a value indicating whether the outcome is failed. + /// + public bool IsFailed => this == Failed; + + /// + /// Gets a value indicating whether the outcome is skipped. + /// + public bool IsSkipped => this == Skipped; + + /// + /// Converts the test outcome to its persisted string value. + /// + /// The test outcome to convert. + public static implicit operator string(TestOutcome testOutcome) => testOutcome.Value; + /// + /// Converts a raw value to a test outcome. + /// + /// The raw test outcome value. + public static implicit operator TestOutcome(string value) => From(value); + + public override string ToString() => Value; +} diff --git a/ai-dev.core/Models/Types/TestOutcomeJsonConverter.cs b/ai-dev.core/Models/Types/TestOutcomeJsonConverter.cs new file mode 100644 index 0000000..860a9c5 --- /dev/null +++ b/ai-dev.core/Models/Types/TestOutcomeJsonConverter.cs @@ -0,0 +1,10 @@ +namespace AiDev.Models.Types; + +internal sealed class TestOutcomeJsonConverter : JsonConverter +{ + public override TestOutcome Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => TestOutcome.From(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, TestOutcome value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/ai-dev.core/Models/WorkspaceConsistencyReport.cs b/ai-dev.core/Models/WorkspaceConsistencyReport.cs new file mode 100644 index 0000000..bd946b3 --- /dev/null +++ b/ai-dev.core/Models/WorkspaceConsistencyReport.cs @@ -0,0 +1,22 @@ +namespace AiDev.Models; + +/// +/// Represents consistency findings aggregated across workspace projects. +/// +public sealed class WorkspaceConsistencyReport(IReadOnlyList projects) +{ + /// + /// Gets the project consistency reports included in the workspace report. + /// + public IReadOnlyList Projects { get; } = projects; + + /// + /// Gets the total number of error findings across all projects. + /// + public int ErrorCount => Projects.Sum(p => p.Findings.Count(f => f.Severity == ConsistencySeverity.Error)); + + /// + /// Gets the total number of warning findings across all projects. + /// + public int WarningCount => Projects.Sum(p => p.Findings.Count(f => f.Severity == ConsistencySeverity.Warning)); +} diff --git a/ai-dev.core/Services/ConsistencyCheckHostedService.cs b/ai-dev.core/Services/ConsistencyCheckHostedService.cs index b6f003b..695f41b 100644 --- a/ai-dev.core/Services/ConsistencyCheckHostedService.cs +++ b/ai-dev.core/Services/ConsistencyCheckHostedService.cs @@ -3,7 +3,7 @@ namespace AiDev.Services; /// /// Runs consistency checks during startup and emits results to logs and telemetry. /// -public class ConsistencyCheckHostedService( +public partial class ConsistencyCheckHostedService( ConsistencyCheckService consistencyCheckService, ILogger logger) : IHostedService { @@ -21,14 +21,19 @@ public async Task StartAsync(CancellationToken cancellationToken) activity?.SetTag("consistency.projects", report.Projects.Count); activity?.SetTag("consistency.warnings", report.WarningCount); activity?.SetTag("consistency.errors", report.ErrorCount); - logger.LogInformation("[consistency] Startup scan complete: {Projects} projects, {Warnings} warnings, {Errors} errors", - report.Projects.Count, report.WarningCount, report.ErrorCount); + LogStartupScanComplete(report.Projects.Count, report.WarningCount, report.ErrorCount); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - logger.LogWarning("[consistency] Startup scan timed out after {TimeoutSeconds}s", StartupTimeout.TotalSeconds); + LogStartupScanTimedOut(StartupTimeout.TotalSeconds); } } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage(Level = LogLevel.Information, Message = "[consistency] Startup scan complete: {Projects} projects, {Warnings} warnings, {Errors} errors")] + private partial void LogStartupScanComplete(int projects, int warnings, int errors); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[consistency] Startup scan timed out after {TimeoutSeconds}s")] + private partial void LogStartupScanTimedOut(double timeoutSeconds); } diff --git a/ai-dev.core/Services/DispatcherService.cs b/ai-dev.core/Services/DispatcherService.cs index 82c9df3..c06b51c 100644 --- a/ai-dev.core/Services/DispatcherService.cs +++ b/ai-dev.core/Services/DispatcherService.cs @@ -12,7 +12,7 @@ namespace AiDev.Services; /// 2. Periodic poll (every 10 s) — catches anything FSW missed due to buffer overflow, /// OS error, or race conditions. LaunchAgent is idempotent so double-firing is safe. /// -public class DispatcherService( +public partial class DispatcherService( WorkspacePaths paths, WorkspaceService workspace, IAgentRunnerService runner, @@ -38,7 +38,7 @@ public class DispatcherService( public async Task StartAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - logger.LogInformation("[dispatcher] Starting"); + LogStarting(); var projects = workspace.ListProjects(); foreach (var project in projects) @@ -56,12 +56,12 @@ public async Task StartAsync(CancellationToken cancellationToken) _pollTimer = new Timer(_ => PollAllProjects(CancellationToken.None), null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); - logger.LogInformation("[dispatcher] Watching {Count} project(s) with FSW + 10 s poll", projects.Count); + LogWatchingProjects(projects.Count); } public Task StopAsync(CancellationToken cancellationToken) { - logger.LogInformation("[dispatcher] Stopping"); + LogStopping(); Dispose(); return Task.CompletedTask; } @@ -124,14 +124,12 @@ private void WatchDecisionsDir(ProjectSlug projectSlug, string decisionsDir) }; w.Created += (_, e) => { - logger.LogInformation("[dispatcher] New decision in {Project}: {File}", - projectSlug, Path.GetFileName(e.FullPath)); + LogNewDecision(projectSlug, Path.GetFileName(e.FullPath)); projectStateNotifier.Notify(projectSlug, ProjectStateChangeKind.Decisions); }; w.Deleted += (_, e) => { - logger.LogInformation("[dispatcher] Decision resolved in {Project}: {File}", - projectSlug, Path.GetFileName(e.FullPath)); + LogDecisionResolved(projectSlug, Path.GetFileName(e.FullPath)); projectStateNotifier.Notify(projectSlug, ProjectStateChangeKind.Decisions); }; _watchers.Add(w); @@ -158,14 +156,12 @@ private void WatchAgentInbox(ProjectSlug projectSlug, AgentSlug agentSlug) w.Error += (_, e) => OnWatcherError(w, projectSlug, agentSlug, e.GetException()); _watchers.Add(w); - logger.LogInformation("[dispatcher] Watching inbox: {InboxDir}", inboxDir); + LogWatchingInbox(inboxDir); } private void OnWatcherError(FileSystemWatcher w, ProjectSlug projectSlug, AgentSlug agentSlug, Exception ex) { - logger.LogError(ex, - "[dispatcher] FSW error for {Project}/{Agent} — restarting watcher and scanning inbox", - projectSlug, agentSlug); + LogFswError(ex, projectSlug, agentSlug); // Re-enable the watcher so it resumes raising events try @@ -175,8 +171,7 @@ private void OnWatcherError(FileSystemWatcher w, ProjectSlug projectSlug, AgentS } catch (Exception restartEx) { - logger.LogError(restartEx, "[dispatcher] Failed to restart FSW for {Project}/{Agent}", - projectSlug, agentSlug); + LogFswRestartFailed(restartEx, projectSlug, agentSlug); } // Scan immediately to catch anything missed while the watcher was in error state @@ -194,7 +189,7 @@ private void WatchForNewAgents(ProjectSlug projectSlug, string agentsDir) w.Created += (_, e) => { if (!AgentSlug.TryParse(Path.GetFileName(e.FullPath), out var agentSlug)) return; - logger.LogInformation("[dispatcher] New agent detected: {Project}/{Agent}", projectSlug, agentSlug); + LogNewAgentDetected(projectSlug, agentSlug); // Brief delay so the agent folder structure is fully written before watching _ = Task.Run(async () => { @@ -205,8 +200,7 @@ private void WatchForNewAgents(ProjectSlug projectSlug, string agentsDir) } catch (Exception ex) { - logger.LogError(ex, "[dispatcher] Failed to set up inbox watcher for new agent {Project}/{Agent}", - projectSlug, agentSlug); + LogNewAgentWatcherFailed(ex, projectSlug, agentSlug); } }); }; @@ -230,12 +224,12 @@ private void WatchForNewProjects(string workspaceRoot) await Task.Delay(1000).ConfigureAwait(false); if (!File.Exists(Path.Combine(e.FullPath, "project.json"))) return; if (!ProjectSlug.TryParse(Path.GetFileName(e.FullPath), out var projectSlug)) return; - logger.LogInformation("[dispatcher] New project detected: {Project}", projectSlug); + LogNewProjectDetected(projectSlug); WatchProject(projectSlug); } catch (Exception ex) { - logger.LogError(ex, "[dispatcher] Failed to set up watchers for new project at {Path}", e.FullPath); + LogNewProjectWatcherFailed(ex, e.FullPath); } }); }; @@ -265,8 +259,7 @@ private void OnInboxMessage(ProjectSlug projectSlug, AgentSlug agentSlug, string activity?.SetTag("message.file", fileName); activity?.SetTag("dispatch.source", source); - logger.LogInformation("[dispatcher] [{Source}] Inbox message for {Project}/{Agent}: {File}", - source, projectSlug, agentSlug, fileName); + LogInboxMessage(source, projectSlug, agentSlug, fileName); // Notify immediately so UIs refresh badges and agent inbox counts even when // this message is deferred because the agent is currently running. @@ -274,9 +267,7 @@ private void OnInboxMessage(ProjectSlug projectSlug, AgentSlug agentSlug, string if (runner.IsRunning(projectSlug, agentSlug)) { - logger.LogInformation( - "[dispatcher] Agent {Project}/{Agent} already running — session will re-launch on exit if inbox is non-empty", - projectSlug, agentSlug); + LogAgentAlreadyRunning(projectSlug, agentSlug); activity?.SetTag("dispatch.outcome", "deferred-already-running"); return; } @@ -288,15 +279,13 @@ private void OnInboxMessage(ProjectSlug projectSlug, AgentSlug agentSlug, string MessageFile: fileName, ParentSpanId: activity?.Id)); activity?.SetTag("dispatch.outcome", launched ? "launched" : "already-launched"); - logger.LogInformation("[dispatcher] {Outcome} {Project}/{Agent}", - launched ? "Launched" : "Already running —", projectSlug, agentSlug); + LogLaunchOutcome(launched ? "Launched" : "Already running —", projectSlug, agentSlug); projectStateNotifier.Notify(projectSlug, ProjectStateChangeKind.Decisions); } catch (Exception ex) { - logger.LogError(ex, "[dispatcher] OnInboxMessage failed for {Project}/{Agent} ({Source}): {File}", - projectSlug, agentSlug, source, fullPath); + LogOnInboxMessageFailed(ex, projectSlug, agentSlug, source, fullPath); } } @@ -333,9 +322,7 @@ private void ScanAndLaunchAgents(ProjectSlug projectSlug, string source) if (pending.Length == 0) continue; if (runner.IsRunning(projectSlug, agentSlug)) continue; - logger.LogInformation( - "[dispatcher] [{Source}] Found {Count} pending message(s) for {Project}/{Agent} — launching", - source, pending.Length, projectSlug, agentSlug); + LogLaunchingForPendingMessages(source, pending.Length, projectSlug, agentSlug); runner.LaunchAgent(projectSlug, agentSlug, new AgentLaunchTrigger( Source: "dispatcher", @@ -343,4 +330,55 @@ private void ScanAndLaunchAgents(ProjectSlug projectSlug, string source) ProjectSlug: projectSlug)); } } + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] Starting")] + private partial void LogStarting(); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] Watching {Count} project(s) with FSW + 10 s poll")] + private partial void LogWatchingProjects(int count); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] Stopping")] + private partial void LogStopping(); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] New decision in {Project}: {File}")] + private partial void LogNewDecision(ProjectSlug project, string file); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] Decision resolved in {Project}: {File}")] + private partial void LogDecisionResolved(ProjectSlug project, string file); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] Watching inbox: {InboxDir}")] + private partial void LogWatchingInbox(string inboxDir); + + [LoggerMessage(Level = LogLevel.Error, Message = "[dispatcher] FSW error for {Project}/{Agent} — restarting watcher and scanning inbox")] + private partial void LogFswError(Exception ex, ProjectSlug project, AgentSlug agent); + + [LoggerMessage(Level = LogLevel.Error, Message = "[dispatcher] Failed to restart FSW for {Project}/{Agent}")] + private partial void LogFswRestartFailed(Exception ex, ProjectSlug project, AgentSlug agent); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] New agent detected: {Project}/{Agent}")] + private partial void LogNewAgentDetected(ProjectSlug project, AgentSlug agent); + + [LoggerMessage(Level = LogLevel.Error, Message = "[dispatcher] Failed to set up inbox watcher for new agent {Project}/{Agent}")] + private partial void LogNewAgentWatcherFailed(Exception ex, ProjectSlug project, AgentSlug agent); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] New project detected: {Project}")] + private partial void LogNewProjectDetected(ProjectSlug project); + + [LoggerMessage(Level = LogLevel.Error, Message = "[dispatcher] Failed to set up watchers for new project at {Path}")] + private partial void LogNewProjectWatcherFailed(Exception ex, string path); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] [{Source}] Inbox message for {Project}/{Agent}: {File}")] + private partial void LogInboxMessage(string source, ProjectSlug project, AgentSlug agent, string file); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] Agent {Project}/{Agent} already running — session will re-launch on exit if inbox is non-empty")] + private partial void LogAgentAlreadyRunning(ProjectSlug project, AgentSlug agent); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] {Outcome} {Project}/{Agent}")] + private partial void LogLaunchOutcome(string outcome, ProjectSlug project, AgentSlug agent); + + [LoggerMessage(Level = LogLevel.Error, Message = "[dispatcher] OnInboxMessage failed for {Project}/{Agent} ({Source}): {File}")] + private partial void LogOnInboxMessageFailed(Exception ex, ProjectSlug project, AgentSlug agent, string source, string file); + + [LoggerMessage(Level = LogLevel.Information, Message = "[dispatcher] [{Source}] Found {Count} pending message(s) for {Project}/{Agent} — launching")] + private partial void LogLaunchingForPendingMessages(string source, int count, ProjectSlug project, AgentSlug agent); } diff --git a/ai-dev.core/Services/FeatureFlagsService.cs b/ai-dev.core/Services/FeatureFlagsService.cs index 8c79eac..99e2da6 100644 --- a/ai-dev.core/Services/FeatureFlagsService.cs +++ b/ai-dev.core/Services/FeatureFlagsService.cs @@ -1,12 +1,23 @@ namespace AiDev.Services; +/// +/// Loads and persists application feature flags. +/// public class FeatureFlagsService { private readonly string _filePath = Path.Combine(AppContext.BaseDirectory, FilePathConstants.FeatureFlagsFileName); private AppFeatureFlags? _cache; + /// + /// Gets the current application feature flags. + /// + /// The loaded feature flags. public AppFeatureFlags GetFlags() => _cache ??= Load(); + /// + /// Saves the provided application feature flags. + /// + /// The feature flags to persist. public void SaveFlags(AppFeatureFlags flags) { ArgumentNullException.ThrowIfNull(flags); diff --git a/ai-dev.core/Services/InProcessDomainEventDispatcher.cs b/ai-dev.core/Services/InProcessDomainEventDispatcher.cs index b469ec1..7e69576 100644 --- a/ai-dev.core/Services/InProcessDomainEventDispatcher.cs +++ b/ai-dev.core/Services/InProcessDomainEventDispatcher.cs @@ -2,7 +2,7 @@ namespace AiDev.Services; -internal sealed class InProcessDomainEventDispatcher( +internal sealed partial class InProcessDomainEventDispatcher( IServiceProvider serviceProvider, ILogger logger) : IDomainEventDispatcher { @@ -43,8 +43,7 @@ private async Task> DispatchCore(TEvent domainEvent, Cancel } catch (Exception ex) { - logger.LogError(ex, "[dispatcher] Handler {Handler} failed for {Event}", - handler.GetType().Name, typeof(TEvent).Name); + LogHandlerFailed(ex, handler.GetType().Name, typeof(TEvent).Name); failures.Add($"{handler.GetType().Name}: {ex.Message}"); } } @@ -54,6 +53,9 @@ private async Task> DispatchCore(TEvent domainEvent, Cancel : CountFailureResult(failures); } + [LoggerMessage(Level = LogLevel.Error, Message = "[dispatcher] Handler {Handler} failed for {Event}")] + private partial void LogHandlerFailed(Exception ex, string handler, string @event); + private static Err CountFailureResult(List failures) { AiDevTelemetry.DomainDispatchFailures.Add(failures.Count); diff --git a/ai-dev.core/Services/MessagesService.cs b/ai-dev.core/Services/MessagesService.cs index 3bc3e6d..afd9333 100644 --- a/ai-dev.core/Services/MessagesService.cs +++ b/ai-dev.core/Services/MessagesService.cs @@ -1,7 +1,16 @@ namespace AiDev.Services; +/// +/// Provides message listing and archival operations for agent inboxes. +/// public class MessagesService(WorkspacePaths paths) { + /// + /// Lists messages for a project or a specific agent. + /// + /// The project that owns the messages. + /// The optional agent filter. + /// The matching messages ordered by descending date. public List ListMessages(ProjectSlug projectSlug, AgentSlug? agentSlug = null) { var results = new List(); @@ -47,6 +56,12 @@ public List ListMessages(ProjectSlug projectSlug, AgentSlug? agentS return [.. results.OrderByDescending(m => m.Date)]; } + /// + /// Moves a message from the inbox to the processed archive. + /// + /// The project that owns the message. + /// The agent that owns the message. + /// The message filename to archive. public void MarkProcessed(ProjectSlug projectSlug, AgentSlug agentSlug, string filename) { var inboxDir = paths.AgentInboxDir(projectSlug, agentSlug); diff --git a/ai-dev.core/Services/OverwatchService.cs b/ai-dev.core/Services/OverwatchService.cs index bc86614..40cfa63 100644 --- a/ai-dev.core/Services/OverwatchService.cs +++ b/ai-dev.core/Services/OverwatchService.cs @@ -17,7 +17,7 @@ namespace AiDev.Services; /// moves columns so a progressing task is never counted as stalled. /// - Stalled tasks with no assignee raise a decision for human intervention. /// -public class OverwatchService( +public partial class OverwatchService( WorkspaceService workspace, BoardService boardService, IAgentRunnerService runner, @@ -40,9 +40,7 @@ public class OverwatchService( public Task StartAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - logger.LogInformation( - "[overwatch] Starting — stall: {Stall}m, cooldown: {Cooldown}m, interval: {Interval}m", - StallThreshold.TotalMinutes, NudgeCooldown.TotalMinutes, ScanInterval.TotalMinutes); + LogStarting(StallThreshold.TotalMinutes, NudgeCooldown.TotalMinutes, ScanInterval.TotalMinutes); _scanCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _scanLoop = RunScanLoopAsync(_scanCts.Token); @@ -52,7 +50,7 @@ public Task StartAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken) { - logger.LogInformation("[overwatch] Stopping"); + LogStopping(); _scanCts?.Cancel(); if (_scanLoop != null) { @@ -100,7 +98,7 @@ private async Task ScanAllAsync(CancellationToken cancellationToken) catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogError(ex, "[overwatch] Error scanning project {Project}", project.Slug); + LogScanProjectError(ex, project.Slug); } } } @@ -161,9 +159,7 @@ private void ScanProject(ProjectSlug projectSlug, string? parentActivityId, Canc taskActivity?.SetTag("task.ageMinutes", (int)age.TotalMinutes); taskActivity?.SetTag("task.assignee", task.Assignee ?? "unassigned"); - logger.LogWarning( - "[overwatch] Task stalled {Age}m in [{Column}]: \"{Title}\" — assignee: {Assignee}", - (int)age.TotalMinutes, column.Title, task.Title, task.Assignee ?? "none"); + LogTaskStalled((int)age.TotalMinutes, column.Title, task.Title, task.Assignee ?? "none"); if (!string.IsNullOrEmpty(task.Assignee)) { @@ -182,9 +178,7 @@ private void ScanProject(ProjectSlug projectSlug, string? parentActivityId, Canc } if (nudged + escalated > 0) - logger.LogInformation( - "[overwatch] {Project}: {Nudged} nudged, {Escalated} escalated, {Skipped} in cooldown", - projectSlug, nudged, escalated, skipped); + LogScanSummary(projectSlug, nudged, escalated, skipped); activity?.SetTag("overwatch.nudged", nudged); activity?.SetTag("overwatch.escalated", escalated); @@ -199,17 +193,14 @@ private string NudgeAgent(ProjectSlug projectSlug, BoardTask task, string column { if (!AgentSlug.TryParse(task.Assignee, out var assigneeSlug)) { - logger.LogWarning("[overwatch] Task \"{Title}\" has invalid assignee slug: {Assignee}", - task.Title, task.Assignee); + LogInvalidAssigneeSlug(task.Title, task.Assignee); return "invalid-assignee-slug"; } // Don't nudge if the agent is already running — it'll pick up work when it next runs if (runner.IsRunning(projectSlug, assigneeSlug)) { - logger.LogInformation( - "[overwatch] Agent {Agent} is currently running — skipping nudge for \"{Title}\"", - assigneeSlug, task.Title); + LogSkippingNudgeAgentRunning(assigneeSlug, task.Title); return "skip-agent-running"; } @@ -220,9 +211,7 @@ private string NudgeAgent(ProjectSlug projectSlug, BoardTask task, string column var health = executorHealth.GetHealth(executorName); if (!health.IsHealthy) { - logger.LogWarning( - "[overwatch] Executor '{Executor}' for agent {Agent} is unhealthy — raising decision for \"{Title}\"", - executorName, assigneeSlug, task.Title); + LogExecutorUnhealthy(executorName, assigneeSlug, task.Title); return RaiseExecutorOfflineDecision(projectSlug, task, assigneeSlug, executorName, health.Message); } @@ -247,13 +236,11 @@ private string NudgeAgent(ProjectSlug projectSlug, BoardTask task, string column if (writeResult is Err writeErr) { - logger.LogError("[overwatch] Failed to nudge {Agent} for \"{Title}\": {Error}", - task.Assignee, task.Title, writeErr.Error.Message); + LogNudgeFailed(task.Assignee, task.Title, writeErr.Error.Message); return "nudge-failed"; } - logger.LogInformation("[overwatch] Nudged {Agent} for stalled task \"{Title}\" ({Age})", - task.Assignee, task.Title, ageStr); + LogNudged(task.Assignee, task.Title, ageStr); boardService.SetTaskNudged(projectSlug, task.Id); return "nudged"; } @@ -279,13 +266,11 @@ private string RaiseDecision(ProjectSlug projectSlug, BoardTask task, string col if (decisionResult is Err decisionErr) { - logger.LogError("[overwatch] Failed to raise decision for \"{Title}\": {Error}", - task.Title, decisionErr.Error.Message); + LogRaiseDecisionFailed(task.Title, decisionErr.Error.Message); return "decision-failed"; } - logger.LogInformation("[overwatch] Raised decision for unassigned stalled task \"{Title}\" ({Age})", - task.Title, ageStr); + LogDecisionRaised(task.Title, ageStr); boardService.SetTaskNudged(projectSlug, task.Id); return "decision-raised"; } @@ -316,14 +301,11 @@ private string RaiseExecutorOfflineDecision(ProjectSlug projectSlug, BoardTask t if (decisionResult is Err decisionErr) { - logger.LogError("[overwatch] Failed to raise executor offline decision for \"{Title}\": {Error}", - task.Title, decisionErr.Error.Message); + LogRaiseExecutorOfflineDecisionFailed(task.Title, decisionErr.Error.Message); return "decision-failed"; } - logger.LogInformation( - "[overwatch] Raised executor offline decision for agent {Agent} ({Executor}), task \"{Title}\"", - agentSlug, executorName, task.Title); + LogExecutorOfflineDecisionRaised(agentSlug, executorName, task.Title); boardService.SetTaskNudged(projectSlug, task.Id); return "decision-raised"; } @@ -332,4 +314,46 @@ private static string FormatAge(TimeSpan age) => age.TotalHours >= 1 ? $"{(int)age.TotalHours}h {age.Minutes}m" : $"{(int)age.TotalMinutes}m"; + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] Starting — stall: {Stall}m, cooldown: {Cooldown}m, interval: {Interval}m")] + private partial void LogStarting(double stall, double cooldown, double interval); + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] Stopping")] + private partial void LogStopping(); + + [LoggerMessage(Level = LogLevel.Error, Message = "[overwatch] Error scanning project {Project}")] + private partial void LogScanProjectError(Exception ex, ProjectSlug project); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[overwatch] Task stalled {Age}m in [{Column}]: \"{Title}\" — assignee: {Assignee}")] + private partial void LogTaskStalled(int age, string column, string title, string assignee); + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] {Project}: {Nudged} nudged, {Escalated} escalated, {Skipped} in cooldown")] + private partial void LogScanSummary(ProjectSlug project, int nudged, int escalated, int skipped); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[overwatch] Task \"{Title}\" has invalid assignee slug: {Assignee}")] + private partial void LogInvalidAssigneeSlug(string title, string? assignee); + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] Agent {Agent} is currently running — skipping nudge for \"{Title}\"")] + private partial void LogSkippingNudgeAgentRunning(AgentSlug agent, string title); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[overwatch] Executor '{Executor}' for agent {Agent} is unhealthy — raising decision for \"{Title}\"")] + private partial void LogExecutorUnhealthy(AgentExecutorName executor, AgentSlug agent, string title); + + [LoggerMessage(Level = LogLevel.Error, Message = "[overwatch] Failed to nudge {Agent} for \"{Title}\": {Error}")] + private partial void LogNudgeFailed(string? agent, string title, string error); + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] Nudged {Agent} for stalled task \"{Title}\" ({Age})")] + private partial void LogNudged(string? agent, string title, string age); + + [LoggerMessage(Level = LogLevel.Error, Message = "[overwatch] Failed to raise decision for \"{Title}\": {Error}")] + private partial void LogRaiseDecisionFailed(string title, string error); + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] Raised decision for unassigned stalled task \"{Title}\" ({Age})")] + private partial void LogDecisionRaised(string title, string age); + + [LoggerMessage(Level = LogLevel.Error, Message = "[overwatch] Failed to raise executor offline decision for \"{Title}\": {Error}")] + private partial void LogRaiseExecutorOfflineDecisionFailed(string title, string error); + + [LoggerMessage(Level = LogLevel.Information, Message = "[overwatch] Raised executor offline decision for agent {Agent} ({Executor}), task \"{Title}\"")] + private partial void LogExecutorOfflineDecisionRaised(AgentSlug agent, AgentExecutorName executor, string title); } diff --git a/ai-dev.core/Services/ProjectStateSnapshotService.cs b/ai-dev.core/Services/ProjectStateSnapshotService.cs index 2ba9e45..2ffa096 100644 --- a/ai-dev.core/Services/ProjectStateSnapshotService.cs +++ b/ai-dev.core/Services/ProjectStateSnapshotService.cs @@ -9,7 +9,7 @@ namespace AiDev.Services; /// /// Computes a single project state snapshot so different UIs use the same source of truth. /// -public class ProjectStateSnapshotService( +public partial class ProjectStateSnapshotService( MessagesService messagesService, DecisionsService decisionsService, BoardService boardService, @@ -19,10 +19,10 @@ public class ProjectStateSnapshotService( { public ProjectStateSnapshot GetSnapshot(ProjectSlug projectSlug) { - var unreadMessages = SafeCount(() => messagesService.ListMessages(projectSlug).Count(m => !m.IsProcessed), logger, "unread-messages"); - var pendingDecisions = SafeCount(() => decisionsService.ListDecisions(projectSlug, DecisionStatus.Pending).Count, logger, "pending-decisions"); + var unreadMessages = SafeCount(() => messagesService.ListMessages(projectSlug).Count(m => !m.IsProcessed), "unread-messages"); + var pendingDecisions = SafeCount(() => decisionsService.ListDecisions(projectSlug, DecisionStatus.Pending).Count, "pending-decisions"); - var board = SafeGet(() => boardService.LoadBoard(projectSlug), logger, "board"); + var board = SafeGet(() => boardService.LoadBoard(projectSlug), "board"); var openBoardTasks = 0; if (board != null) { @@ -31,7 +31,7 @@ public ProjectStateSnapshot GetSnapshot(ProjectSlug projectSlug) openBoardTasks = board.Tasks.Keys.Count(taskId => !doneIds.Contains(taskId)); } - var agents = SafeGet(() => agentService.ListAgents(projectSlug), logger, "agents") ?? []; + var agents = SafeGet(() => agentService.ListAgents(projectSlug), "agents") ?? []; var runningAgents = agents.Count(agent => agentRunnerService.IsRunning(projectSlug, agent.Slug)); var agentsWithPendingInbox = agents.Count(agent => agent.InboxCount > 0); @@ -44,7 +44,7 @@ public ProjectStateSnapshot GetSnapshot(ProjectSlug projectSlug) AgentsWithPendingInboxCount: agentsWithPendingInbox); } - private static int SafeCount(Func countFactory, ILogger logger, string context) + private int SafeCount(Func countFactory, string context) { try { @@ -52,12 +52,12 @@ private static int SafeCount(Func countFactory, ILogger logger, string cont } catch (Exception ex) { - logger.LogWarning(ex, "[snapshot] SafeCount failed for {Context}", context); + LogSafeCountFailed(ex, context); return 0; } } - private static T? SafeGet(Func factory, ILogger logger, string context) + private T? SafeGet(Func factory, string context) { try { @@ -65,8 +65,14 @@ private static int SafeCount(Func countFactory, ILogger logger, string cont } catch (Exception ex) { - logger.LogWarning(ex, "[snapshot] SafeGet failed for {Context}", context); + LogSafeGetFailed(ex, context); return default; } } + + [LoggerMessage(Level = LogLevel.Warning, Message = "[snapshot] SafeCount failed for {Context}")] + private partial void LogSafeCountFailed(Exception ex, string context); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[snapshot] SafeGet failed for {Context}")] + private partial void LogSafeGetFailed(Exception ex, string context); } diff --git a/ai-dev.core/Services/PromptEnhancerService.cs b/ai-dev.core/Services/PromptEnhancerService.cs index 2c4c089..7fc9199 100644 --- a/ai-dev.core/Services/PromptEnhancerService.cs +++ b/ai-dev.core/Services/PromptEnhancerService.cs @@ -4,6 +4,11 @@ namespace AiDev.Services; +/// +/// Represents the enhanced title and description generated for a task prompt. +/// +/// The enhanced task title. +/// The enhanced task description. public record EnhanceResult(string Title, string Description); /// @@ -18,6 +23,14 @@ public partial class PromptEnhancerService( WorkspacePaths paths, ILogger logger) { + /// + /// Enhances a task title and description using project context and knowledge base content. + /// + /// The project whose context should be used. + /// The original task title. + /// The original task description. + /// The cancellation token for the operation. + /// The enhanced result, or when enhancement fails. public async Task EnhanceAsync( ProjectSlug projectSlug, string title, string description, CancellationToken ct = default) { @@ -75,7 +88,7 @@ public partial class PromptEnhancerService( if (process.ExitCode != 0) { - logger.LogWarning("[enhancer] Claude exited {Code}. stderr: {Err}", process.ExitCode, stderr); + LogClaudeExitCode(process.ExitCode, stderr); return null; } @@ -85,7 +98,7 @@ public partial class PromptEnhancerService( if (jsonStart < 0 || jsonEnd <= jsonStart) { - logger.LogWarning("[enhancer] No JSON object found in output: {Raw}", raw); + LogNoJsonFound(raw); return null; } @@ -102,11 +115,20 @@ public partial class PromptEnhancerService( } catch (Exception ex) { - logger.LogWarning(ex, "[enhancer] Failed. stdout: {Out} stderr: {Err}", stdout, stderr); + LogEnhanceFailed(ex, stdout, stderr); return null; } } + [LoggerMessage(Level = LogLevel.Warning, Message = "[enhancer] Claude exited {Code}. stderr: {Err}")] + private partial void LogClaudeExitCode(int code, StringBuilder err); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[enhancer] No JSON object found in output: {Raw}")] + private partial void LogNoJsonFound(string raw); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[enhancer] Failed. stdout: {Out} stderr: {Err}")] + private partial void LogEnhanceFailed(Exception ex, StringBuilder @out, StringBuilder err); + private static ProcessStartInfo BuildProcessStartInfo(string workingDir, string modelId) { var psi = new ProcessStartInfo diff --git a/ai-dev.core/Services/StudioSettingsService.cs b/ai-dev.core/Services/StudioSettingsService.cs index f05ccae..bba2046 100644 --- a/ai-dev.core/Services/StudioSettingsService.cs +++ b/ai-dev.core/Services/StudioSettingsService.cs @@ -2,6 +2,9 @@ namespace AiDev.Services; +/// +/// Loads and persists studio settings from configuration sources and the local settings file. +/// public class StudioSettingsService(IConfiguration configuration) { private readonly string _settingsFilePath = Path.Combine(AppContext.BaseDirectory, FilePathConstants.StudioSettingsFileName); @@ -17,6 +20,10 @@ public class StudioSettingsService(IConfiguration configuration) ["haiku"] = "claude-haiku-4-5-20251001", }; + /// + /// Gets the effective studio settings merged from defaults and configured values. + /// + /// The effective studio settings. public StudioSettings GetSettings() { var configuredModels = GetConfiguredModels(); @@ -46,6 +53,10 @@ public StudioSettings GetSettings() }; } + /// + /// Saves studio settings to the local settings file. + /// + /// The settings to persist. public void SaveSettings(StudioSettings settings) { ArgumentNullException.ThrowIfNull(settings); diff --git a/ai-dev.core/WorkspacePaths.cs b/ai-dev.core/WorkspacePaths.cs index 330fb60..84d6d99 100644 --- a/ai-dev.core/WorkspacePaths.cs +++ b/ai-dev.core/WorkspacePaths.cs @@ -2,62 +2,127 @@ namespace AiDev; +/// +/// Represents a strongly typed file-system path value. +/// +/// The underlying path value. public abstract record FilePathBase(string Value) { + /// + /// Gets the absolute normalized path. + /// public string FullPath => Path.GetFullPath(Value); + /// + /// Converts the typed path to its underlying string value. + /// + /// The typed path value. public static implicit operator string(FilePathBase path) => path.Value; } +/// +/// Represents a strongly typed directory path. +/// +/// The underlying directory path value. public abstract record DirPath(string Value) : FilePathBase(Value) { + /// + /// Determines whether the directory exists. + /// + /// when the directory exists; otherwise, . public bool Exists() => Directory.Exists(Value); + + /// + /// Creates the directory if it does not already exist. + /// public void Create() => Directory.CreateDirectory(Value); } +/// +/// Represents a strongly typed file path. +/// +/// The underlying file path value. public abstract record FilePath(string Value) : FilePathBase(Value) { + /// + /// Determines whether the file exists. + /// + /// when the file exists; otherwise, . public bool Exists() => File.Exists(Value); } +/// Represents the workspace root directory. public record RootDir(string Value) : DirPath(Value); +/// Represents the workspace registry file path. public record RegistryFile(string Value) : FilePath(Value); +/// Represents the studio settings file path. public record StudioSettingFile(string Value) : FilePath(Value); +/// Represents the agent templates directory path. public record AgentTemplatesFile(string Value) : FilePath(Value); +/// Represents a project root directory. public record ProjectDir(string Value) : DirPath(Value); +/// Represents a project metadata file path. public record ProjectJsonFile(string Value) : FilePath(Value); +/// Represents a project agents directory. public record AgentsDir(string Value) : DirPath(Value); +/// Represents a project board file path. public record BoardFile(string Value) : FilePath(Value); +/// Represents a project's pending decisions directory. public record DecisionsPendingDir(string Value) : DirPath(Value); +/// Represents a project's resolved decisions directory. public record DecisionsResolvedDir(string Value) : DirPath(Value); +/// Represents a project's decision chats directory. public record DecisionChatsDir(string Value) : DirPath(Value); +/// Represents a project's knowledge base directory. public record KbDir(string Value) : DirPath(Value); +/// Represents a project's playbooks directory. public record PlaybooksDir(string Value) : DirPath(Value); +/// Represents a playbook file path. public record PlaybookFile(string Value) : FilePath(Value); +/// Represents an agent directory. public record AgentDir(string Value) : DirPath(Value); +/// Represents an agent metadata file path. public record AgentJsonFile(string Value) : FilePath(Value); +/// Represents an agent CLAUDE.md file path. public record AgentClaudeMdFile(string Value) : FilePath(Value); +/// Represents an agent inbox directory. public record AgentInboxDir(string Value) : DirPath(Value); +/// Represents an agent processed inbox directory. public record AgentInboxProcessedDir(string Value) : DirPath(Value); +/// Represents an agent outbox directory. public record AgentOutboxDir(string Value) : DirPath(Value); +/// Represents an agent journal directory. public record AgentJournalDir(string Value) : DirPath(Value); +/// Represents an agent transcripts directory. public record AgentTranscriptsDir(string Value) : DirPath(Value); +/// Represents a planning sessions directory. public record PlanningSessionsDir(string Value) : DirPath(Value); +/// Represents a specific planning session directory. public record PlanningSessionDir(string Value) : DirPath(Value); +/// Represents a planning session drafts directory. public record PlanningSessionDraftsDir(string Value) : DirPath(Value); +/// Represents a planning session metadata file path. public record PlanningSessionMetadataFile(string Value) : FilePath(Value); +/// Represents a planning session conversation file path. public record PlanningSessionConversationFile(string Value) : FilePath(Value); +/// Represents a planning session DSL file path. public record PlanningSessionDslFile(string Value) : FilePath(Value); +/// Represents a transcript file path. public record TranscriptFile(string Value) : FilePath(Value); +/// Represents an insight file path. public record InsightFile(string Value) : FilePath(Value); +/// Represents a secrets file path. public record SecretsFile(string Value) : FilePath(Value); +/// Represents a knowledge base article file path. public record KbArticleFile(string Value) : FilePath(Value); +/// Represents a playbook article file path. public record PlaybookArticleFile(string Value) : FilePath(Value); +/// Represents a template file path. public record TemplateFile(string Value) : FilePath(Value); /// @@ -78,6 +143,10 @@ public class WorkspacePaths /// Directory containing agent template files. public AgentTemplatesFile AgentTemplatesDir { get; } + /// + /// Initializes resolved workspace paths from the workspace root. + /// + /// The workspace root directory. public WorkspacePaths(RootDir root) { Root = root; @@ -86,50 +155,80 @@ public WorkspacePaths(RootDir root) AgentTemplatesDir = Root.AgentTemplatesFile(); } + /// Gets the project directory path. public ProjectDir ProjectDir(ProjectSlug p) => Root.ProjectDir(p); + /// Gets the project metadata file path. public ProjectJsonFile ProjectJsonPath(ProjectSlug p) => ProjectDir(p).ProjectJsonFile(); + /// Gets the agents directory path. public AgentsDir AgentsDir(ProjectSlug p) => ProjectDir(p).AgentsDir(); + /// Gets the board file path. public BoardFile BoardPath(ProjectSlug p) => ProjectDir(p).BoardFile(); + /// Gets the pending decisions directory path. public DecisionsPendingDir DecisionsPendingDir(ProjectSlug p) => ProjectDir(p).DecisionsPendingDir(); + /// Gets the resolved decisions directory path. public DecisionsResolvedDir DecisionsResolvedDir(ProjectSlug p) => ProjectDir(p).DecisionsResolvedDir(); + /// Gets the decision chats directory path. public DecisionChatsDir DecisionChatsDir(ProjectSlug p) => ProjectDir(p).DecisionChatsDir(); + /// Gets the knowledge base directory path. public KbDir KbDir(ProjectSlug p) => ProjectDir(p).KbDir(); + /// Gets the secrets file path. public SecretsFile SecretsPath(ProjectSlug p) => ProjectDir(p).SecretsFile(); + /// Gets the playbooks directory path. public PlaybooksDir PlaybooksDir(ProjectSlug p) => ProjectDir(p).PlaybooksDir(); + /// Gets the agent directory path. public AgentDir AgentDir(ProjectSlug p, AgentSlug a) => AgentsDir(p).AgentDir(a); + /// Gets the agent metadata file path. public AgentJsonFile AgentJsonPath(ProjectSlug p, AgentSlug a) => AgentDir(p, a).AgentJsonFile(); + /// Gets the agent CLAUDE.md file path. public AgentClaudeMdFile AgentClaudeMdPath(ProjectSlug p, AgentSlug a) => AgentDir(p, a).AgentClaudeMdFile(); + /// Gets the agent inbox directory path. public AgentInboxDir AgentInboxDir(ProjectSlug p, AgentSlug a) => AgentDir(p, a).AgentInboxDir(); + /// Gets the processed agent inbox directory path. public AgentInboxProcessedDir AgentInboxProcessedDir(ProjectSlug p, AgentSlug a) => AgentInboxDir(p, a).AgentInboxProcessedDir(); + /// Gets the agent outbox directory path. public AgentOutboxDir AgentOutboxDir(ProjectSlug p, AgentSlug a) => AgentDir(p, a).AgentOutboxDir(); + /// Gets the agent journal directory path. public AgentJournalDir AgentJournalDir(ProjectSlug p, AgentSlug a) => AgentDir(p, a).AgentJournalDir(); + /// Gets the agent transcripts directory path. public AgentTranscriptsDir AgentTranscriptsDir(ProjectSlug p, AgentSlug a) => AgentDir(p, a).AgentTranscriptsDir(); + /// Gets the planning sessions directory path. public PlanningSessionsDir PlanningSessionsDir(ProjectSlug p) => new(Path.Combine(ProjectDir(p).Value, FilePathConstants.SessionsDirName, FilePathConstants.PlanningDirName)); + /// Gets a planning session directory path. public PlanningSessionDir PlanningSessionDir(ProjectSlug p, SessionId sessionId) => new(Path.Combine(PlanningSessionsDir(p).Value, sessionId.Value)); + /// Gets a planning session drafts directory path. public PlanningSessionDraftsDir PlanningSessionDraftsDir(ProjectSlug p, SessionId sessionId) => new(Path.Combine(PlanningSessionDir(p, sessionId).Value, FilePathConstants.DraftsDirName)); + /// Gets a planning session metadata file path. public PlanningSessionMetadataFile PlanningSessionMetadataPath(ProjectSlug p, SessionId sessionId) => new(Path.Combine(PlanningSessionDir(p, sessionId).Value, FilePathConstants.PlanningMetadataFileName)); + /// Gets a planning session conversation file path. public PlanningSessionConversationFile PlanningSessionConversationPath(ProjectSlug p, SessionId sessionId) => new(Path.Combine(PlanningSessionDir(p, sessionId).Value, FilePathConstants.PlanningConversationFileName)); + /// Gets a locked planning DSL file path. public PlanningSessionDslFile PlanningSessionLockedDslPath(ProjectSlug p, SessionId sessionId, string dslFileName) => new(Path.Combine(PlanningSessionDir(p, sessionId).Value, dslFileName)); + /// Gets a draft planning DSL file path. public PlanningSessionDslFile PlanningSessionDraftDslPath(ProjectSlug p, SessionId sessionId, string dslFileName) => new(Path.Combine(PlanningSessionDraftsDir(p, sessionId).Value, dslFileName)); + /// Gets a transcript file path. public TranscriptFile TranscriptPath(ProjectSlug p, AgentSlug a, TranscriptDate date) => AgentTranscriptsDir(p, a).TranscriptFile(date); + /// Gets an insight file path. public InsightFile InsightPath(ProjectSlug p, AgentSlug a, TranscriptDate date) => AgentTranscriptsDir(p, a).InsightFile(date); + /// Gets a safe knowledge base article file path when the slug is valid. public KbArticleFile? SafeKbArticlePath(ProjectSlug p, string slug) => KbDir(p).SafeKbArticleFile(slug); + /// Gets a safe playbook file path when the slug is valid. public PlaybookArticleFile? SafePlaybookPath(ProjectSlug p, string slug) => PlaybooksDir(p).SafePlaybookFile(slug); + /// Gets a safe template file path when the slug and extension are valid. public TemplateFile? SafeTemplatePath(string slug, string extension) => AgentTemplatesDir.SafeTemplateFile(slug, extension); } diff --git a/ai-dev.executor.anthropic/AnthropicAgentExecutor.cs b/ai-dev.executor.anthropic/AnthropicAgentExecutor.cs index 696f287..a810422 100644 --- a/ai-dev.executor.anthropic/AnthropicAgentExecutor.cs +++ b/ai-dev.executor.anthropic/AnthropicAgentExecutor.cs @@ -25,7 +25,7 @@ namespace AiDev.Executors; /// Rate limits (HTTP 429) are detected from the response status code and surfaced as /// = true, suppressing re-launches. /// -public sealed class AnthropicAgentExecutor( +public sealed partial class AnthropicAgentExecutor( IHttpClientFactory httpClientFactory, StudioSettingsService settingsService, ILogger logger) : IAgentExecutor @@ -95,7 +95,7 @@ public async Task CheckHealthAsync(CancellationToken ct = catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogDebug(ex, "[anthropic-health] Probe failed: {Message}", ex.Message); + LogHealthProbeFailed(ex, ex.Message); return new ExecutorHealthResult(false, $"Connection failed: {ex.Message}"); } } @@ -110,7 +110,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (string.IsNullOrWhiteSpace(apiKey)) { const string msg = "AnthropicApiKey not configured in studio settings. Aborting."; - logger.LogError("[anthropic] {Message}", msg); + LogMissingApiKey(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -119,9 +119,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var enableTools = context.EnabledSkills.Contains("mcp-workspace"); var workspaceRoot = enableTools ? DeriveWorkspaceRoot(context.WorkingDir) : null; - logger.LogInformation( - "[anthropic] Starting session — model={Model} tools={Tools}", - context.ModelId, enableTools ? "enabled" : "disabled"); + LogSessionStarting(context.ModelId, enableTools ? "enabled" : "disabled"); output.TryWrite($"[{DateTime.UtcNow:o}] [anthropic] model={context.ModelId}"); @@ -160,7 +158,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite catch (Exception ex) when (ex is not OperationCanceledException) { var msg = $"Failed to connect to Anthropic API: {ex.Message}"; - logger.LogError(ex, "[anthropic] {Message}", msg); + LogConnectFailed(ex, msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -172,7 +170,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var retryAfter = response.Headers.RetryAfter?.Delta; var hint = retryAfter.HasValue ? $" retry-after={retryAfter.Value.TotalSeconds:F0}s" : ""; var msg = $"Anthropic rate limit reached.{hint}"; - logger.LogWarning("[anthropic] {Message} Body={Body}", msg, body); + LogRateLimited(msg, body); output.TryWrite($"[{DateTime.UtcNow:o}] [rate-limit] {msg}"); return new ExecutorResult(1, IsRateLimited: true, ErrorMessage: msg); } @@ -181,12 +179,12 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite { var body = await response.Content.ReadAsStringAsync(context.CancellationToken).ConfigureAwait(false); var msg = $"Anthropic returned HTTP {(int)response.StatusCode}: {body}"; - logger.LogError("[anthropic] {Message}", msg); + LogHttpError(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } - logger.LogInformation("[anthropic] Streaming response — iteration {N}", iteration); + LogStreamingResponse(iteration); // --- Stream and parse SSE --- var (stopReason, textContent, toolUses, iterationUsage) = @@ -199,15 +197,12 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite // --- End turn: final response --- if (stopReason == "end_turn" || toolUses.Count == 0) { - logger.LogInformation( - "[anthropic] Session complete — {Chars} chars | iterations: {N}", - textContent.Length, iteration); + LogSessionComplete(textContent.Length, iteration); return new ExecutorResult(0, Usage: totalUsage); } // --- Tool use: execute calls and loop --- - logger.LogInformation( - "[anthropic] Tool calls requested — count={Count} iteration={N}", toolUses.Count, iteration); + LogToolCallsRequested(toolUses.Count, iteration); output.TryWrite($"[{DateTime.UtcNow:o}] [anthropic] tool calls: {toolUses.Count} (iteration {iteration})"); // Append assistant message (content array with text + tool_use blocks). @@ -238,7 +233,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var inputJson = use.InputJson.ToString(); var argsPreview = inputJson.Length > 80 ? inputJson[..80] + "…" : inputJson; output.TryWrite($"[{DateTime.UtcNow:o}] [tool:call] {use.Name}({argsPreview})"); - logger.LogInformation("[anthropic] Executing tool — {Tool}({Args})", use.Name, argsPreview); + LogExecutingTool(use.Name, argsPreview); // Parse input JSON to JsonElement for WorkspaceTools compatibility. JsonElement argsElement; @@ -253,7 +248,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite ? result[..120].Replace('\n', ' ') + "…" : result.Replace('\n', ' '); output.TryWrite($"[{DateTime.UtcNow:o}] [tool:result] {preview}"); - logger.LogInformation("[anthropic] Tool result — {Chars} chars", result.Length); + LogToolResult(result.Length); toolResults.Add(new JsonObject { @@ -268,7 +263,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite } var limitMsg = $"Exceeded maximum tool-call iterations ({MaxToolIterations}). Aborting session."; - logger.LogError("[anthropic] {Message}", limitMsg); + LogMaxIterationsExceeded(limitMsg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {limitMsg}"); return new ExecutorResult(1, ErrorMessage: limitMsg); } @@ -457,4 +452,44 @@ private sealed record ToolUse(string Id, string Name) /// Accumulated partial_json deltas — finalised after message_stop. public StringBuilder InputJson { get; init; } = new(); } + + // ------------------------------------------------------------------------- + // Logger messages + // ------------------------------------------------------------------------- + + [LoggerMessage(Level = LogLevel.Debug, Message = "[anthropic-health] Probe failed: {Message}")] + private partial void LogHealthProbeFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "[anthropic] {Message}")] + private partial void LogMissingApiKey(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[anthropic] Starting session — model={Model} tools={Tools}")] + private partial void LogSessionStarting(string model, string tools); + + [LoggerMessage(Level = LogLevel.Error, Message = "[anthropic] {Message}")] + private partial void LogConnectFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[anthropic] {Message} Body={Body}")] + private partial void LogRateLimited(string message, string body); + + [LoggerMessage(Level = LogLevel.Error, Message = "[anthropic] {Message}")] + private partial void LogHttpError(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[anthropic] Streaming response — iteration {N}")] + private partial void LogStreamingResponse(int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[anthropic] Session complete — {Chars} chars | iterations: {N}")] + private partial void LogSessionComplete(int chars, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[anthropic] Tool calls requested — count={Count} iteration={N}")] + private partial void LogToolCallsRequested(int count, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[anthropic] Executing tool — {Tool}({Args})")] + private partial void LogExecutingTool(string tool, string args); + + [LoggerMessage(Level = LogLevel.Information, Message = "[anthropic] Tool result — {Chars} chars")] + private partial void LogToolResult(int chars); + + [LoggerMessage(Level = LogLevel.Error, Message = "[anthropic] {Message}")] + private partial void LogMaxIterationsExceeded(string message); } diff --git a/ai-dev.executor.anthropic/AnthropicPlanningLlmClient.cs b/ai-dev.executor.anthropic/AnthropicPlanningLlmClient.cs index 2542ac0..0ab903c 100644 --- a/ai-dev.executor.anthropic/AnthropicPlanningLlmClient.cs +++ b/ai-dev.executor.anthropic/AnthropicPlanningLlmClient.cs @@ -15,7 +15,7 @@ namespace AiDev.Executors; /// Anthropic Messages API implementation of . /// Used when the analyst agent is configured with the "anthropic" executor. /// -public sealed class AnthropicPlanningLlmClient( +public sealed partial class AnthropicPlanningLlmClient( IHttpClientFactory httpClientFactory, StudioSettingsService settingsService, ILogger logger) : IPlanningLlmClient @@ -62,15 +62,14 @@ public async Task ChatAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogError(ex, "[planning-llm] Failed to call Anthropic API"); + LogApiCallFailed(ex); throw; } if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - logger.LogError("[planning-llm] Anthropic returned HTTP {Status}: {Body}", - (int)response.StatusCode, body); + LogHttpError((int)response.StatusCode, body); throw new HttpRequestException( $"Anthropic API returned HTTP {(int)response.StatusCode}: {body}"); } @@ -94,10 +93,23 @@ public async Task ChatAsync( if (usage.TryGetProperty("output_tokens", out var ot)) outputTokens = ot.GetInt32(); } - logger.LogDebug("[planning-llm] tokens: {Input} in / {Output} out", inputTokens, outputTokens); + LogTokenUsage(inputTokens, outputTokens); return new PlanningLlmResponse(content, inputTokens, outputTokens); } + // ------------------------------------------------------------------------- + // Logger messages + // ------------------------------------------------------------------------- + + [LoggerMessage(Level = LogLevel.Error, Message = "[planning-llm] Failed to call Anthropic API")] + private partial void LogApiCallFailed(Exception ex); + + [LoggerMessage(Level = LogLevel.Error, Message = "[planning-llm] Anthropic returned HTTP {Status}: {Body}")] + private partial void LogHttpError(int status, string body); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[planning-llm] tokens: {Input} in / {Output} out")] + private partial void LogTokenUsage(int input, int output); + private static JsonArray BuildMessages(IReadOnlyList history, string newUserMessage) { var array = new JsonArray(); diff --git a/ai-dev.executor.anthropic/AnthropicSkills.cs b/ai-dev.executor.anthropic/AnthropicSkills.cs index 85a75da..83687c8 100644 --- a/ai-dev.executor.anthropic/AnthropicSkills.cs +++ b/ai-dev.executor.anthropic/AnthropicSkills.cs @@ -25,9 +25,9 @@ public static class AnthropicSkills internal static IReadOnlyList Resolve(IReadOnlyList enabledSkills) { if (enabledSkills.Count == 0) - return All.Where(s => s.DefaultEnabled).ToList(); + return [.. All.Where(s => s.DefaultEnabled)]; var keys = enabledSkills.ToHashSet(StringComparer.OrdinalIgnoreCase); - return All.Where(s => keys.Contains(s.Key)).ToList(); + return [.. All.Where(s => keys.Contains(s.Key))]; } } diff --git a/ai-dev.executor.anthropic/Extensions/AnthropicExecutorExtensions.cs b/ai-dev.executor.anthropic/Extensions/AnthropicExecutorExtensions.cs index 8fd638e..96f97da 100644 --- a/ai-dev.executor.anthropic/Extensions/AnthropicExecutorExtensions.cs +++ b/ai-dev.executor.anthropic/Extensions/AnthropicExecutorExtensions.cs @@ -22,10 +22,12 @@ public static IServiceCollection AddAnthropicExecutor(this IServiceCollection se client.Timeout = TimeSpan.FromMinutes(10); }); +#pragma warning disable EXTEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates services.AddHttpClient("anthropic-health", client => { client.Timeout = TimeSpan.FromSeconds(5); - }); + }).RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 services.AddSingleton(); services.AddSingleton(); diff --git a/ai-dev.executor.copilot-cli/CopilotCliAgentExecutor.cs b/ai-dev.executor.copilot-cli/CopilotCliAgentExecutor.cs index 1a42a39..f555006 100644 --- a/ai-dev.executor.copilot-cli/CopilotCliAgentExecutor.cs +++ b/ai-dev.executor.copilot-cli/CopilotCliAgentExecutor.cs @@ -39,7 +39,7 @@ namespace AiDev.Executors; /// counter in the final result.usage. We sum output tokens across messages and /// leave input at zero. /// -public class CopilotCliAgentExecutor(ILogger logger) : IAgentExecutor +public partial class CopilotCliAgentExecutor(ILogger logger) : IAgentExecutor { public AgentExecutorName Name => AgentExecutorName.CopilotCli; public string DisplayName => "Copilot CLI"; @@ -159,19 +159,19 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (!isRateLimited && IsRateLimitLine(e.Data)) { isRateLimited = true; - logger.LogWarning("[copilot] Rate limit detected in output"); + LogRateLimitDetected(); } var (visibleText, usage, failure) = ParseJsonLine(e.Data); if (failure is not null) { - logger.LogError("[copilot] {FailureMessage}", failure); + LogOutputFailure(failure); Interlocked.CompareExchange(ref errorMessage, failure, null); } else { - logger.LogDebug("[copilot] {Line}", e.Data); + LogOutputLine(e.Data); } if (usage != null) @@ -191,12 +191,12 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (IsBenignStderrLine(e.Data)) { - logger.LogDebug("[copilot] [stderr/warn] {Line}", e.Data); + LogStderrWarn(e.Data); return; } var failureMessage = $"stderr: {e.Data}"; - logger.LogError("[copilot] {FailureMessage}", failureMessage); + LogStderrFailure(failureMessage); Interlocked.CompareExchange(ref errorMessage, failureMessage, null); output.TryWrite($"[{DateTime.UtcNow:o}] [stderr] {e.Data}"); }; @@ -220,7 +220,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (silentFor.TotalSeconds >= StallCheckInterval) { var msg = $"[stall-check] no output for {silentFor.TotalMinutes:F0}m — process PID={process.Id} still running"; - logger.LogWarning("[copilot] {Message}", msg); + LogStallDetected(msg); output.TryWrite($"[{DateTime.UtcNow:o}] {msg}"); context.ReportWarning?.Invoke(msg); } @@ -492,4 +492,26 @@ private static (string? VisibleText, TokenUsage? Usage, string? Failure) // session.*, user.*, assistant.turn_* and unknown events — suppress. return (null, null, null); } + + // ------------------------------------------------------------------------- + // Logger messages + // ------------------------------------------------------------------------- + + [LoggerMessage(Level = LogLevel.Warning, Message = "[copilot] Rate limit detected in output")] + private partial void LogRateLimitDetected(); + + [LoggerMessage(Level = LogLevel.Error, Message = "[copilot] {FailureMessage}")] + private partial void LogOutputFailure(string failureMessage); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[copilot] {Line}")] + private partial void LogOutputLine(string line); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[copilot] [stderr/warn] {Line}")] + private partial void LogStderrWarn(string line); + + [LoggerMessage(Level = LogLevel.Error, Message = "[copilot] {FailureMessage}")] + private partial void LogStderrFailure(string failureMessage); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[copilot] {Message}")] + private partial void LogStallDetected(string message); } diff --git a/ai-dev.executor.github-models/Extensions/GitHubModelsExecutorExtensions.cs b/ai-dev.executor.github-models/Extensions/GitHubModelsExecutorExtensions.cs index e785ba9..a220505 100644 --- a/ai-dev.executor.github-models/Extensions/GitHubModelsExecutorExtensions.cs +++ b/ai-dev.executor.github-models/Extensions/GitHubModelsExecutorExtensions.cs @@ -19,10 +19,12 @@ public static IServiceCollection AddGitHubModelsExecutor(this IServiceCollection client.Timeout = TimeSpan.FromMinutes(10); }); +#pragma warning disable EXTEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates services.AddHttpClient("github-models-health", client => { client.Timeout = TimeSpan.FromSeconds(5); - }); + }).RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 services.AddSingleton(); return services; diff --git a/ai-dev.executor.github-models/GitHubModelsAgentExecutor.cs b/ai-dev.executor.github-models/GitHubModelsAgentExecutor.cs index 5e5d170..2a1bfd1 100644 --- a/ai-dev.executor.github-models/GitHubModelsAgentExecutor.cs +++ b/ai-dev.executor.github-models/GitHubModelsAgentExecutor.cs @@ -28,7 +28,7 @@ namespace AiDev.Executors; /// Rate limits (HTTP 429) are detected from the response status code and surfaced as /// = true, suppressing re-launches. /// -public sealed class GitHubModelsAgentExecutor( +public sealed partial class GitHubModelsAgentExecutor( IHttpClientFactory httpClientFactory, StudioSettingsService settingsService, ILogger logger) : IAgentExecutor @@ -83,7 +83,7 @@ public async Task CheckHealthAsync(CancellationToken ct = // Catalog returns a top-level array: [ { "id": "openai/gpt-4o", "name": "GPT-4o", ... }, ... ] if (doc.RootElement.ValueKind == JsonValueKind.Array) { - discovered = doc.RootElement.EnumerateArray() + discovered = [.. doc.RootElement.EnumerateArray() .Select(m => { var id = m.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty; @@ -93,8 +93,7 @@ public async Task CheckHealthAsync(CancellationToken ct = .Where(t => t.id.Length > 0) .OrderBy(t => t.id, StringComparer.OrdinalIgnoreCase) .Select(t => new ModelDescriptor(t.id, t.name, AgentExecutorName.GitHubModels, - ModelCapabilities.Streaming | ModelCapabilities.ToolCalling)) - .ToList(); + ModelCapabilities.Streaming | ModelCapabilities.ToolCalling))]; } } catch { /* model list is best-effort */ } @@ -108,7 +107,7 @@ public async Task CheckHealthAsync(CancellationToken ct = catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogDebug(ex, "[github-models-health] Probe failed: {Message}", ex.Message); + LogHealthProbeFailed(ex, ex.Message); return new ExecutorHealthResult(false, $"Connection failed: {ex.Message}"); } } @@ -123,7 +122,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (string.IsNullOrWhiteSpace(token)) { const string msg = "GitHubToken not configured in studio settings. Aborting."; - logger.LogError("[github-models] {Message}", msg); + LogNoTokenConfigured(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -132,9 +131,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var enableTools = GitHubModelsSkills.AreWorkspaceToolsEnabled(context.EnabledSkills); var workspaceRoot = enableTools ? DeriveWorkspaceRoot(context.WorkingDir) : null; - logger.LogInformation( - "[github-models] Starting session — model={Model} tools={Tools}", - context.ModelId, enableTools ? "enabled" : "disabled"); + LogStartingSession(context.ModelId, enableTools ? "enabled" : "disabled"); output.TryWrite($"[{DateTime.UtcNow:o}] [github-models] model={context.ModelId}"); @@ -174,7 +171,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite catch (Exception ex) when (ex is not OperationCanceledException) { var msg = $"Failed to connect to GitHub Models API: {ex.Message}"; - logger.LogError(ex, "[github-models] {Message}", msg); + LogHttpConnectFailed(ex, msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -190,7 +187,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var retryAfter = response.Headers.RetryAfter?.Delta; var hint = retryAfter.HasValue ? $" retry-after={retryAfter.Value.TotalSeconds:F0}s" : ""; var msg = $"GitHub Models rate limit reached.{hint}"; - logger.LogWarning("[github-models] {Message} Body={Body}", msg, body); + LogRateLimitReached(msg, body); output.TryWrite($"[{DateTime.UtcNow:o}] [rate-limit] {msg}"); return new ExecutorResult(1, IsRateLimited: true, ErrorMessage: msg); } @@ -199,12 +196,12 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite { var body = await response.Content.ReadAsStringAsync(context.CancellationToken).ConfigureAwait(false); var msg = $"GitHub Models returned HTTP {(int)response.StatusCode}: {body}"; - logger.LogError("[github-models] {Message}", msg); + LogHttpErrorResponse(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } - logger.LogInformation("[github-models] Streaming response — iteration {N}", iteration); + LogStreamingResponse(iteration); // Stream and parse OpenAI SSE response. var (finishReason, textContent, toolCalls, sessionUsage) = @@ -214,20 +211,17 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (sessionUsage != null) totalUsage = totalUsage == null ? sessionUsage : totalUsage + sessionUsage; - logger.LogInformation("[github-models] finish_reason={Reason} iteration={N}", finishReason, iteration); + LogFinishReason(finishReason, iteration); // Final response: no tool calls. if (toolCalls.Count == 0) { - logger.LogInformation( - "[github-models] Session complete — {Chars} chars | iterations: {N}", - textContent.Length, iteration); + LogSessionComplete(textContent.Length, iteration); return new ExecutorResult(0, Usage: totalUsage); } // Tool calls requested — execute and loop. - logger.LogInformation( - "[github-models] Tool calls requested — count={Count} iteration={N}", toolCalls.Count, iteration); + LogToolCallsRequested(toolCalls.Count, iteration); output.TryWrite($"[{DateTime.UtcNow:o}] [github-models] tool calls: {toolCalls.Count} (iteration {iteration})"); // Append assistant message with tool_calls. @@ -260,7 +254,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var argsJson = tc.Arguments.ToString(); var argsPreview = argsJson.Length > 80 ? argsJson[..80] + "…" : argsJson; output.TryWrite($"[{DateTime.UtcNow:o}] [tool:call] {tc.Name}({argsPreview})"); - logger.LogInformation("[github-models] Executing tool — {Tool}({Args})", tc.Name, argsPreview); + LogExecutingTool(tc.Name, argsPreview); JsonElement argsElement; try { argsElement = JsonDocument.Parse(argsJson).RootElement; } @@ -274,7 +268,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite ? result[..120].Replace('\n', ' ') + "…" : result.Replace('\n', ' '); output.TryWrite($"[{DateTime.UtcNow:o}] [tool:result] {preview}"); - logger.LogInformation("[github-models] Tool result — {Chars} chars", result.Length); + LogToolResult(result.Length); messages.Add(new JsonObject { @@ -286,7 +280,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite } var limitMsg = $"Exceeded maximum tool-call iterations ({MaxToolIterations}). Aborting session."; - logger.LogError("[github-models] {Message}", limitMsg); + LogMaxIterationsExceeded(limitMsg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {limitMsg}"); return new ExecutorResult(1, ErrorMessage: limitMsg); } @@ -444,4 +438,47 @@ private sealed class ToolCall(string id) public string Name { get; set; } = string.Empty; public StringBuilder Arguments { get; } = new(); } + + // ------------------------------------------------------------------------- + // Logger message declarations + // ------------------------------------------------------------------------- + + [LoggerMessage(Level = LogLevel.Debug, Message = "[github-models-health] Probe failed: {Message}")] + private partial void LogHealthProbeFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "[github-models] {Message}")] + private partial void LogNoTokenConfigured(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] Starting session — model={Model} tools={Tools}")] + private partial void LogStartingSession(string model, string tools); + + [LoggerMessage(Level = LogLevel.Error, Message = "[github-models] {Message}")] + private partial void LogHttpConnectFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[github-models] {Message} Body={Body}")] + private partial void LogRateLimitReached(string message, string body); + + [LoggerMessage(Level = LogLevel.Error, Message = "[github-models] {Message}")] + private partial void LogHttpErrorResponse(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] Streaming response — iteration {N}")] + private partial void LogStreamingResponse(int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] finish_reason={Reason} iteration={N}")] + private partial void LogFinishReason(string reason, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] Session complete — {Chars} chars | iterations: {N}")] + private partial void LogSessionComplete(int chars, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] Tool calls requested — count={Count} iteration={N}")] + private partial void LogToolCallsRequested(int count, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] Executing tool — {Tool}({Args})")] + private partial void LogExecutingTool(string tool, string args); + + [LoggerMessage(Level = LogLevel.Information, Message = "[github-models] Tool result — {Chars} chars")] + private partial void LogToolResult(int chars); + + [LoggerMessage(Level = LogLevel.Error, Message = "[github-models] {Message}")] + private partial void LogMaxIterationsExceeded(string message); } diff --git a/ai-dev.executor.lmstudio/Extensions/LmStudioExecutorExtensions.cs b/ai-dev.executor.lmstudio/Extensions/LmStudioExecutorExtensions.cs index 6652b5b..97c8fc1 100644 --- a/ai-dev.executor.lmstudio/Extensions/LmStudioExecutorExtensions.cs +++ b/ai-dev.executor.lmstudio/Extensions/LmStudioExecutorExtensions.cs @@ -17,9 +17,9 @@ public static IServiceCollection AddLmStudioExecutor(this IServiceCollection ser #pragma warning disable EXTEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates services.AddHttpClient("lmstudio", c => c.Timeout = TimeSpan.FromMinutes(10)) .RemoveAllResilienceHandlers(); + services.AddHttpClient("lmstudio-health", c => c.Timeout = TimeSpan.FromSeconds(5)) + .RemoveAllResilienceHandlers(); #pragma warning restore EXTEXP0001 - - services.AddHttpClient("lmstudio-health", c => c.Timeout = TimeSpan.FromSeconds(5)); services.AddSingleton(); return services; } diff --git a/ai-dev.executor.lmstudio/LmStudioAgentExecutor.cs b/ai-dev.executor.lmstudio/LmStudioAgentExecutor.cs index 018626f..3504b57 100644 --- a/ai-dev.executor.lmstudio/LmStudioAgentExecutor.cs +++ b/ai-dev.executor.lmstudio/LmStudioAgentExecutor.cs @@ -24,7 +24,7 @@ namespace AiDev.Executors; /// operations, appends results to the message history, and re-invokes the model — continuing /// until the model produces a final response with no further tool calls. /// -public sealed class LmStudioAgentExecutor( +public sealed partial class LmStudioAgentExecutor( IHttpClientFactory httpClientFactory, StudioSettingsService settingsService, ILogger logger) : IAgentExecutor @@ -101,7 +101,7 @@ public async Task CheckHealthAsync(CancellationToken ct = } catch (Exception ex) { - logger.LogDebug(ex, "[lmstudio-health] Failed to parse model list from {Url}", url); + LogHealthParseModelListFailed(ex, url); } var message = discovered.Count > 0 @@ -116,7 +116,7 @@ public async Task CheckHealthAsync(CancellationToken ct = catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogDebug(ex, "[lmstudio-health] Probe failed: {Message}", ex.Message); + LogHealthProbeFailed(ex, ex.Message); return new ExecutorHealthResult(false, $"Connection refused: {ex.Message}"); } } @@ -152,11 +152,15 @@ private List ParseModelDescriptors(JsonElement models) // LM Studio exposes both the loaded context window (for currently-loaded models) and // the model's maximum supported context. Prefer the loaded value — it reflects what // was actually allocated, which is what matters for preflight budget checks. - var contextWindow = - TryGetInt32(m, "loaded_context_length") - ?? TryGetInt32(m, "max_context_length") - ?? TryGetInt32(m, "context_length") - ?? 0; + // When loaded_context_length is absent (model not yet loaded, or older LM Studio), + // we fall back to the architectural maximum — this can silently cause context starvation + // if the model is later JIT-loaded with LM Studio's default n_ctx (4096). + var loadedContext = TryGetInt32(m, "loaded_context_length"); + var maxContext = TryGetInt32(m, "max_context_length") ?? TryGetInt32(m, "context_length"); + var contextWindow = loadedContext ?? maxContext ?? 0; + + if (loadedContext == null && maxContext != null) + LogModelNoLoadedContextLength(id, maxContext.Value); if (contextWindow > 0) _contextWindows[id] = contextWindow; @@ -236,7 +240,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite || (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps)) { var msg = $"LmStudioBaseUrl '{rawBaseUrl}' is not a valid http/https URL. Aborting."; - logger.LogError("[lmstudio] {Message}", msg); + LogInvalidBaseUrl(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -246,11 +250,8 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var enableTools = LmStudioSkills.AreWorkspaceToolsEnabled(context.EnabledSkills); var workspaceRoot = enableTools ? DeriveWorkspaceRoot(context.WorkingDir) : null; - logger.LogInformation( - "[lmstudio] Starting session — model={Model} endpoint={Uri} tools={Tools}", - context.ModelId, requestUri, enableTools ? "enabled" : "disabled"); - logger.LogInformation( - "[lmstudio] Trigger source={Source} reason={Reason} project={Project} task={TaskId} decision={DecisionId} message={MessageFile}", + LogSessionStarting(context.ModelId, requestUri, enableTools ? "enabled" : "disabled"); + LogSessionTrigger( context.Trigger?.Source, context.Trigger?.Reason, context.Trigger?.ProjectSlug, @@ -279,15 +280,24 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite context.WorkingDir, contextWindow, settings.CompactPromptThreshold, logger); var promptTier = contextWindow > 0 && contextWindow < settings.CompactPromptThreshold ? "compact" : "full"; + var estimatedSystemTokens = TokenBudget.EstimateTokens(systemPrompt); + var estimatedUserTokens = TokenBudget.EstimateTokens(context.Prompt); + var estimatedPromptTokens = estimatedSystemTokens + estimatedUserTokens; + if (contextWindow > 0) + { + var headroom = contextWindow - estimatedPromptTokens - maxOutputTokens; output.TryWrite( - $"[{DateTime.UtcNow:o}] [lmstudio] context_window={contextWindow} max_output_tokens={maxOutputTokens} prompt_tier={promptTier}"); + $"[{DateTime.UtcNow:o}] [lmstudio] context_window={contextWindow} prompt_est={estimatedPromptTokens} max_output={maxOutputTokens} headroom={headroom} tier={promptTier}"); + if (headroom < 256) + LogLowHeadroom(contextWindow, estimatedPromptTokens, maxOutputTokens, headroom, context.ModelId); + } if (!TokenBudget.CanFitCompact(contextWindow, systemPrompt, maxOutputTokens)) { var minRequired = TokenBudget.EstimateTokens(systemPrompt) + maxOutputTokens + TokenBudget.SafetyMargin; var refusal = SystemPromptLoader.BuildRefusalMessage(context.ModelId, contextWindow, minRequired); - logger.LogWarning("[lmstudio] {Refusal}", refusal); + LogContextWindowRefusal(refusal); output.TryWrite($"[{DateTime.UtcNow:o}] {refusal}"); return new ExecutorResult(1, ErrorMessage: refusal, RequiresHumanDecision: true); } @@ -323,7 +333,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var msg = iteration == 1 ? exceeded.Error : exceeded.Error + $" (after {iteration - 1} tool iteration(s))"; - logger.LogError("[lmstudio] {Message}", msg); + LogPreflightExceeded(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, Usage: totalUsage, ErrorMessage: msg, RequiresHumanDecision: true); } @@ -345,7 +355,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite catch (Exception ex) when (ex is not OperationCanceledException) { var msg = $"Failed to connect to LM Studio at {requestUri}: {ex.Message}"; - logger.LogError(ex, "[lmstudio] {Message}", msg); + LogConnectFailed(ex, msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -356,12 +366,12 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite { var body = await response.Content.ReadAsStringAsync(context.CancellationToken).ConfigureAwait(false); var msg = $"LM Studio returned HTTP {(int)response.StatusCode}: {body}"; - logger.LogError("[lmstudio] {Message}", msg); + LogHttpError(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } - logger.LogInformation("[lmstudio] Streaming response — iteration {N}", iteration); + LogStreamingResponse(iteration); // Stream and parse OpenAI SSE response. var (finishReason, textContent, toolCalls, sessionUsage, streamError) = @@ -371,45 +381,48 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (sessionUsage != null) totalUsage = totalUsage == null ? sessionUsage : totalUsage + sessionUsage; - logger.LogInformation("[lmstudio] finish_reason={Reason} iteration={N}", finishReason, iteration); + LogFinishReason(finishReason, iteration); // Surface provider-level errors returned in the SSE stream. if (streamError != null) { var msg = $"LM Studio returned an error: {streamError}"; - logger.LogError("[lmstudio] {Message}", msg); + LogStreamError(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, Usage: totalUsage, ErrorMessage: msg, RequiresHumanDecision: true); } - // Detect empty output — two distinct causes: + // Detect empty output — three distinct causes: // 1. Empty SSE stream (finishReason=""): LM Studio silently drops the request when // the prompt exceeds the loaded context window (n_keep >= n_ctx). - // 2. Thinking-model no-op: Qwen3 and similar models route tokens to internal - // reasoning (reasoning_content). StreamSseAsync promotes reasoning_content to - // textContent when content is empty, so this case only fires when BOTH fields - // are empty despite tokens being consumed — an unrecoverable model failure. - var emptyOutput = textContent.Length == 0 && toolCalls.Count == 0; + // 2. Context starvation: the model loaded with a tiny context window barely fits the + // prompt, leaving almost no room for generation. LM Studio generates 1-2 tokens + // immediately and stops. Shows as finish_reason=stop, OutputTokens<=3, 0ms eval. + // 3. Thinking-model no-op: LM Studio's server-side thinking intercepts all output. + // Shows as finish_reason=stop, OutputTokens>>0 (many thinking tokens consumed). + var emptyOutput = textContent.Length == 0 && toolCalls.Count == 0; var isEmptyStream = emptyOutput && string.IsNullOrEmpty(finishReason); - var isThinkingModelNoOp = emptyOutput && sessionUsage is { OutputTokens: > 0 }; + var outputTokenCount = sessionUsage?.OutputTokens ?? 0; + var isContextStarved = emptyOutput && outputTokenCount is > 0 and <= 3; + var isThinkingModelNoOp = emptyOutput && outputTokenCount > 3; - if (isEmptyStream || isThinkingModelNoOp) + if (isEmptyStream || isContextStarved || isThinkingModelNoOp) { string msg; - if (isThinkingModelNoOp) + if (isContextStarved || isEmptyStream) { - msg = $"Model '{context.ModelId}' finished (finish_reason={finishReason}) but produced no usable output. " - + "If this is a reasoning/thinking model (e.g. Qwen3), disable thinking mode in LM Studio " - + "or load a non-thinking variant of the model."; + var ctxDetail = contextWindow > 0 + ? $"context_window={contextWindow}, estimated prompt tokens={estimatedPromptTokens}, available for response≈{contextWindow - estimatedPromptTokens}" + : "context_window unknown"; + msg = $"Model '{context.ModelId}' produced no usable output — context window is likely too small ({ctxDetail}). " + + $"Reload the model in LM Studio with a larger context window (recommended: {TokenBudget.SuggestContextWindow(estimatedPromptTokens + maxOutputTokens)})."; } else { - var hint = contextWindow > 0 - ? $" The model has context_window={contextWindow}; reload it with a larger context or switch models." - : " Check LM Studio server logs for details (often n_keep >= n_ctx)."; - msg = $"LM Studio returned an empty response for '{context.ModelId}'.{hint}"; + msg = $"Model '{context.ModelId}' finished (finish_reason={finishReason}) but produced no usable output ({outputTokenCount} tokens consumed, likely all internal reasoning). " + + "If thinking mode is enabled in LM Studio, disable it for this model or load a non-thinking variant."; } - logger.LogError("[lmstudio] {Message}", msg); + LogEmptyOutput(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, Usage: totalUsage, ErrorMessage: msg, RequiresHumanDecision: true); } @@ -417,15 +430,12 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite // Final response: no tool calls. if (toolCalls.Count == 0) { - logger.LogInformation( - "[lmstudio] Session complete — {Chars} chars | iterations: {N}", - textContent.Length, iteration); + LogSessionComplete(textContent.Length, iteration); return new ExecutorResult(0, Usage: totalUsage); } // Tool calls requested — execute and loop. - logger.LogInformation( - "[lmstudio] Tool calls requested — count={Count} iteration={N}", toolCalls.Count, iteration); + LogToolCallsRequested(toolCalls.Count, iteration); output.TryWrite($"[{DateTime.UtcNow:o}] [lmstudio] tool calls: {toolCalls.Count} (iteration {iteration})"); // Append assistant message with tool_calls. @@ -458,7 +468,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var argsJson = tc.Arguments.ToString(); var argsPreview = argsJson.Length > 80 ? argsJson[..80] + "..." : argsJson; output.TryWrite($"[{DateTime.UtcNow:o}] [tool:call] {tc.Name}({argsPreview})"); - logger.LogInformation("[lmstudio] Executing tool — {Tool}({Args})", tc.Name, argsPreview); + LogExecutingTool(tc.Name, argsPreview); JsonElement argsElement; try { argsElement = JsonDocument.Parse(argsJson).RootElement; } @@ -472,7 +482,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite ? result[..120].Replace('\n', ' ') + "..." : result.Replace('\n', ' '); output.TryWrite($"[{DateTime.UtcNow:o}] [tool:result] {preview}"); - logger.LogInformation("[lmstudio] Tool result — {Chars} chars", result.Length); + LogToolResult(result.Length); messages.Add(new JsonObject { @@ -484,7 +494,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite } var limitMsg = $"Exceeded maximum tool-call iterations ({MaxToolIterations}). Aborting session."; - logger.LogError("[lmstudio] {Message}", limitMsg); + LogMaxIterationsExceeded(limitMsg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {limitMsg}"); return new ExecutorResult(1, ErrorMessage: limitMsg); } @@ -677,7 +687,7 @@ private async Task ResolveContextWindowAsync(string modelId, CancellationTo } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogDebug(ex, "[lmstudio] On-demand health probe failed while resolving context window"); + LogOnDemandProbeFailed(ex); } return _contextWindows.TryGetValue(modelId, out var resolved) ? resolved : 0; @@ -693,4 +703,71 @@ private sealed class ToolCall(string id) public string Name { get; set; } = string.Empty; public StringBuilder Arguments { get; } = new(); } + + // ------------------------------------------------------------------------- + // Logger messages + // ------------------------------------------------------------------------- + + [LoggerMessage(Level = LogLevel.Debug, Message = "[lmstudio-health] Failed to parse model list from {Url}")] + private partial void LogHealthParseModelListFailed(Exception ex, string url); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[lmstudio-health] Probe failed: {Message}")] + private partial void LogHealthProbeFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[lmstudio] Model '{Id}' has no loaded_context_length — using max_context_length={Max} as estimate. If the model JIT-loads with a small n_ctx (e.g. 4096), prompts near that size will cause context starvation. Load the model in LM Studio first and set an explicit context window.")] + private partial void LogModelNoLoadedContextLength(string id, int max); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogInvalidBaseUrl(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Starting session — model={Model} endpoint={Uri} tools={Tools}")] + private partial void LogSessionStarting(string model, string uri, string tools); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Trigger source={Source} reason={Reason} project={Project} task={TaskId} decision={DecisionId} message={MessageFile}")] + private partial void LogSessionTrigger(string? source, string? reason, string? project, string? taskId, string? decisionId, string? messageFile); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[lmstudio] Low headroom: context_window={Ctx} prompt_est={Prompt} max_output={Out} headroom={Headroom} — reload '{Model}' with a larger context window")] + private partial void LogLowHeadroom(int ctx, int prompt, int @out, int headroom, string model); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[lmstudio] {Refusal}")] + private partial void LogContextWindowRefusal(string refusal); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogPreflightExceeded(string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogConnectFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogHttpError(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Streaming response — iteration {N}")] + private partial void LogStreamingResponse(int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] finish_reason={Reason} iteration={N}")] + private partial void LogFinishReason(string reason, int n); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogStreamError(string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogEmptyOutput(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Session complete — {Chars} chars | iterations: {N}")] + private partial void LogSessionComplete(int chars, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Tool calls requested — count={Count} iteration={N}")] + private partial void LogToolCallsRequested(int count, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Executing tool — {Tool}({Args})")] + private partial void LogExecutingTool(string tool, string args); + + [LoggerMessage(Level = LogLevel.Information, Message = "[lmstudio] Tool result — {Chars} chars")] + private partial void LogToolResult(int chars); + + [LoggerMessage(Level = LogLevel.Error, Message = "[lmstudio] {Message}")] + private partial void LogMaxIterationsExceeded(string message); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[lmstudio] On-demand health probe failed while resolving context window")] + private partial void LogOnDemandProbeFailed(Exception ex); } diff --git a/ai-dev.executor.ollama/Extensions/OllamaExecutorExtensions.cs b/ai-dev.executor.ollama/Extensions/OllamaExecutorExtensions.cs index 303fef0..f01459a 100644 --- a/ai-dev.executor.ollama/Extensions/OllamaExecutorExtensions.cs +++ b/ai-dev.executor.ollama/Extensions/OllamaExecutorExtensions.cs @@ -19,9 +19,9 @@ public static IServiceCollection AddOllamaExecutor(this IServiceCollection servi #pragma warning disable EXTEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates services.AddHttpClient("ollama", c => c.Timeout = TimeSpan.FromMinutes(10)) .RemoveAllResilienceHandlers(); + services.AddHttpClient("ollama-health", c => c.Timeout = TimeSpan.FromSeconds(5)) + .RemoveAllResilienceHandlers(); #pragma warning restore EXTEXP0001 - - services.AddHttpClient("ollama-health", c => c.Timeout = TimeSpan.FromSeconds(5)); services.AddSingleton(); return services; } diff --git a/ai-dev.executor.ollama/OllamaAgentExecutor.cs b/ai-dev.executor.ollama/OllamaAgentExecutor.cs index cf76a47..2278f74 100644 --- a/ai-dev.executor.ollama/OllamaAgentExecutor.cs +++ b/ai-dev.executor.ollama/OllamaAgentExecutor.cs @@ -25,7 +25,7 @@ namespace AiDev.Executors; /// Tool implementations live in and delegate directly /// to the MCP server implementation without the protocol overhead. /// -public class OllamaAgentExecutor( +public partial class OllamaAgentExecutor( IHttpClientFactory httpClientFactory, StudioSettingsService settingsService, ILogger logger) : IAgentExecutor @@ -116,7 +116,7 @@ public async Task CheckHealthAsync(CancellationToken ct = catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogDebug(ex, "[ollama-health] Probe failed: {Message}", ex.Message); + LogHealthProbeFailed(ex, ex.Message); return new ExecutorHealthResult(false, $"Connection refused: {ex.Message}"); } } @@ -164,7 +164,7 @@ private async Task FetchContextLengthAsync(string baseUrl, string modelName catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogDebug(ex, "[ollama-health] /api/show failed for {Model}", modelName); + LogApiShowFailed(ex, modelName); return 0; } } @@ -191,7 +191,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite || (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps)) { var msg = $"OllamaBaseUrl '{rawBaseUrl}' is not a valid http/https URL. Aborting."; - logger.LogError("[ollama] {Message}", msg); + LogInvalidBaseUrl(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -203,16 +203,13 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (enableTools && OllamaToolSupport.IsKnownUnsupportedModel(context.ModelId)) { var msg = OllamaToolSupport.GetUnsupportedToolsMessage(context.ModelId); - logger.LogWarning("[ollama] {Message}", msg); + LogUnsupportedModelWarning(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, PreserveInbox: true, ErrorMessage: msg); } - logger.LogInformation( - "[ollama] Starting session — model={Model} endpoint={Uri} tools={Tools}", - context.ModelId, requestUri, enableTools ? "enabled" : "disabled"); - logger.LogInformation( - "[ollama] Trigger source={Source} reason={Reason} project={Project} task={TaskId} decision={DecisionId} message={MessageFile}", + LogStartingSession(context.ModelId, requestUri, enableTools ? "enabled" : "disabled"); + LogTriggerDetails( context.Trigger?.Source, context.Trigger?.Reason, context.Trigger?.ProjectSlug, @@ -243,7 +240,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite { var minRequired = TokenBudget.EstimateTokens(systemPrompt) + maxOutputTokens + TokenBudget.SafetyMargin; var refusal = SystemPromptLoader.BuildRefusalMessage(context.ModelId, contextWindow, minRequired); - logger.LogWarning("[ollama] {Refusal}", refusal); + LogPromptTooLarge(refusal); output.TryWrite($"[{DateTime.UtcNow:o}] {refusal}"); return new ExecutorResult(1, ErrorMessage: refusal); } @@ -276,7 +273,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var msg = iteration == 1 ? exceeded.Error : exceeded.Error + $" (after {iteration - 1} tool iteration(s))"; - logger.LogError("[ollama] {Message}", msg); + LogPreflightExceeded(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, Usage: totalUsage, ErrorMessage: msg); } @@ -323,7 +320,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite catch (Exception ex) when (ex is not OperationCanceledException) { var msg = $"Failed to connect to Ollama at {requestUri}: {ex.Message}"; - logger.LogError(ex, "[ollama] {Message}", msg); + LogHttpConnectFailed(ex, msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } @@ -333,19 +330,18 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var body = await response.Content.ReadAsStringAsync(context.CancellationToken).ConfigureAwait(false); if (enableTools && OllamaToolSupport.TryGetUnsupportedToolsMessage(context.ModelId, body, out var unsupportedToolsMessage)) { - logger.LogWarning("[ollama] Request rejected because workspace tools are unsupported for model {Model}: {Body}", - context.ModelId, body); + LogToolsUnsupportedForModel(context.ModelId, body); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {unsupportedToolsMessage}"); return new ExecutorResult(1, PreserveInbox: true, ErrorMessage: unsupportedToolsMessage); } var msg = $"Ollama returned HTTP {(int)response.StatusCode}: {body}"; - logger.LogError("[ollama] {Message}", msg); + LogHttpErrorResponse(msg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {msg}"); return new ExecutorResult(1, ErrorMessage: msg); } - logger.LogInformation("[ollama] Streaming response — iteration {N}", iteration); + LogStreamingResponse(iteration); // --- Stream the response body --- await using var stream = await response.Content.ReadAsStreamAsync(context.CancellationToken).ConfigureAwait(false); @@ -364,7 +360,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite try { chunk = JsonSerializer.Deserialize(line, OllamaJsonOptions); } catch (JsonException ex) { - logger.LogWarning(ex, "[ollama] Failed to parse chunk: {Line}", line); + LogChunkParseFailed(ex, line); continue; } @@ -405,7 +401,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite if (toolCalls is { Count: > 0 } && enableTools && workspaceRoot != null) { - logger.LogInformation("[ollama] Tool calls requested — count={Count} iteration={N}", toolCalls.Count, iteration); + LogToolCallsRequested(toolCalls.Count, iteration); output.TryWrite($"[{DateTime.UtcNow:o}] [ollama] tool calls: {toolCalls.Count} (iteration {iteration})"); // Accumulate token usage from this tool-calling iteration before continuing. @@ -438,13 +434,13 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite var argsPreview = ArgsPreview(toolArgs); output.TryWrite($"[{DateTime.UtcNow:o}] [tool:call] {toolName}({argsPreview})"); - logger.LogInformation("[ollama] Executing tool — {Tool}({Args})", toolName, argsPreview); + LogExecutingTool(toolName, argsPreview); var result = WorkspaceTools.Execute(workspaceRoot, toolName, toolArgs); var resultPreview = result.Length > 120 ? result[..120].Replace('\n', ' ') + "…" : result.Replace('\n', ' '); output.TryWrite($"[{DateTime.UtcNow:o}] [tool:result] {resultPreview}"); - logger.LogInformation("[ollama] Tool result — {Chars} chars", result.Length); + LogToolResult(result.Length); messages.Add(new JsonObject { ["role"] = "tool", ["content"] = result }); } @@ -457,7 +453,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite // Stream ended without done=true; emit whatever was collected. if (finalChunk == null) - logger.LogWarning("[ollama] Stream ended without done=true"); + LogStreamEndedWithoutDone(); var promptTokens = finalChunk?.PromptEvalCount ?? 0; var outputTokens = finalChunk?.EvalCount ?? 0; @@ -465,9 +461,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite ? finalChunk.TotalDuration.Value / 1_000_000.0 : 0; - logger.LogInformation( - "[ollama] Session complete — {Chars} chars | tokens: {In} in / {Out} out | {Ms:F0} ms | iterations: {N}", - responseText.Length, promptTokens, outputTokens, durationMs, iteration); + LogSessionComplete(responseText.Length, promptTokens, outputTokens, durationMs, iteration); if (promptTokens > 0 || outputTokens > 0) output.TryWrite( @@ -486,7 +480,7 @@ public async Task RunAsync(ExecutorContext context, ChannelWrite // Reached MaxToolIterations without a final response. var limitMsg = $"Exceeded maximum tool-call iterations ({MaxToolIterations}). Aborting session."; - logger.LogError("[ollama] {Message}", limitMsg); + LogMaxIterationsExceeded(limitMsg); output.TryWrite($"[{DateTime.UtcNow:o}] [error] {limitMsg}"); return new ExecutorResult(1, ErrorMessage: limitMsg); } @@ -587,4 +581,65 @@ private sealed class OllamaToolCallFunction public string? Name { get; set; } public JsonElement Arguments { get; set; } } + + // ------------------------------------------------------------------------- + // Logger message declarations + // ------------------------------------------------------------------------- + + [LoggerMessage(Level = LogLevel.Debug, Message = "[ollama-health] Probe failed: {Message}")] + private partial void LogHealthProbeFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Debug, Message = "[ollama-health] /api/show failed for {Model}")] + private partial void LogApiShowFailed(Exception ex, string model); + + [LoggerMessage(Level = LogLevel.Error, Message = "[ollama] {Message}")] + private partial void LogInvalidBaseUrl(string message); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[ollama] {Message}")] + private partial void LogUnsupportedModelWarning(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Starting session — model={Model} endpoint={Uri} tools={Tools}")] + private partial void LogStartingSession(string model, string uri, string tools); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Trigger source={Source} reason={Reason} project={Project} task={TaskId} decision={DecisionId} message={MessageFile}")] + private partial void LogTriggerDetails(string? source, string? reason, string? project, string? taskId, string? decisionId, string? messageFile); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[ollama] {Refusal}")] + private partial void LogPromptTooLarge(string refusal); + + [LoggerMessage(Level = LogLevel.Error, Message = "[ollama] {Message}")] + private partial void LogPreflightExceeded(string message); + + [LoggerMessage(Level = LogLevel.Error, Message = "[ollama] {Message}")] + private partial void LogHttpConnectFailed(Exception ex, string message); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[ollama] Request rejected because workspace tools are unsupported for model {Model}: {Body}")] + private partial void LogToolsUnsupportedForModel(string model, string body); + + [LoggerMessage(Level = LogLevel.Error, Message = "[ollama] {Message}")] + private partial void LogHttpErrorResponse(string message); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Streaming response — iteration {N}")] + private partial void LogStreamingResponse(int n); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[ollama] Failed to parse chunk: {Line}")] + private partial void LogChunkParseFailed(Exception ex, string line); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Tool calls requested — count={Count} iteration={N}")] + private partial void LogToolCallsRequested(int count, int n); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Executing tool — {Tool}({Args})")] + private partial void LogExecutingTool(string tool, string args); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Tool result — {Chars} chars")] + private partial void LogToolResult(int chars); + + [LoggerMessage(Level = LogLevel.Warning, Message = "[ollama] Stream ended without done=true")] + private partial void LogStreamEndedWithoutDone(); + + [LoggerMessage(Level = LogLevel.Information, Message = "[ollama] Session complete — {Chars} chars | tokens: {In} in / {Out} out | {Ms:F0} ms | iterations: {N}")] + private partial void LogSessionComplete(int chars, int @in, int @out, double ms, int n); + + [LoggerMessage(Level = LogLevel.Error, Message = "[ollama] {Message}")] + private partial void LogMaxIterationsExceeded(string message); } diff --git a/ai-dev.ui.winui/App.xaml.cs b/ai-dev.ui.winui/App.xaml.cs index f99d774..772e6d8 100644 --- a/ai-dev.ui.winui/App.xaml.cs +++ b/ai-dev.ui.winui/App.xaml.cs @@ -23,12 +23,14 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) { try { - _host = Host.CreateDefaultBuilder() - .ConfigureAppConfiguration(cfg => - cfg.AddJsonFile("appsettings.json", optional: true)) - .ConfigureServices((ctx, services) => - ConfigureServices(ctx.Configuration, services)) - .Build(); + var builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddJsonFile("appsettings.json", optional: true); + builder.Configuration.AddJsonFile( + Path.Combine(AppContext.BaseDirectory, FilePathConstants.StudioSettingsFileName), + optional: true, reloadOnChange: false); + builder.AddServiceDefaults(); + ConfigureServices(builder.Configuration, builder.Services); + _host = builder.Build(); await _host.StartAsync(); diff --git a/ai-dev.ui.winui/Converters/AgentStatusColorConverter.cs b/ai-dev.ui.winui/Converters/AgentStatusColorConverter.cs new file mode 100644 index 0000000..5f414d5 --- /dev/null +++ b/ai-dev.ui.winui/Converters/AgentStatusColorConverter.cs @@ -0,0 +1,15 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace AiDev.WinUI.Converters; + +public class AgentStatusColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is AgentStatus s ? ConverterHelpers.BrushFromHex(s.ColorHex) : new SolidColorBrush(Colors.Gray); + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/AgentStatusTextConverter.cs b/ai-dev.ui.winui/Converters/AgentStatusTextConverter.cs new file mode 100644 index 0000000..31a8eaf --- /dev/null +++ b/ai-dev.ui.winui/Converters/AgentStatusTextConverter.cs @@ -0,0 +1,13 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class AgentStatusTextConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is AgentStatus s ? s.DisplayName : "Idle"; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/BoolToVisibilityConverter.cs b/ai-dev.ui.winui/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..9615aec --- /dev/null +++ b/ai-dev.ui.winui/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,13 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class BoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is true ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => value is Visibility.Visible; +} diff --git a/ai-dev.ui.winui/Converters/ConverterHelpers.cs b/ai-dev.ui.winui/Converters/ConverterHelpers.cs new file mode 100644 index 0000000..3e4fcf0 --- /dev/null +++ b/ai-dev.ui.winui/Converters/ConverterHelpers.cs @@ -0,0 +1,25 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml.Media; + +using Windows.UI; + +namespace AiDev.WinUI.Converters; + +static class ConverterHelpers +{ + public static SolidColorBrush BrushFromHex(string hex) + { + try + { + if (hex.StartsWith('#') && hex.Length == 7) + { + var r = System.Convert.ToByte(hex.Substring(1, 2), 16); + var g = System.Convert.ToByte(hex.Substring(3, 2), 16); + var b = System.Convert.ToByte(hex.Substring(5, 2), 16); + return new SolidColorBrush(Color.FromArgb(255, r, g, b)); + } + } + catch { } + return new SolidColorBrush(Colors.Gray); + } +} diff --git a/ai-dev.ui.winui/Converters/Converters.cs b/ai-dev.ui.winui/Converters/Converters.cs deleted file mode 100644 index f98c056..0000000 --- a/ai-dev.ui.winui/Converters/Converters.cs +++ /dev/null @@ -1,187 +0,0 @@ -using AiDev.Executors; -using AiDev.Models.Types; - -using Microsoft.UI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Media; - -using Windows.UI; - -namespace AiDev.WinUI.Converters; - -public class BoolToVisibilityConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is true ? Visibility.Visible : Visibility.Collapsed; - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => value is Visibility.Visible; -} - -public class InverseBoolToVisibilityConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is true ? Visibility.Collapsed : Visibility.Visible; - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => value is not Visibility.Visible; -} - -public class InverseBoolConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is not true; - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => value is not true; -} - -public class NullToVisibilityConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - { - var isNullOrEmpty = value is null || (value is string s && string.IsNullOrWhiteSpace(s)); - var inverse = parameter is string text && text.Equals("inverse", StringComparison.OrdinalIgnoreCase); - return inverse - ? (isNullOrEmpty ? Visibility.Visible : Visibility.Collapsed) - : (isNullOrEmpty ? Visibility.Collapsed : Visibility.Visible); - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class StringHexToBrushConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is string hex && hex.StartsWith('#') && hex.Length == 7) - { - try - { - var r = System.Convert.ToByte(hex.Substring(1, 2), 16); - var g = System.Convert.ToByte(hex.Substring(3, 2), 16); - var b = System.Convert.ToByte(hex.Substring(5, 2), 16); - return new SolidColorBrush(Color.FromArgb(255, r, g, b)); - } - catch (FormatException ex) - { - System.Diagnostics.Debug.WriteLine($"Invalid hex color '{hex}': {ex.Message}"); - } - } - return new SolidColorBrush(Colors.Gray); - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class PriorityColorConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is Priority p ? ConverterHelpers.BrushFromHex(p.ColorHex) : new SolidColorBrush(Colors.Gray); - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class PriorityTextConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is Priority p ? p.DisplayName : ""; - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class AgentStatusColorConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is AgentStatus s ? ConverterHelpers.BrushFromHex(s.ColorHex) : new SolidColorBrush(Colors.Gray); - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class AgentStatusTextConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is AgentStatus s ? s.DisplayName : "Idle"; - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class DateTimeToDisplayStringConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is DateTime dt) - { - var format = parameter as string ?? "MMM d"; - return dt.ToLocalTime().ToString(format); - } - return parameter as string == "HH:mm" ? string.Empty : "—"; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class NullableDateTimeToDisplayStringConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is DateTime dt) - { - var format = parameter as string ?? "MMM d"; - return dt.ToLocalTime().ToString(format); - } - return parameter as string == "HH:mm" ? string.Empty : "—"; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class LongToFormattedStringConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is long number) - return number.ToString("N0"); - return "0"; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -public class ThinkingLevelToDisplayNameConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, string language) - => value is ThinkingLevel level ? level.DisplayName : ""; - - public object ConvertBack(object value, Type targetType, object parameter, string language) - => DependencyProperty.UnsetValue; -} - -file static class ConverterHelpers -{ - public static SolidColorBrush BrushFromHex(string hex) - { - try - { - if (hex.StartsWith('#') && hex.Length == 7) - { - var r = System.Convert.ToByte(hex.Substring(1, 2), 16); - var g = System.Convert.ToByte(hex.Substring(3, 2), 16); - var b = System.Convert.ToByte(hex.Substring(5, 2), 16); - return new SolidColorBrush(Color.FromArgb(255, r, g, b)); - } - } - catch { } - return new SolidColorBrush(Colors.Gray); - } -} diff --git a/ai-dev.ui.winui/Converters/DateTimeToDisplayStringConverter.cs b/ai-dev.ui.winui/Converters/DateTimeToDisplayStringConverter.cs new file mode 100644 index 0000000..3ce078c --- /dev/null +++ b/ai-dev.ui.winui/Converters/DateTimeToDisplayStringConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class DateTimeToDisplayStringConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DateTime dt) + { + var format = parameter as string ?? "MMM d"; + return dt.ToLocalTime().ToString(format); + } + return parameter as string == "HH:mm" ? string.Empty : "—"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/InverseBoolConverter.cs b/ai-dev.ui.winui/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..d16511b --- /dev/null +++ b/ai-dev.ui.winui/Converters/InverseBoolConverter.cs @@ -0,0 +1,12 @@ +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class InverseBoolConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is not true; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => value is not true; +} diff --git a/ai-dev.ui.winui/Converters/InverseBoolToVisibilityConverter.cs b/ai-dev.ui.winui/Converters/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000..d6e50c9 --- /dev/null +++ b/ai-dev.ui.winui/Converters/InverseBoolToVisibilityConverter.cs @@ -0,0 +1,13 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class InverseBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is true ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => value is not Visibility.Visible; +} diff --git a/ai-dev.ui.winui/Converters/LongToFormattedStringConverter.cs b/ai-dev.ui.winui/Converters/LongToFormattedStringConverter.cs new file mode 100644 index 0000000..781bf3a --- /dev/null +++ b/ai-dev.ui.winui/Converters/LongToFormattedStringConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class LongToFormattedStringConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is long number) + return number.ToString("N0"); + return "0"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/NullToVisibilityConverter.cs b/ai-dev.ui.winui/Converters/NullToVisibilityConverter.cs new file mode 100644 index 0000000..01e4522 --- /dev/null +++ b/ai-dev.ui.winui/Converters/NullToVisibilityConverter.cs @@ -0,0 +1,19 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class NullToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var isNullOrEmpty = value is null || (value is string s && string.IsNullOrWhiteSpace(s)); + var inverse = parameter is string text && text.Equals("inverse", StringComparison.OrdinalIgnoreCase); + return inverse + ? (isNullOrEmpty ? Visibility.Visible : Visibility.Collapsed) + : (isNullOrEmpty ? Visibility.Collapsed : Visibility.Visible); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/NullableDateTimeToDisplayStringConverter.cs b/ai-dev.ui.winui/Converters/NullableDateTimeToDisplayStringConverter.cs new file mode 100644 index 0000000..a3374cc --- /dev/null +++ b/ai-dev.ui.winui/Converters/NullableDateTimeToDisplayStringConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class NullableDateTimeToDisplayStringConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DateTime dt) + { + var format = parameter as string ?? "MMM d"; + return dt.ToLocalTime().ToString(format); + } + return parameter as string == "HH:mm" ? string.Empty : "—"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/PriorityColorConverter.cs b/ai-dev.ui.winui/Converters/PriorityColorConverter.cs new file mode 100644 index 0000000..0b221fa --- /dev/null +++ b/ai-dev.ui.winui/Converters/PriorityColorConverter.cs @@ -0,0 +1,15 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace AiDev.WinUI.Converters; + +public class PriorityColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is Priority p ? ConverterHelpers.BrushFromHex(p.ColorHex) : new SolidColorBrush(Colors.Gray); + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/PriorityTextConverter.cs b/ai-dev.ui.winui/Converters/PriorityTextConverter.cs new file mode 100644 index 0000000..dc0a141 --- /dev/null +++ b/ai-dev.ui.winui/Converters/PriorityTextConverter.cs @@ -0,0 +1,13 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class PriorityTextConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is Priority p ? p.DisplayName : ""; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/StringHexToBrushConverter.cs b/ai-dev.ui.winui/Converters/StringHexToBrushConverter.cs new file mode 100644 index 0000000..298bca2 --- /dev/null +++ b/ai-dev.ui.winui/Converters/StringHexToBrushConverter.cs @@ -0,0 +1,33 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +using Windows.UI; + +namespace AiDev.WinUI.Converters; + +public class StringHexToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is string hex && hex.StartsWith('#') && hex.Length == 7) + { + try + { + var r = System.Convert.ToByte(hex.Substring(1, 2), 16); + var g = System.Convert.ToByte(hex.Substring(3, 2), 16); + var b = System.Convert.ToByte(hex.Substring(5, 2), 16); + return new SolidColorBrush(Color.FromArgb(255, r, g, b)); + } + catch (FormatException ex) + { + System.Diagnostics.Debug.WriteLine($"Invalid hex color '{hex}': {ex.Message}"); + } + } + return new SolidColorBrush(Colors.Gray); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/Converters/ThinkingLevelToDisplayNameConverter.cs b/ai-dev.ui.winui/Converters/ThinkingLevelToDisplayNameConverter.cs new file mode 100644 index 0000000..6e9766c --- /dev/null +++ b/ai-dev.ui.winui/Converters/ThinkingLevelToDisplayNameConverter.cs @@ -0,0 +1,15 @@ +using AiDev.Executors; + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AiDev.WinUI.Converters; + +public class ThinkingLevelToDisplayNameConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => value is ThinkingLevel level ? level.DisplayName : ""; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => DependencyProperty.UnsetValue; +} diff --git a/ai-dev.ui.winui/MainWindow.xaml.cs b/ai-dev.ui.winui/MainWindow.xaml.cs index ef25fb1..8c6dfb9 100644 --- a/ai-dev.ui.winui/MainWindow.xaml.cs +++ b/ai-dev.ui.winui/MainWindow.xaml.cs @@ -103,18 +103,12 @@ public void NavigateToProject(ProjectDetail project) private void UpdateNavBadges() { - if (_messagesNavItem != null) - { - _messagesNavItem.InfoBadge = _viewModel.UnreadMessageCount > 0 + _messagesNavItem?.InfoBadge = _viewModel.UnreadMessageCount > 0 ? new InfoBadge { Value = _viewModel.UnreadMessageCount } : null; - } - if (_decisionsNavItem != null) - { - _decisionsNavItem.InfoBadge = _viewModel.PendingDecisionCount > 0 + _decisionsNavItem?.InfoBadge = _viewModel.PendingDecisionCount > 0 ? new InfoBadge { Value = _viewModel.PendingDecisionCount } : null; - } } private void HomeHeader_Click(object sender, RoutedEventArgs e) diff --git a/ai-dev.ui.winui/ViewModels/AgentDashboardViewModel.cs b/ai-dev.ui.winui/ViewModels/AgentDashboardViewModel.cs index bf3d631..d9a2092 100644 --- a/ai-dev.ui.winui/ViewModels/AgentDashboardViewModel.cs +++ b/ai-dev.ui.winui/ViewModels/AgentDashboardViewModel.cs @@ -54,10 +54,10 @@ public Task LoadAsync() // Detect agents that have automatically failed over to a backup executor FailoverAlerts.Clear(); - foreach (var a in agents.Where(a => a.FailoverExecutor != null)) + foreach (var a in agents.Where(a => a.Failover != null)) { - var when = a.FailedOverAt.HasValue ? a.FailedOverAt.Value.ToString("HH:mm") : "recently"; - FailoverAlerts.Add($"{a.Name} failed over to {a.FailoverExecutor!.Value} at {when}"); + var when = a.Failover!.OccurredAt.ToString("HH:mm"); + FailoverAlerts.Add($"{a.Name} failed over to {a.Failover.Executor.Value} at {when}"); } HasFailoverAlerts = FailoverAlerts.Count > 0; @@ -118,7 +118,7 @@ public async Task StopAgentAsync(AgentCardViewModel card) return "Name is required."; var resolvedSlug = string.IsNullOrWhiteSpace(slug) - ? new string(name.Trim().ToLowerInvariant().Replace(" ", "-").Where(c => char.IsLetterOrDigit(c) || c == '-').ToArray()).Trim('-') + ? new string([.. name.Trim().ToLowerInvariant().Replace(" ", "-").Where(c => char.IsLetterOrDigit(c) || c == '-')]).Trim('-') : slug.Trim(); if (string.IsNullOrWhiteSpace(resolvedSlug)) diff --git a/ai-dev.ui.winui/ViewModels/DecisionsViewModel.cs b/ai-dev.ui.winui/ViewModels/DecisionsViewModel.cs index faadf5f..e01c555 100644 --- a/ai-dev.ui.winui/ViewModels/DecisionsViewModel.cs +++ b/ai-dev.ui.winui/ViewModels/DecisionsViewModel.cs @@ -150,6 +150,15 @@ private async Task PollSelectedDecisionAsync() return; } + // Replace the item in Decisions with the fresh fetch before assigning SelectedDecision. + // The ListView's TwoWay SelectedItem binding uses reference equality; setting SelectedDecision + // to a new object not present in the collection coerces SelectedItem to null, which fires back + // through the binding and clears SelectedDecision — silently breaking subsequent sends. + var idx = -1; + for (var i = 0; i < Decisions.Count; i++) + if (Decisions[i].Id == latest.Id) { idx = i; break; } + if (idx >= 0) Decisions[idx] = latest; + SelectedDecision = latest; RefreshChat(latest); diff --git a/ai-dev.ui.winui/ViewModels/DigestViewModel.cs b/ai-dev.ui.winui/ViewModels/DigestViewModel.cs index 5d03d10..9d20327 100644 --- a/ai-dev.ui.winui/ViewModels/DigestViewModel.cs +++ b/ai-dev.ui.winui/ViewModels/DigestViewModel.cs @@ -32,9 +32,9 @@ public void Load() IsLoading = true; try { - var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + var today = DateOnly.FromDateTime(DateTime.UtcNow); var digest = _digestService.GetDigest(CurrentSlug, today); - DigestDate = digest.Date; + DigestDate = digest.Date.ToString("yyyy-MM-dd"); TotalMessages = digest.TotalMessages; PendingDecisions = digest.PendingDecisions; ResolvedDecisions = digest.ResolvedDecisions; diff --git a/ai-dev.ui.winui/ViewModels/IUiDispatcher.cs b/ai-dev.ui.winui/ViewModels/IUiDispatcher.cs index e1091af..c25be3b 100644 --- a/ai-dev.ui.winui/ViewModels/IUiDispatcher.cs +++ b/ai-dev.ui.winui/ViewModels/IUiDispatcher.cs @@ -1,14 +1,28 @@ namespace AiDev.WinUI.ViewModels; +/// +/// Provides a UI-thread dispatch abstraction for view models. +/// public interface IUiDispatcher { + /// + /// Enqueues an action to run on the UI thread. + /// + /// The action to execute. void Enqueue(Action action); } +/// +/// Dispatches work to the current WinUI dispatcher queue. +/// public sealed class DispatcherQueueUiDispatcher : IUiDispatcher { private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + /// + /// Enqueues an action to run on the UI thread. + /// + /// The action to execute. public void Enqueue(Action action) { ArgumentNullException.ThrowIfNull(action); diff --git a/ai-dev.ui.winui/ViewModels/MainViewModel.cs b/ai-dev.ui.winui/ViewModels/MainViewModel.cs index ee2fc67..2507e71 100644 --- a/ai-dev.ui.winui/ViewModels/MainViewModel.cs +++ b/ai-dev.ui.winui/ViewModels/MainViewModel.cs @@ -6,12 +6,18 @@ namespace AiDev.WinUI.ViewModels; +/// +/// Represents executor health status for UI display. +/// public partial class ExecutorStatusItem : ObservableObject { [ObservableProperty] public partial string Name { get; set; } = ""; [ObservableProperty] public partial string HealthColor { get; set; } = "#6B7280"; } +/// +/// Provides shared application shell state for the main UI. +/// public partial class MainViewModel : ObservableObject { private readonly ExecutorHealthMonitor _healthMonitor; @@ -34,6 +40,12 @@ public TaskId? PendingTaskId public ObservableCollection ExecutorStatuses { get; } = []; + /// + /// Initializes a new instance of the class. + /// + /// The executor health monitor. + /// The messages service. + /// The decisions service. public MainViewModel(ExecutorHealthMonitor healthMonitor, MessagesService messagesService, DecisionsService decisionsService) { _healthMonitor = healthMonitor; @@ -41,11 +53,18 @@ public MainViewModel(ExecutorHealthMonitor healthMonitor, MessagesService messag _decisionsService = decisionsService; } + /// + /// Sets the active project for the shell. + /// + /// The active project. public void SetActiveProject(ProjectDetail? project) { ActiveProject = project; } + /// + /// Refreshes navigation badge counts for the active project. + /// public void RefreshNavBadges() { if (ActiveProject is null) @@ -74,6 +93,10 @@ public void RefreshNavBadges() } } + /// + /// Loads executor health status items for display. + /// + /// A task that completes when executor statuses are loaded. public async Task LoadExecutorStatusesAsync() { try @@ -95,6 +118,10 @@ public async Task LoadExecutorStatusesAsync() } } + /// + /// Starts listening for live executor health updates. + /// + /// The dispatcher used to marshal updates to the UI thread. public void StartLiveHealthUpdates(DispatcherQueue dispatcher) { _healthSubscription?.Dispose(); @@ -102,6 +129,9 @@ public void StartLiveHealthUpdates(DispatcherQueue dispatcher) dispatcher.TryEnqueue(async () => await LoadExecutorStatusesAsync())); } + /// + /// Stops listening for live executor health updates. + /// public void StopLiveHealthUpdates() { _healthSubscription?.Dispose(); diff --git a/ai-dev.ui.winui/Views/Dialogs/NewAgentDialog.xaml.cs b/ai-dev.ui.winui/Views/Dialogs/NewAgentDialog.xaml.cs index bf23bd9..335e62e 100644 --- a/ai-dev.ui.winui/Views/Dialogs/NewAgentDialog.xaml.cs +++ b/ai-dev.ui.winui/Views/Dialogs/NewAgentDialog.xaml.cs @@ -110,9 +110,8 @@ private void ShowError(string message) } private static string DeriveSlug(string name) => - new string(name.ToLowerInvariant() + new string([.. name.ToLowerInvariant() .Replace(' ', '-') - .Where(c => char.IsLetterOrDigit(c) || c == '-') - .ToArray()) + .Where(c => char.IsLetterOrDigit(c) || c == '-')]) .Trim('-'); } diff --git a/ai-dev.ui.winui/Views/Dialogs/NewProjectDialog.xaml.cs b/ai-dev.ui.winui/Views/Dialogs/NewProjectDialog.xaml.cs index b8ac350..f48f06e 100644 --- a/ai-dev.ui.winui/Views/Dialogs/NewProjectDialog.xaml.cs +++ b/ai-dev.ui.winui/Views/Dialogs/NewProjectDialog.xaml.cs @@ -170,18 +170,36 @@ private void ShowError(string message) } private static string DeriveSlug(string name) => - new string(name.ToLowerInvariant() + new string([.. name.ToLowerInvariant() .Replace(' ', '-') - .Where(c => char.IsLetterOrDigit(c) || c == '-') - .ToArray()) + .Where(c => char.IsLetterOrDigit(c) || c == '-')]) .Trim('-'); + /// + /// Represents a selectable template entry in the new project dialog. + /// + /// The underlying agent template. public sealed class TemplateItem(AgentTemplate template) { + /// + /// Gets the template slug. + /// public string Slug { get; } = template.Slug?.Value ?? string.Empty; + /// + /// Gets the template display name. + /// public string Name { get; } = template.Name; + /// + /// Gets the template description. + /// public string Description { get; } = template.Description; + /// + /// Gets the template model. + /// public string Model { get; } = template.Model; + /// + /// Gets or sets a value indicating whether the template is selected. + /// public bool IsSelected { get; set; } = true; } } diff --git a/ai-dev.ui.winui/Views/Pages/DecisionsPage.xaml.cs b/ai-dev.ui.winui/Views/Pages/DecisionsPage.xaml.cs index 2c3954e..e9f0698 100644 --- a/ai-dev.ui.winui/Views/Pages/DecisionsPage.xaml.cs +++ b/ai-dev.ui.winui/Views/Pages/DecisionsPage.xaml.cs @@ -54,7 +54,9 @@ private async void ShowResolved_Toggled(object sender, RoutedEventArgs e) private async void Decisions_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (ViewModel is null) return; - if (ViewModel.SelectedDecision is { } decision) + // Read from event args — TwoWay binding may not have updated ViewModel.SelectedDecision yet + // when this handler fires, so reading the property risks acting on stale state. + if (e.AddedItems.FirstOrDefault() is DecisionItem decision) { await ViewModel.SelectDecisionAsync(decision); RenderDecisionConversation(); diff --git a/ai-dev.ui.winui/Views/Pages/InsightsPage.xaml.cs b/ai-dev.ui.winui/Views/Pages/InsightsPage.xaml.cs index 7c88fae..e644fbe 100644 --- a/ai-dev.ui.winui/Views/Pages/InsightsPage.xaml.cs +++ b/ai-dev.ui.winui/Views/Pages/InsightsPage.xaml.cs @@ -5,10 +5,19 @@ namespace AiDev.WinUI.Views.Pages; +/// +/// Displays AI-generated insight summaries for agent sessions. +/// public sealed partial class InsightsPage : Page { + /// + /// Gets the page view model. + /// public InsightsViewModel ViewModel { get; } + /// + /// Initializes a new instance of the class. + /// public InsightsPage() { InitializeComponent(); diff --git a/ai-dev.ui.winui/ai-dev.ui.winui.csproj b/ai-dev.ui.winui/ai-dev.ui.winui.csproj index 0775809..0a67692 100644 --- a/ai-dev.ui.winui/ai-dev.ui.winui.csproj +++ b/ai-dev.ui.winui/ai-dev.ui.winui.csproj @@ -71,6 +71,7 @@ + diff --git a/ai-dev.ui.winui/appsettings.json b/ai-dev.ui.winui/appsettings.json index ba7a82e..bb0604d 100644 --- a/ai-dev.ui.winui/appsettings.json +++ b/ai-dev.ui.winui/appsettings.json @@ -2,14 +2,17 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Warning" + "Microsoft.Hosting.Lifetime": "Warning", + "System.Net.Http.HttpClient.ollama-health": "Warning", + "System.Net.Http.HttpClient.lmstudio-health": "Warning", + "System.Net.Http.HttpClient.anthropic-health": "Warning", + "System.Net.Http.HttpClient.github-models-health": "Warning", + "Polly": "Warning" } }, "WorkspacesPath": "", "StudioSettings": { "OllamaBaseUrl": "http://localhost:11434", - "LmStudioBaseUrl": "http://localhost:1234", - "insightsExecutor": "Claude", - "insightsModel": "sonnet" + "LmStudioBaseUrl": "http://localhost:1234" } } diff --git a/docs/design/vibe-kanban.md b/docs/design/vibe-kanban.md new file mode 100644 index 0000000..eca484d --- /dev/null +++ b/docs/design/vibe-kanban.md @@ -0,0 +1,741 @@ +Yes — this app does **not appear to initialize an embedded local LLM server like Ollama/LM Studio/llama.cpp inside the repo**. Instead, it initializes and orchestrates **local coding-agent CLIs/processes** such as **OpenAI Codex**, **Claude Code**, and **Cursor Agent**, then streams their output through the app’s local server to the web UI. + +Also: I couldn’t use deep research because that tool requires write access on the repo, so I traced this using code search and file reads instead. **Code search results may be incomplete** because GitHub search responses are capped. You can inspect more here: +- https://github.com/search?q=repo%3ABloopAI%2Fvibe-kanban+Ollama+OR+LMStudio+OR+llama.cpp+OR+OPENAI_API_KEY+OR+CODEX_HOME+OR+cursor-agent+OR+claude+OR+anthropic&type=code +- https://github.com/search?q=repo%3ABloopAI%2Fvibe-kanban+%22%2Fexecution%22+OR+%22thread%2Fstart%22+OR+%22turn%2Fstart%22+OR+JsonRpcPeer+OR+WebSocket&type=code + +## Short version + +The request-processing path looks roughly like this: + +1. **App boots local deployment** + - Loads config and cached executor profiles. + - Chooses a recommended executor profile if onboarding isn’t complete. +2. **UI asks local backend for available executors/models** + - Model/provider choices come from executor profile/config abstractions, not from a built-in model runtime. +3. **User starts a task / coding attempt** + - Backend creates an `ExecutionProcess` DB record. + - Container/execution service launches a local agent subprocess. +4. **Agent subprocess communicates with app** + - **Codex** uses a JSON-RPC-style “app-server” protocol. + - **Claude** uses a line-oriented control protocol over stdin/stdout. + - **Cursor** is spawned as a CLI with streaming JSON output. +5. **Backend normalizes logs/events into patches** + - Output is transformed into conversation/log entries and persisted. +6. **Frontend receives live updates** + - Via local HTTP + WebSocket JSON patch streams. +7. **Optional remote/relay comms** + - Remote auth/review services exist. + - WebRTC relay can proxy HTTP/WebSocket traffic to the local backend. + +--- + +## 1) Where initialization starts + +The local app boot path is in `LocalDeployment::new(...)`. + +```rust name=crates/local-deployment/src/lib.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/local-deployment/src/lib.rs#L93-L113 +#[async_trait] +impl Deployment for LocalDeployment { + async fn new(shutdown: CancellationToken) -> Result { + // Run one-time process logs migration from DB to filesystem + services::services::execution_process::migrate_execution_logs_to_files() + .await + .map_err(|e| DeploymentError::Other(anyhow::anyhow!("Migration failed: {}", e)))?; + + let mut raw_config = load_config_from_file(&config_path()).await; + + let profiles = ExecutorConfigs::get_cached(); + if !raw_config.onboarding_acknowledged + && let Ok(recommended_executor) = profiles.get_recommended_executor_profile().await + { + raw_config.executor_profile = recommended_executor; + } +``` + +What matters here: +- startup performs a **logs migration** +- reads app config +- loads **executor profiles** +- may set a **recommended executor** automatically + +That means “model initialization” is really **executor/profile initialization**, not GPU model bootstrapping. + +--- + +## 2) How local models/executors are configured + +The central abstraction is `ExecutorConfigs`, which loads default and user-overridden executor profiles. + +```rust name=crates/executors/src/profile.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/profile.rs#L243-L304 +impl ExecutorConfigs { + /// Get cached executor profiles + pub fn get_cached() -> ExecutorConfigs { + EXECUTOR_PROFILES_CACHE.read().unwrap().clone() + } + + /// Reload executor profiles cache + pub fn reload() { + let mut cache = EXECUTOR_PROFILES_CACHE.write().unwrap(); + *cache = Self::load(); + } + + /// Load executor profiles from file or defaults + pub fn load() -> Self { + let profiles_path = workspace_utils::assets::profiles_path(); + + // Load defaults first + let mut defaults = Self::from_defaults(); + defaults.canonicalise(); + + // Try to load user overrides + let content = match fs::read_to_string(&profiles_path) { + Ok(content) => content, + Err(_) => { + tracing::info!("No user profiles.json found, using defaults only"); + return defaults; + } + }; + + // Parse user overrides + match serde_json::from_str::(&content) { + Ok(mut user_overrides) => { + tracing::info!("Loaded user profile overrides from profiles.json"); + user_overrides.canonicalise(); + Self::merge_with_defaults(defaults, user_overrides) + } +``` + +So model/executor setup comes from: +- built-in defaults +- optional `profiles.json` user overrides +- cached profile state at runtime + +The UI-facing model schema is generic and provider-based: + +```rust name=crates/executors/src/model_selector.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/model_selector.rs#L1-L68 +pub struct ModelProvider { + pub id: String, + pub name: String, +} + +pub struct ModelInfo { + pub id: String, + pub name: String, + pub provider_id: Option, + pub reasoning_options: Vec, +} + +pub struct ModelSelectorConfig { + pub providers: Vec, + pub models: Vec, + pub default_model: Option, + pub agents: Vec, + pub permissions: Vec, +} +``` + +This strongly suggests the app treats “models” as **selectable metadata exposed by executors**, rather than owning a direct inference engine. + +--- + +## 3) What “local models” seem to mean in this repo + +From the code and docs, the main active local paths are: + +- **OpenAI Codex CLI** +- **Claude Code CLI** +- **Cursor Agent CLI** + +### Codex +Docs show the user runs Codex separately / locally: + +````mdx name=docs/agents/openai-codex.mdx url=https://github.com/BloopAI/vibe-kanban/blob/main/docs/agents/openai-codex.mdx#L1-L27 + + ```bash + npx -y @openai/codex + ``` + +```` + +And the executor launches Codex’s app-server mode: + +```rust name=crates/executors/src/executors/codex.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/codex.rs#L431-L444 +impl Codex { + pub fn base_command() -> &'static str { + "npx -y @openai/codex@0.124.0" + } + + fn build_command_builder(&self) -> Result { + let mut builder = CommandBuilder::new(Self::base_command()); + builder = builder.extend_params(["app-server"]); + if self.oss.unwrap_or(false) { + builder = builder.extend_params(["--oss"]); + } +``` + +Also, it resolves Codex config from `CODEX_HOME` or `~/.codex`: + +```rust name=crates/executors/src/executors/codex.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/codex.rs#L12-L24 +pub fn codex_home() -> Option { + if let Ok(codex_home) = env::var("CODEX_HOME") + && !codex_home.trim().is_empty() + { + return Some(PathBuf::from(codex_home)); + } + dirs::home_dir().map(|home| home.join(".codex")) +} +``` + +### Claude Code +Docs show Claude Code is also an external local CLI: + +````mdx name=docs/agents/claude-code.mdx url=https://github.com/BloopAI/vibe-kanban/blob/main/docs/agents/claude-code.mdx#L1-L20 + + ```bash + npx -y @anthropic-ai/claude-code + ``` + +```` + +There is Claude-specific session discovery from local filesystem: + +```rust name=crates/review/src/claude_session.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/review/src/claude_session.rs#L28-L42 +fn get_claude_projects_dir() -> Option { + dirs::home_dir().map(|home| home.join(".claude").join("projects")) +} +``` + +### Cursor +Cursor is launched via `cursor-agent`: + +```rust name=crates/executors/src/executors/cursor.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/cursor.rs#L139-L167 +impl CursorAgent { + pub fn base_command() -> &'static str { + "cursor-agent" + } + + fn build_command_builder(&self) -> Result { + let mut builder = + CommandBuilder::new(Self::base_command()).params(["-p", "--output-format=stream-json"]); + + if let Some(model) = self.resolved_model() { + builder = builder.extend_params(["--model", model]); + } +``` + +## Conclusion on local models +I **did not find evidence of built-in Ollama, LM Studio, or llama.cpp runtime initialization** in the code paths I traced. The app seems to rely on **external agent runtimes/CLIs** and model/provider selection within those runtimes. + +--- + +## 4) How Codex is initialized and communicated with + +This is the clearest “model init + request processing” path. + +The Codex client binds to a JSON-RPC peer, then sends `initialize`, `thread/start`, and `turn/start`. + +```rust name=crates/executors/src/executors/codex/client.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/codex/client.rs#L91-L172 +pub fn connect(&self, peer: JsonRpcPeer) { + let _ = self.rpc.set(peer); +} + +pub fn set_resolved_model(&self, model: String) { + let _ = self.resolved_model.set(model); +} + +pub async fn initialize(&self) -> Result<(), ExecutorError> { + let request = ClientRequest::Initialize { + request_id: self.next_request_id(), + params: InitializeParams { + client_info: ClientInfo { + name: "vibe-codex-executor".to_string(), + title: None, + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + }; + + self.send_request::(request, "initialize") + .await?; + self.send_message(&ClientNotification::Initialized).await +} + +pub async fn thread_start( + &self, + params: ThreadStartParams, +) -> Result { + let request = ClientRequest::ThreadStart { + request_id: self.next_request_id(), + params, + }; + self.send_request(request, "thread/start").await +} + +pub async fn turn_start_with_mode( + &self, + thread_id: String, + input: Vec, + collaboration_mode: Option, +) -> Result { + let request = ClientRequest::TurnStart { + request_id: self.next_request_id(), + params: TurnStartParams { + thread_id, + input, + collaboration_mode, + ..Default::default() + }, + }; + self.send_request(request, "turn/start").await +} +``` + +Important points: +- the app starts a **Codex app-server subprocess** +- it attaches a **JSON-RPC peer** +- it performs an explicit **initialize handshake** +- then creates a **thread** +- then starts **turns** with user input + +Model selection is folded into collaboration settings: + +```rust name=crates/executors/src/executors/codex/client.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/codex/client.rs#L172-L206 +fn collaboration_mode(&self, mode: ModeKind) -> Result { + let model = self.resolved_model.get().cloned().ok_or_else(|| { + tracing::error!("collaboration_mode called before resolved_model was set"); + ExecutorError::Io(io::Error::other( + "resolved model not available for collaboration mode", + )) + })?; + Ok(CollaborationMode { + mode, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }) +} +``` + +So for Codex, “local model init” =: +- resolve selected model +- connect to Codex RPC peer +- initialize session +- create/fork thread +- start turn with user inputs and mode + +--- + +## 5) How Claude communicates + +Claude is different: it uses a **control protocol over stdin/stdout** instead of the Codex JSON-RPC app-server. + +The protocol peer spawns a background reader and parses line-delimited JSON messages: + +```rust name=crates/executors/src/executors/claude/protocol.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude/protocol.rs#L20-L79 +impl ProtocolPeer { + pub fn spawn( + stdin: ChildStdin, + stdout: ChildStdout, + client: Arc, + cancel: CancellationToken, + ) -> Self { + let peer = Self { + stdin: Arc::new(Mutex::new(stdin)), + }; + + let reader_peer = peer.clone(); + tokio::spawn(async move { + if let Err(e) = reader_peer.read_loop(stdout, client, cancel).await { + tracing::error!("Protocol reader loop error: {}", e); + } + }); + + peer + } +``` + +The read loop: +- reads stdout lines +- logs them +- parses control messages +- handles tool approval / interruption flow + +Claude-specific client logic handles approval requests and responses: + +```rust name=crates/executors/src/executors/claude/client.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude/client.rs#L30-L80 +pub struct ClaudeAgentClient { + log_writer: LogWriter, + approvals: Option>, + auto_approve: bool, + repo_context: RepoContext, + commit_reminder_prompt: String, + cancel: CancellationToken, +} +``` + +And normalized Claude output is converted into app conversation patches: + +```rust name=crates/executors/src/executors/claude.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude.rs#L823-L878 +let patches = processor.normalize_entries( + &claude_json, + &worktree_path, + &entry_index_provider, +); +for patch in patches { + msg_store.push_patch(patch); +} +``` + +So Claude path =: +- launch Claude CLI +- talk over stdio control protocol +- inspect tool-use requests +- convert line-delimited events into normalized patches +- stream those patches to UI + +--- + +## 6) How requests enter the backend and become execution processes + +The backend concept that represents a running task is `ExecutionProcess`. + +```rust name=crates/db/src/models/execution_process.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/db/src/models/execution_process.rs#L32-L71 +pub enum ExecutionProcessRunReason { + SetupScript, + CleanupScript, + ArchiveScript, + CodingAgent, + DevServer, +} + +pub struct ExecutionProcess { + pub id: Uuid, + pub session_id: Uuid, + pub run_reason: ExecutionProcessRunReason, + pub executor_action: sqlx::types::Json, + pub status: ExecutionProcessStatus, + pub exit_code: Option, + pub dropped: bool, + pub started_at: DateTime, + pub completed_at: Option>, +``` + +This tells you the app processes user actions by creating tracked process records in the DB. + +The container service is the orchestration layer that starts executors and scripts: + +```rust name=crates/services/src/services/container.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/services/src/services/container.rs#L59-L92 +#[async_trait] +pub trait ContainerService { + fn msg_stores(&self) -> &Arc>>>; + fn db(&self) -> &DBService; + fn git(&self) -> &GitService; + fn notification_service(&self) -> &NotificationService; + + async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError>; + + fn workspace_to_current_dir(&self, workspace: &Workspace) -> PathBuf; + + async fn discover_executor_options( + &self, + executor_profile_id: ExecutorProfileId, + session_id: Option, + workspace_id: Option, + repo_id: Option, + ) -> Result>, ContainerError> { +``` + +That’s the service layer that: +- finds workspace/session context +- discovers executor options +- launches subprocesses +- stores messages/log patches + +--- + +## 7) How the frontend communicates with the local backend + +The frontend uses a local API transport abstraction for both HTTP and WebSocket communication. + +```typescript name=packages/web-core/src/shared/lib/localApiTransport.ts url=https://github.com/BloopAI/vibe-kanban/blob/main/packages/web-core/src/shared/lib/localApiTransport.ts#L91-L126 +const defaultTransport: LocalApiTransport = { + request: (pathOrUrl, init = {}) => { + const { + hostScope: _hostScope, + hostId: _hostId, + relayHostId: _relayHostId, + ...requestInit + } = init; + return fetch(pathOrUrl, requestInit); + }, + openWebSocket: (pathOrUrl, _options = {}) => + new WebSocket(toAbsoluteWsUrl(pathOrUrl)), +}; +``` + +And requests are automatically scoped to the current local/host context: + +```typescript name=packages/web-core/src/shared/lib/localApiTransport.ts url=https://github.com/BloopAI/vibe-kanban/blob/main/packages/web-core/src/shared/lib/localApiTransport.ts#L71-L116 +export async function makeLocalApiRequest( + pathOrUrl: string, + init: LocalApiRequestOptions = {} +): Promise { + return transport.request(resolveScopedPath(pathOrUrl, init), init); +} +``` + +So the frontend doesn’t talk directly to model runtimes. It talks to the **local backend API**, which then manages executors. + +--- + +## 8) How live process/log streaming works + +The UI listens to execution-process updates over WebSocket JSON patch streams. + +```typescript name=packages/web-core/src/shared/hooks/useExecutionProcesses.ts url=https://github.com/BloopAI/vibe-kanban/blob/main/packages/web-core/src/shared/hooks/useExecutionProcesses.ts#L19-L60 +if (sessionId) { + const apiBasePath = hostId ? `/api/host/${hostId}` : '/api'; + const params = new URLSearchParams({ session_id: sessionId }); + if (typeof showSoftDeleted === 'boolean') { + params.set('show_soft_deleted', String(showSoftDeleted)); + } + endpoint = `${apiBasePath}/execution-processes/stream/session/ws?${params.toString()}`; +} +``` + +The server route upgrades to WebSocket and pushes events from the event service: + +```rust name=crates/server/src/routes/execution_processes.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/server/src/routes/execution_processes.rs#L201-L253 +async fn stream_execution_processes_by_session_ws( + ws: SignedWsUpgrade, + State(deployment): State, + Query(query): Query, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| async move { + if let Err(e) = handle_execution_processes_by_session_ws( + socket, + deployment, + query.session_id, + query.show_soft_deleted.unwrap_or(false), + ) + .await + { + tracing::warn!("execution processes by session WS closed: {}", e); + } + }) +} +``` + +And the actual WS handler forwards stream items to the socket: + +```rust name=crates/server/src/routes/execution_processes.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/server/src/routes/execution_processes.rs#L219-L285 +let mut stream = deployment + .events() + .stream_execution_processes_for_session_raw(session_id, show_soft_deleted) + .await? + .map_ok(|msg| msg.to_ws_message_unchecked()); +``` + +So communication chain is: +- executor process emits logs/events +- backend normalizes/persists them +- event service emits patches +- WebSocket route streams them +- React hooks apply patches into live UI state + +--- + +## 9) How process logs are stored + +Execution logs are persisted as JSONL files under a sessions directory: + +```rust name=crates/utils/src/execution_logs.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/utils/src/execution_logs.rs#L1-L45 +pub const EXECUTION_LOGS_DIRNAME: &str = "sessions"; + +pub fn process_log_file_path(session_id: Uuid, process_id: Uuid) -> PathBuf { + process_log_file_path_in_root(&asset_dir(), session_id, process_id) +} + +pub struct ExecutionLogWriter { + path: PathBuf, + file: tokio::fs::File, +} +``` + +At startup, old logs are migrated from DB to filesystem, which fits the “request processing history” story. + +--- + +## 10) Remote and relay communication boundaries + +There are multiple communication layers besides local HTTP/WS. + +### A) Local frontend ↔ local backend +- `fetch` +- `WebSocket` +- scoped through `localApiTransport` + +### B) Local/desktop/backend ↔ remote auth/review service +There’s a separate remote service for auth and review-related routes. + +Example local login route on remote side: + +```rust name=crates/remote/src/routes/oauth.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/remote/src/routes/oauth.rs#L114-L120 +async fn local_login( + State(state): State, + Json(payload): Json, +) -> Result, LocalAuthError> { + let response = local_login_flow(&state, &payload).await?; + Ok(Json(response)) +} +``` + +And the local server proxies/finalizes login via remote client: + +```rust name=crates/server/src/routes/oauth.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/server/src/routes/oauth.rs#L215-L232 +async fn local_login( + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + let response = client.local_login(&payload).await?; +``` + +### C) Relay/WebRTC path +There is a WebRTC relay layer that can proxy HTTP and bridge WebSocket connections to the local backend. + +```rust name=crates/relay-webrtc/src/peer.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/relay-webrtc/src/peer.rs#L193-L209 +/// Run the server-side peer. +/// +/// Registers callbacks on the peer connection to handle incoming data channel +/// messages. HTTP requests are proxied to the local backend; WebSocket +/// connections are bridged. +pub async fn run_peer( + peer_connection: Arc, + config: PeerConfig, +) -> Result<(), WebRtcError> { +``` + +And server exposes WebRTC endpoints: + +```rust name=crates/server/src/routes/webrtc.rs url=https://github.com/BloopAI/vibe-kanban/blob/main/crates/server/src/routes/webrtc.rs#L9-L27 +pub fn router() -> Router { + Router::new() + .route("/webrtc/offer", post(handle_offer)) + .route("/webrtc/candidate", post(handle_candidate)) +} +``` + +This means the app can expose the local backend through a relay rather than requiring direct local-only access. + +--- + +## 11) End-to-end lifecycle of a request + +Here’s the likely end-to-end flow for a coding request: + +### Step 1: User submits task from UI +The React app maintains create-mode/workspace state, including selected executor/model/profile. +That state ultimately drives a backend execution request. + +### Step 2: Backend resolves executor config +- `LocalDeployment` has loaded config + `ExecutorConfigs` +- selected executor profile determines agent type and model/provider options + +### Step 3: Backend creates `ExecutionProcess` +- persisted in DB with `run_reason = codingagent` +- execution context includes session/workspace/repos + +### Step 4: Container service spawns executor subprocess +Depending on executor: +- **Codex** → `npx -y @openai/codex ... app-server` +- **Claude** → Claude Code CLI over control protocol +- **Cursor** → `cursor-agent -p --output-format=stream-json` + +### Step 5: Executor/session initialization +- **Codex**: connect peer → `initialize` → `thread/start` +- **Claude**: protocol setup → send/receive control messages +- **Cursor**: model passed via CLI flags + +### Step 6: User request is converted to executor input +For Codex this is explicit: +- `thread_start(...)` +- `turn_start_with_mode(thread_id, input, collaboration_mode)` + +### Step 7: Model/agent emits responses and tool-use events +- stdout / protocol / JSON-RPC events are read +- approval flow may interrupt or gate tool actions +- outputs are normalized into patches/messages + +### Step 8: Logs and patches are stored and streamed +- logs written to JSONL +- execution/event streams emitted +- frontend subscribes via WebSocket patch streams + +### Step 9: UI renders live conversation/process state +Hooks like `useExecutionProcesses` update task status and timeline in real time. + +--- + +## 12) What I think is the key code to read first + +If your goal is “understand the code that allows processing of requests,” I’d read these in order: + +1. `crates/local-deployment/src/lib.rs` + - app startup and profile/config init + +2. `crates/executors/src/profile.rs` + - executor profile loading and overrides + +3. `crates/executors/src/model_selector.rs` + - model/provider abstraction exposed to UI + +4. `crates/services/src/services/container.rs` + - orchestration layer for starting executions + +5. `crates/db/src/models/execution_process.rs` + - persistent representation of a request run + +6. `crates/executors/src/executors/codex.rs` + - codex executor setup / command building + +7. `crates/executors/src/executors/codex/client.rs` + - best file for actual request lifecycle: initialize → thread → turn + +8. `crates/executors/src/executors/claude/protocol.rs` + - how stdio-based agent communication works + +9. `crates/server/src/routes/execution_processes.rs` + - live streaming of process state/logs to clients + +10. `packages/web-core/src/shared/lib/localApiTransport.ts` + - frontend transport abstraction + +11. `packages/web-core/src/shared/hooks/useExecutionProcesses.ts` + - how UI subscribes to process updates + +--- + +## 13) My interpretation of “local models” + +If by “local models” you meant “models running entirely on-device like Ollama/llama.cpp,” I **don’t see that as a first-class integrated runtime in this repo**. + +What I do see is: +- **local execution of agent CLIs** +- **model selection/config passed into those CLIs** +- **communication protocols between app and those agents** +- **local backend that orchestrates, stores, and streams request state** + +So the app is more of a **local orchestration shell for coding agents** than a local inference engine itself. + +If you want, I can do a **second pass focused only on the exact request path for one executor** — e.g.: +- **Codex path from UI click → subprocess spawn → `thread/start` → `turn/start`** +- **Claude path from UI click → protocol messages → approval/tool handling** +- or a **sequence diagram of frontend ↔ server ↔ executor subprocess**. \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..993d13d --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file