Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/AppLens.Backend/PlatformLoopService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,50 @@ await _blackboardStore.AppendAsync(
return approval;
}

public async Task<TuneActionApproval> 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<TuneActionRecord> ExecuteTuneActionAsync(
TunePlanItem item,
TuneActionProposal proposal,
Expand Down Expand Up @@ -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<ProposedActionKind>(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
Expand Down
67 changes: 67 additions & 0 deletions tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(() =>
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))
Expand All @@ -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()
{
Expand Down
Loading