diff --git a/src/AppLens.Backend/PlatformLoopService.cs b/src/AppLens.Backend/PlatformLoopService.cs index e352615..8f7a073 100644 --- a/src/AppLens.Backend/PlatformLoopService.cs +++ b/src/AppLens.Backend/PlatformLoopService.cs @@ -114,6 +114,50 @@ await _blackboardStore.AppendAsync( return approval; } + public async Task DecidePendingTuneActionAsync( + string proposalId, + string approvedBy, + bool approved, + string rationale, + string? correlationId = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(proposalId)) + { + throw new InvalidOperationException("Tune action proposal id is missing."); + } + + var events = await _blackboardStore.ReadAllAsync(cancellationToken).ConfigureAwait(false); + var isAlreadyDecided = events.Any(evt => + evt.EventType is BlackboardEventType.ActionApproved or BlackboardEventType.ActionExecuted && + string.Equals(Payload(evt, "proposal_id"), proposalId, StringComparison.OrdinalIgnoreCase)); + + if (isAlreadyDecided) + { + throw new InvalidOperationException($"Tune action proposal {proposalId} was already decided."); + } + + var proposedEvent = events + .Where(evt => evt.EventType == BlackboardEventType.ActionProposed) + .Where(evt => string.Equals(Payload(evt, "proposal_id"), proposalId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(evt => evt.CreatedAt) + .FirstOrDefault(); + + if (proposedEvent is null) + { + throw new InvalidOperationException($"Tune action proposal {proposalId} was not found."); + } + + return await ApproveTuneActionAsync( + ToProposal(proposedEvent), + approvedBy, + approved, + rationale, + correlationId ?? proposedEvent.CorrelationId, + cancellationToken) + .ConfigureAwait(false); + } + public async Task ExecuteTuneActionAsync( TunePlanItem item, TuneActionProposal proposal, @@ -170,6 +214,23 @@ private static TuneActionRecord BlockedRecord(TunePlanItem item, string message) private static string NormalizeCorrelationId(string? correlationId, string prefix) => string.IsNullOrWhiteSpace(correlationId) ? $"{prefix}-{Guid.NewGuid():N}" : correlationId; + + private static TuneActionProposal ToProposal(BlackboardEvent evt) => + new() + { + ProposalId = Payload(evt, "proposal_id"), + PlanItemId = Payload(evt, "plan_item_id"), + Kind = Enum.TryParse(Payload(evt, "kind"), ignoreCase: true, out var kind) + ? kind + : ProposedActionKind.None, + Target = Payload(evt, "target"), + TargetContext = Payload(evt, "target_context"), + CorrelationId = evt.CorrelationId, + ProposedAt = evt.CreatedAt + }; + + private static string Payload(BlackboardEvent evt, string key) => + evt.Payload.TryGetValue(key, out var value) ? value : ""; } file static class TuneActionProposalExtensions diff --git a/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs b/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs index 52e9324..f01a485 100644 --- a/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs +++ b/tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs @@ -62,6 +62,62 @@ public async Task Rejected_approval_does_not_call_runtime_and_records_blocked_ex Assert.Contains("approval", executionEvent.Payload["message"], StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Decide_pending_tune_action_by_proposal_id_records_approval_and_closes_dashboard_item() + { + var runtime = new FakeTuneActionRuntime(); + var service = CreateService(runtime); + var item = StartupItem("startup-docker"); + var proposal = await service.ProposeTuneActionAsync(item, "corr-decision"); + + var approval = await service.DecidePendingTuneActionAsync( + proposal.ProposalId, + approvedBy: "operator", + approved: true, + rationale: "Approved from dashboard.", + correlationId: "corr-decision"); + + var dashboard = await CreateDashboardService().GetPendingActionsAsync(); + var approvalEvents = await _store.QueryAsync(new BlackboardEventQuery + { + CorrelationId = "corr-decision", + EventType = BlackboardEventType.ActionApproved + }); + + Assert.Equal(proposal.ProposalId, approval.ProposalId); + Assert.True(approval.Approved); + Assert.Empty(dashboard); + var approvalEvent = Assert.Single(approvalEvents); + Assert.Equal("True", approvalEvent.Payload["approved"]); + Assert.Equal("operator", approvalEvent.Payload["approved_by"]); + Assert.Equal("Approved from dashboard.", approvalEvent.Payload["rationale"]); + } + + [Fact] + public async Task Decide_pending_tune_action_by_proposal_id_rejects_duplicate_decisions() + { + var runtime = new FakeTuneActionRuntime(); + var service = CreateService(runtime); + var item = StartupItem("startup-docker"); + var proposal = await service.ProposeTuneActionAsync(item, "corr-duplicate-decision"); + await service.DecidePendingTuneActionAsync( + proposal.ProposalId, + approvedBy: "operator", + approved: false, + rationale: "Not needed.", + correlationId: "corr-duplicate-decision"); + + var ex = await Assert.ThrowsAsync(() => + service.DecidePendingTuneActionAsync( + proposal.ProposalId, + approvedBy: "operator", + approved: true, + rationale: "Changed mind.", + correlationId: "corr-duplicate-decision")); + + Assert.Contains("already decided", ex.Message, StringComparison.OrdinalIgnoreCase); + } + public void Dispose() { if (Directory.Exists(_root)) @@ -82,6 +138,17 @@ private PlatformLoopService CreateService(FakeTuneActionRuntime runtime) => _store, new TuneActionExecutor(runtime)); + private DashboardReadModelService CreateDashboardService() => + new( + new ModuleStatusService(new ModuleStatusPaths + { + AppLensLlmRoot = Path.Combine(_root, "missing-llm"), + OracleRoot = Path.Combine(_root, "missing-oracle"), + MailboxRoot = Path.Combine(_root, "missing-mailbox"), + AppLensZeroRoot = Path.Combine(_root, "missing-zero") + }), + _store); + private static TunePlanItem StartupItem(string id) => new() {