Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s

Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications.

Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
2026-09-25 11:58:34 +02:00
parent e011e36ef7
commit 7b2bcd3631
50 changed files with 2188 additions and 113 deletions
@@ -0,0 +1,70 @@
using MeetingAssistant.Notifications;
using MeetingAssistant.Workflow;
using System.Threading.Channels;
namespace MeetingAssistant.Summary;
public sealed class WorkflowChangeRequestService(
AgentApprovalService approvals,
IWorkflowRulesEditorWindowService editor,
ILogger<WorkflowChangeRequestService> logger) : BackgroundService
{
private readonly Channel<RequestDetails> requests = Channel.CreateUnbounded<RequestDetails>(
new UnboundedChannelOptions { SingleReader = true });
public string Request(string intention, string detailedPrompt, string meetingNotePath)
{
if (string.IsNullOrWhiteSpace(intention) || intention.Length > 100 || intention.Contains('\r') || intention.Contains('\n'))
return "Refused: intention must be a nonempty single line of at most 100 characters.";
if (string.IsNullOrWhiteSpace(detailedPrompt))
return "Refused: supply a detailed change prompt, preferably a scenario or specification.";
var request = new RequestDetails(Guid.NewGuid().ToString("N"), intention, detailedPrompt, meetingNotePath);
return requests.Writer.TryWrite(request) ? $"Requested: {request.Id}. User approval is pending."
: "Refused: application is stopping.";
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var pending = new List<Task>();
try
{
await foreach (var request in requests.Reader.ReadAllAsync(stoppingToken))
{
pending.RemoveAll(task => task.IsCompleted);
pending.Add(HandleAsync(request, stoppingToken));
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
finally
{
await Task.WhenAll(pending);
}
}
private async Task HandleAsync(RequestDetails request, CancellationToken cancellationToken)
{
try
{
if (await approvals.ConfirmAsync($"Workflow change requested: {request.Intention}",
$"The summarizer for {Path.GetFileName(request.MeetingNotePath)} requests a workflow change. Open the settings agent to apply it?",
cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
editor.ShowWithPrompt(request.DetailedPrompt);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception exception)
{
logger.LogWarning(exception, "Workflow request {RequestId} could not be handled", request.Id);
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
requests.Writer.TryComplete();
return base.StopAsync(cancellationToken);
}
private sealed record RequestDetails(string Id, string Intention, string DetailedPrompt, string MeetingNotePath);
}