forked from Manuel/meeting-assistant
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40234d3b5 | ||
|
|
0a30a1ca5a | ||
|
|
1b19b08f2e | ||
|
|
6b1896660f | ||
|
|
05e23857dd | ||
|
|
9a91a81992 | ||
|
|
83726a2233 | ||
|
|
4840b8e3be | ||
|
|
445bff99ce | ||
|
|
1164c26846 | ||
|
|
bd35ebc4c8 | ||
|
|
fc0edd812d | ||
|
|
7b2bcd3631 | ||
|
|
bf6e1e7560 | ||
|
|
d1d4b53ee8 | ||
|
|
0e2aa3830f | ||
|
|
d77187e9ec | ||
|
|
6adf6f726b |
@@ -0,0 +1,36 @@
|
||||
name: Native macOS Recovery diagnostic on Ubuntu
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
macos-native-diagnostic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout diagnostic source
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup .NET for the diagnostic helper
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Probe native macOS Recovery with existing Docker resources
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||
|
||||
- name: Always clean up only this diagnostic's owned resources
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||
|
||||
- name: Preserve native diagnostic evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: native-macos-recovery-diagnostic
|
||||
path: artifacts/native-macos/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -24,37 +24,33 @@ jobs:
|
||||
|
||||
- name: Install Wine
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends wine64 wine32 winbind unzip
|
||||
if [ -d /usr/lib/wine ]; then
|
||||
echo "/usr/lib/wine" >> "${GITHUB_PATH}"
|
||||
export PATH="${PATH}:/usr/lib/wine"
|
||||
fi
|
||||
if command -v wine >/dev/null 2>&1; then
|
||||
WINE_BIN="$(command -v wine)"
|
||||
elif command -v wine64 >/dev/null 2>&1; then
|
||||
WINE_BIN="$(command -v wine64)"
|
||||
elif [ -x /usr/lib/wine/wine64 ]; then
|
||||
WINE_BIN="/usr/lib/wine/wine64"
|
||||
elif [ -x /usr/lib/wine/wine ]; then
|
||||
WINE_BIN="/usr/lib/wine/wine"
|
||||
else
|
||||
echo "No wine binary found after installation."
|
||||
ls -la /usr/lib/wine || true
|
||||
exit 1
|
||||
fi
|
||||
sudo apt-get install -y --no-install-recommends ca-certificates curl gnupg winbind unzip
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/winehq-archive.key >/dev/null
|
||||
. /etc/os-release
|
||||
curl -fsSL "https://dl.winehq.org/wine-builds/ubuntu/dists/${VERSION_CODENAME}/winehq-${VERSION_CODENAME}.sources" \
|
||||
| sudo tee /etc/apt/sources.list.d/winehq.sources >/dev/null
|
||||
sudo apt-get update
|
||||
# Wine 9 cannot enumerate the DOS_DOT patterns used by .NET 10's SDK pack lookup.
|
||||
sudo apt-get install -y --no-install-recommends "winehq-stable=11.0.0.0~${VERSION_CODENAME}-1"
|
||||
WINE_BIN="/opt/wine-stable/bin/wine"
|
||||
test -x "${WINE_BIN}"
|
||||
echo "WINE_BIN=${WINE_BIN}" >> "${GITHUB_ENV}"
|
||||
"${WINE_BIN}" --version
|
||||
|
||||
- name: Restore (Windows target)
|
||||
- name: Restore (portable tests for Windows/Wine)
|
||||
run: |
|
||||
dotnet restore MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
-r win-x64
|
||||
|
||||
- name: Build (Windows target)
|
||||
- name: Build (portable tests for Windows/Wine)
|
||||
run: |
|
||||
dotnet build MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||
-c Release \
|
||||
@@ -77,16 +73,110 @@ jobs:
|
||||
echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}"
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info
|
||||
|
||||
- name: Provision NuGet signature trust inside Wine
|
||||
run: |
|
||||
SDK_VERSION="$(dotnet --version)"
|
||||
SDK_TRUST_ROOT="${DOTNET_ROOT}/sdk/${SDK_VERSION}/trustedroots"
|
||||
dotnet publish scripts/wine-sdk-trust.cs \
|
||||
-c Release \
|
||||
-p:UseAppHost=false \
|
||||
-p:PublishAot=false \
|
||||
-o "${RUNNER_TEMP}/wine-sdk-trust" \
|
||||
--nologo
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" \
|
||||
"Z:${RUNNER_TEMP}/wine-sdk-trust/WineSdkTrust.dll" --import \
|
||||
"Z:${SDK_TRUST_ROOT}/codesignctl.pem" \
|
||||
"Z:${SDK_TRUST_ROOT}/timestampctl.pem"
|
||||
|
||||
- name: Diagnose Windows SDK targeting-pack resolution
|
||||
run: |
|
||||
find "${WIN_DOTNET_DIR}/packs" -maxdepth 4 -name PackageOverrides.txt -print
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
-p:TargetFramework=net10.0 \
|
||||
-getProperty:NetCoreRoot,NetCoreTargetingPackRoot,PrunePackageDataRoot,PrunePackageTargetingPackRoots,MSBuildSDKsPath,DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
-p:TargetFramework=net10.0 \
|
||||
-t:AddPrunePackageReferences \
|
||||
-v:normal
|
||||
|
||||
- name: Build Windows desktop target via Wine
|
||||
run: |
|
||||
rm -f MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" build MeetingAssistant/MeetingAssistant.csproj \
|
||||
-c Release \
|
||||
-f net10.0-windows10.0.19041.0 \
|
||||
-r win-x64 \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
--nologo
|
||||
test -s MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll
|
||||
|
||||
- name: Run tests via Wine (Windows dotnet host)
|
||||
run: |
|
||||
rm -f artifacts/tests/wine.trx
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||
-c Release \
|
||||
-r win-x64 \
|
||||
--no-build \
|
||||
--logger "trx;LogFileName=wine.trx" \
|
||||
--results-directory artifacts/tests \
|
||||
--nologo
|
||||
test -s artifacts/tests/wine.trx
|
||||
|
||||
- name: Show test output folder on failure
|
||||
if: failure()
|
||||
run: |
|
||||
find artifacts/tests -type f -print || true
|
||||
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
||||
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
||||
|
||||
portable-build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
TZ: "Europe/Berlin"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Restore (portable target)
|
||||
run: |
|
||||
dotnet restore MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||
-p:EnableWindowsTargeting=true
|
||||
|
||||
- name: Build (portable target including macOS managed adapters)
|
||||
run: |
|
||||
dotnet build MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||
-c Release \
|
||||
-f net10.0 \
|
||||
--no-restore \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
--nologo
|
||||
|
||||
- name: Run portable and macOS managed behavior tests on Ubuntu
|
||||
run: |
|
||||
rm -f artifacts/tests/portable.trx
|
||||
dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj \
|
||||
-c Release \
|
||||
-f net10.0 \
|
||||
--no-build \
|
||||
-p:EnableWindowsTargeting=true \
|
||||
--logger "trx;LogFileName=portable.trx" \
|
||||
--results-directory artifacts/tests \
|
||||
--nologo
|
||||
test -s artifacts/tests/portable.trx
|
||||
|
||||
- name: Show test output folder on failure
|
||||
if: failure()
|
||||
run: |
|
||||
find artifacts/tests -type f -print || true
|
||||
find MeetingAssistant.Tests -type d -name TestResults -print || true
|
||||
find MeetingAssistant.Tests -type f -path "*/TestResults/*" -maxdepth 5 -print || true
|
||||
|
||||
@@ -110,6 +110,11 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
[Fact]
|
||||
public void ResolveKeyFallsBackToUserEnvironment()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.Workflow;
|
||||
using MeetingAssistant.Notifications;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsDesktopControlManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void MacOsApplicationRegistersARealInteractiveAgentWindow()
|
||||
public void AutomaticWorkflowChangeCannotSilentlyLoseItsPromptOnMacOs()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
IWorkflowRulesEditorWindowService service = new MacOsWorkflowRulesEditorWindowService(
|
||||
Options.Create(new MeetingAssistantOptions()));
|
||||
|
||||
Assert.Throws<PlatformNotSupportedException>(() => service.ShowWithPrompt("Update meeting rules"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsApplicationDeclinesUnavailableAutomaticApprovals()
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
var approval = factory.Services.GetRequiredService<AgentApprovalService>();
|
||||
|
||||
Assert.False(await approval.ConfirmAsync("Test approval", "Do not apply a change", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MacOsApplicationRegistersARealInteractiveAgentWindow()
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>();
|
||||
|
||||
var service = factory.Services.GetRequiredService<IWorkflowRulesEditorWindowService>();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsFactAttribute : FactAttribute
|
||||
{
|
||||
public MacOsFactAttribute()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
Skip = "Requires native macOS helpers and Apple system tools; run on macOS.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
@@ -17,16 +18,46 @@ namespace MeetingAssistant.Tests;
|
||||
public sealed class MacOsMeetingAudioSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void NativeMicrophoneHelperDeclaresMacOsPrivacyMetadata()
|
||||
public void MacOsServiceLauncherDefinesStablePrivacyIdentity()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
var repositoryDirectory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (repositoryDirectory is not null &&
|
||||
!File.Exists(Path.Combine(repositoryDirectory.FullName, "MeetingAssistant", "MeetingAssistant.csproj")))
|
||||
{
|
||||
return;
|
||||
repositoryDirectory = repositoryDirectory.Parent;
|
||||
}
|
||||
|
||||
Assert.NotNull(repositoryDirectory);
|
||||
var projectDirectory = Path.Combine(repositoryDirectory.FullName, "MeetingAssistant");
|
||||
var launcherDirectory = Path.Combine(projectDirectory, "Native", "MacOsLauncher");
|
||||
var infoPlistPath = Path.Combine(launcherDirectory, "Info.plist");
|
||||
var launcherSourcePath = Path.Combine(launcherDirectory, "main.swift");
|
||||
|
||||
Assert.True(File.Exists(infoPlistPath), $"Expected launcher Info.plist at '{infoPlistPath}'.");
|
||||
Assert.True(File.Exists(launcherSourcePath), $"Expected launcher source at '{launcherSourcePath}'.");
|
||||
|
||||
var infoPlist = File.ReadAllText(infoPlistPath);
|
||||
Assert.Contains("cloud.schweigert.meeting-assistant", infoPlist, StringComparison.Ordinal);
|
||||
Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal);
|
||||
Assert.Contains("NSCalendarsFullAccessUsageDescription", infoPlist, StringComparison.Ordinal);
|
||||
|
||||
var launcherSource = File.ReadAllText(launcherSourcePath);
|
||||
Assert.Contains("set -a", launcherSource, StringComparison.Ordinal);
|
||||
Assert.Contains("Contents/Resources/app", launcherSource, StringComparison.Ordinal);
|
||||
|
||||
var projectFile = File.ReadAllText(Path.Combine(projectDirectory, "MeetingAssistant.csproj"));
|
||||
Assert.Contains(
|
||||
"designated => identifier "cloud.schweigert.meeting-assistant"",
|
||||
projectFile,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[MacOsFact]
|
||||
public void NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant()
|
||||
{
|
||||
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent?.Name ?? "Debug";
|
||||
var helperPath = Path.GetFullPath(Path.Combine(
|
||||
var appPath = Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
@@ -37,11 +68,30 @@ public sealed class MacOsMeetingAudioSourceTests
|
||||
configuration,
|
||||
"net10.0",
|
||||
"Native",
|
||||
"macos-meeting-audio-capture"));
|
||||
var helperImage = System.Text.Encoding.UTF8.GetString(File.ReadAllBytes(helperPath));
|
||||
"MeetingAssistantAudioCapture.app"));
|
||||
var infoPlistPath = Path.Combine(appPath, "Contents", "Info.plist");
|
||||
var helperPath = Path.Combine(appPath, "Contents", "MacOS", "macos-meeting-audio-capture");
|
||||
|
||||
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", helperImage, StringComparison.Ordinal);
|
||||
Assert.Contains("NSMicrophoneUsageDescription", helperImage, StringComparison.Ordinal);
|
||||
Assert.True(Directory.Exists(appPath), $"Expected macOS audio capture app at '{appPath}'.");
|
||||
Assert.True(File.Exists(infoPlistPath), $"Expected Info.plist at '{infoPlistPath}'.");
|
||||
Assert.True(File.Exists(helperPath), $"Expected native helper at '{helperPath}'.");
|
||||
|
||||
var infoPlist = File.ReadAllText(infoPlistPath);
|
||||
Assert.Contains("cloud.schweigert.meeting-assistant.audio-capture", infoPlist, StringComparison.Ordinal);
|
||||
Assert.Contains("NSMicrophoneUsageDescription", infoPlist, StringComparison.Ordinal);
|
||||
|
||||
using var verification = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "/usr/bin/codesign",
|
||||
ArgumentList = { "--verify", "--deep", "--strict", appPath },
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
});
|
||||
Assert.NotNull(verification);
|
||||
verification.WaitForExit();
|
||||
Assert.True(
|
||||
verification.ExitCode == 0,
|
||||
$"Expected a valid app-bundle signature: {verification.StandardError.ReadToEnd()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,6 +184,10 @@ public sealed class MacOsMeetingAudioSourceTests
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// The endpoint fixture supplies its own transcription pipeline and does not warm external pyannote models.
|
||||
services.Remove(services.Single(descriptor =>
|
||||
descriptor.ServiceType == typeof(IHostedService) &&
|
||||
descriptor.ImplementationType == typeof(PyannoteDiarizationWarmupHostedService)));
|
||||
services.RemoveAll<IMeetingAudioSource>();
|
||||
services.RemoveAll<IMacOsAudioCaptureProcessFactory>();
|
||||
PortableMeetingAudioRegistration.Add(services, isMacOs: true);
|
||||
|
||||
@@ -133,14 +133,9 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
Assert.Equal("meeting.png.cropped.png", croppedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[MacOsFact]
|
||||
public async Task MacOsCapabilityEndpointReportsEnabledRealProviders()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var factory = new WebApplicationFactory<Program>();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
@@ -163,14 +158,9 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
Assert.True(report.WhisperLocalDiarizationEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[MacOsFact]
|
||||
public async Task NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var runner = new ProcessCommandRunner(
|
||||
NullLogger<ProcessCommandRunner>.Instance);
|
||||
|
||||
@@ -190,14 +180,9 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[MacOsFact]
|
||||
public async Task NativeHelperCropsPngUsingOcrPixelCoordinates()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var testRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"meeting-assistant-native-crop-{Guid.NewGuid():N}");
|
||||
@@ -268,14 +253,9 @@ public sealed class MacOsMeetingIntegrationTests
|
||||
Assert.Equal(TimeSpan.FromHours(23), client.CalendarTo - client.CalendarFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[MacOsFact]
|
||||
public async Task CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var runner = new SequenceCommandRunner(
|
||||
new CommandResult(
|
||||
MacOsIntegrationException.CalendarPermissionDeniedExitCode,
|
||||
|
||||
@@ -206,7 +206,7 @@ public sealed class MeetingNoteStoreTests
|
||||
{
|
||||
var link = MeetingNoteActionLinks.CreateSummaryRetryLink(
|
||||
"http://localhost:5090/",
|
||||
"C:\\Vault\\Meetings\\Summaries\\summary with spaces.md");
|
||||
Path.Combine(Path.GetTempPath(), "summary with spaces.md"));
|
||||
|
||||
Assert.Equal(
|
||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary%20with%20spaces.md)",
|
||||
|
||||
@@ -7,6 +7,37 @@ namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingSummaryInstructionBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuilderAddsBoundMetadataWithoutAgentsAndRequestGuidanceToCustomPrompt()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
var artifacts = CreateArtifacts(root);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, ["Alpha"]);
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Alpha"));
|
||||
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "PROJECT.md"),
|
||||
"---\nname: Alpha Platform\ndescription: Builds meeting automation\n---\nInternal notes");
|
||||
await WriteProjectAgentsAsync(projectsRoot, "Unbound", "Do not expose these instructions");
|
||||
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Agent = new AgentOptions { InitialPrompt = "Custom base." },
|
||||
Vault = new VaultOptions { ProjectsFolder = projectsRoot }
|
||||
}));
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
Assert.StartsWith("Custom base.", instructions);
|
||||
Assert.Contains("# Alpha", instructions);
|
||||
Assert.Contains("Alpha Platform", instructions);
|
||||
Assert.Contains("Builds meeting automation", instructions);
|
||||
Assert.Contains("PROJECT.md", instructions);
|
||||
Assert.Contains("256", instructions);
|
||||
Assert.Contains("request_project_association", instructions);
|
||||
Assert.Contains("request_workflow_change", instructions);
|
||||
Assert.DoesNotContain("Internal notes", instructions);
|
||||
Assert.DoesNotContain("Do not expose", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuilderUsesDefaultPromptWhenConfiguredPromptIsBlank()
|
||||
{
|
||||
@@ -75,7 +106,8 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Custom summarizer instructions.", instructions);
|
||||
Assert.StartsWith("Custom summarizer instructions.", instructions);
|
||||
Assert.Contains("request_project_association", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,21 +128,12 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
var expected = """
|
||||
Base.
|
||||
|
||||
---
|
||||
projects:
|
||||
|
||||
# Alpha
|
||||
|
||||
Alpha instructions.
|
||||
|
||||
# Beta
|
||||
|
||||
Beta instructions.
|
||||
""";
|
||||
Assert.Equal(NormalizeLineEndings(expected), NormalizeLineEndings(instructions));
|
||||
Assert.StartsWith("Base.", instructions);
|
||||
Assert.Contains("---\nprojects:", NormalizeLineEndings(instructions));
|
||||
Assert.Contains("# Alpha", instructions);
|
||||
Assert.Contains("Alpha instructions.", instructions);
|
||||
Assert.Contains("# Beta", instructions);
|
||||
Assert.Contains("Beta instructions.", instructions);
|
||||
}
|
||||
|
||||
private static MeetingSessionArtifacts CreateArtifacts(string root)
|
||||
|
||||
@@ -50,6 +50,145 @@ public sealed class MicrophoneAudioSourceTests
|
||||
await cancellation.CancelAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedNewMicrophoneReplacesCaptureWithinTheSameStream()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
var devices = new SelectableCaptureDevices(selection);
|
||||
var prompts = new AcceptingPrompt();
|
||||
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
|
||||
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
|
||||
var source = new MicrophoneAudioSource(devices, NullLogger<MicrophoneAudioSource>.Instance,
|
||||
TimeSpan.Zero, monitor, selection);
|
||||
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator();
|
||||
try
|
||||
{
|
||||
Assert.True(await chunks.MoveNextAsync());
|
||||
Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||
devices.Available = [new("old", "Built-in"), new("new", "USB headset")];
|
||||
Assert.Equal("new", (await prompts.Shown.Task.WaitAsync(TimeSpan.FromSeconds(2))).Id);
|
||||
var nextChunk = chunks.MoveNextAsync().AsTask();
|
||||
Assert.False(nextChunk.IsCompleted);
|
||||
Assert.Null(selection.SelectedDeviceId);
|
||||
|
||||
prompts.Response.SetResult(true);
|
||||
|
||||
Assert.True(await nextChunk.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||
Assert.Equal("new", selection.SelectedDeviceId);
|
||||
Assert.False(lifetime.IsCancellationRequested);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await lifetime.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndingMeetingDuringCreationDisposesAbandonedCapture()
|
||||
{
|
||||
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var abandonedCapture = new DisposableAudioSource();
|
||||
var factory = new CallbackCaptureFactory(() =>
|
||||
{
|
||||
lifetime.Cancel();
|
||||
return abandonedCapture;
|
||||
});
|
||||
var source = new MicrophoneAudioSource(factory, NullLogger<MicrophoneAudioSource>.Instance);
|
||||
await using var chunks = source.CaptureAsync(lifetime.Token).GetAsyncEnumerator();
|
||||
|
||||
Assert.False(await chunks.MoveNextAsync());
|
||||
Assert.True(abandonedCapture.Disposed);
|
||||
}
|
||||
|
||||
private sealed class CallbackCaptureFactory(Func<IMeetingAudioSource> create) : IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) => create();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedSwitchDuringCreationDisposesAbandonedCapture()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
var devices = new SwitchDuringCreationDevices(selection);
|
||||
var prompts = new AcceptingPrompt();
|
||||
prompts.Response.SetResult(true);
|
||||
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
|
||||
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
|
||||
var source = new MicrophoneAudioSource(devices, NullLogger<MicrophoneAudioSource>.Instance,
|
||||
TimeSpan.Zero, monitor, selection);
|
||||
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator();
|
||||
try
|
||||
{
|
||||
Assert.True(await chunks.MoveNextAsync());
|
||||
Assert.Equal("new", selection.SelectedDeviceId);
|
||||
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||
Assert.True(devices.AbandonedCapture.Disposed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await lifetime.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SwitchDuringCreationDevices(MicrophoneDeviceSelection selection)
|
||||
: IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
private MicrophoneDevice[] available = [new("old", "Built-in")];
|
||||
private int creations;
|
||||
private volatile bool switchObserved;
|
||||
public DisposableAudioSource AbandonedCapture { get; } = new();
|
||||
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
|
||||
{
|
||||
if (selection.SelectedDeviceId == "new") switchObserved = true;
|
||||
return Volatile.Read(ref available);
|
||||
}
|
||||
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
|
||||
=> new(GetAvailableMicrophones(), available[0]);
|
||||
|
||||
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
||||
{
|
||||
if (++creations > 1) return new ActiveAudioSource(Pcm16(2_000));
|
||||
Volatile.Write(ref available, [new("old", "Built-in"), new("new", "USB headset")]);
|
||||
// Let the acceptance callback finish before returning the already-opened source.
|
||||
if (!SpinWait.SpinUntil(() => switchObserved, TimeSpan.FromSeconds(2)))
|
||||
throw new TimeoutException("The new microphone was not selected during capture creation.");
|
||||
return AbandonedCapture;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisposableAudioSource : IMeetingAudioSource, IDisposable
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
=> new ActiveAudioSource(Pcm16(1_000)).CaptureAsync(cancellationToken);
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class AcceptingPrompt : IMicrophoneSwitchPromptService
|
||||
{
|
||||
public TaskCompletionSource<MicrophoneDevice> Shown { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
Shown.TrySetResult(microphone);
|
||||
return Response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SelectableCaptureDevices(MicrophoneDeviceSelection selection)
|
||||
: IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
public volatile MicrophoneDevice[] Available = [new("old", "Built-in")];
|
||||
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones() => Available;
|
||||
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
|
||||
=> new(Available, selection.Resolve(options.Recording.MicrophoneDeviceId, Available[0], Available));
|
||||
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
||||
=> new ActiveAudioSource(Pcm16(GetMicrophoneSnapshot(options).Current!.Id == "new" ? (short)2_000 : (short)1_000));
|
||||
}
|
||||
|
||||
private static byte[] Pcm16(short sample)
|
||||
{
|
||||
return BitConverter.GetBytes(sample);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Threading.Channels;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MicrophoneSwitchMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CancellationDuringConfirmationDeviceLookupCannotSwitchMicrophone()
|
||||
{
|
||||
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var devices = new MutableDevices();
|
||||
var prompts = new ImmediatelyAcceptedPrompt(() => devices.BeforeRead = lifetime.Cancel);
|
||||
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
|
||||
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
|
||||
var switched = false;
|
||||
var monitoring = monitor.RunAsync((_, _) => switched = true, lifetime.Token);
|
||||
devices.Available = [new("usb", "USB microphone")];
|
||||
|
||||
await monitoring.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(prompts.WasShown);
|
||||
Assert.False(switched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffersOnlyNewDevicesOnceAndSwitchesOnlyAfterAcceptance()
|
||||
{
|
||||
var oldDevice = new MicrophoneDevice("old", "Built-in microphone");
|
||||
var newDevice = new MicrophoneDevice("new", "USB headset");
|
||||
var devices = new MutableDevices { Available = [oldDevice] };
|
||||
var prompts = new ControlledPrompts();
|
||||
var monitor = CreateMonitor(devices, prompts);
|
||||
var switched = new TaskCompletionSource<MicrophoneDevice>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var monitoring = monitor.RunAsync((device, _) => switched.TrySetResult(device), lifetime.Token);
|
||||
try
|
||||
{
|
||||
devices.Available = [oldDevice, newDevice];
|
||||
var prompt = await prompts.NextAsync();
|
||||
Assert.Equal(newDevice, prompt.Device);
|
||||
Assert.False(switched.Task.IsCompleted);
|
||||
await devices.WaitForPollsAsync();
|
||||
Assert.False(prompts.HasMore);
|
||||
|
||||
prompt.Response.SetResult(true);
|
||||
Assert.Equal(newDevice, await switched.Task.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
await devices.WaitForPollsAsync();
|
||||
Assert.False(prompts.HasMore);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await lifetime.CancelAsync();
|
||||
await monitoring;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("declined")]
|
||||
[InlineData("removed")]
|
||||
[InlineData("expired")]
|
||||
[InlineData("ended")]
|
||||
public async Task InvalidOrDeclinedPromptsCannotChangeSelection(string outcome)
|
||||
{
|
||||
var device = new MicrophoneDevice("usb", "USB microphone");
|
||||
var devices = new MutableDevices();
|
||||
var prompts = new ControlledPrompts();
|
||||
var monitor = CreateMonitor(devices, prompts,
|
||||
outcome == "expired" ? TimeSpan.FromMilliseconds(30) : null);
|
||||
var switched = false;
|
||||
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var monitoring = monitor.RunAsync((_, _) => switched = true, lifetime.Token);
|
||||
try
|
||||
{
|
||||
devices.Available = [device];
|
||||
var prompt = await prompts.NextAsync();
|
||||
if (outcome == "declined")
|
||||
{
|
||||
prompt.Response.SetResult(false);
|
||||
await devices.WaitForPollsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (outcome == "removed") devices.Available = [];
|
||||
if (outcome == "ended") await lifetime.CancelAsync();
|
||||
var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var registration = prompt.Cancellation.Register(() => canceled.TrySetResult());
|
||||
await canceled.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
prompt.Response.TrySetResult(true);
|
||||
}
|
||||
|
||||
await lifetime.CancelAsync();
|
||||
await monitoring;
|
||||
Assert.False(switched);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await lifetime.CancelAsync();
|
||||
await monitoring;
|
||||
}
|
||||
}
|
||||
|
||||
private static MicrophoneSwitchMonitor CreateMonitor(MutableDevices devices, ControlledPrompts prompts,
|
||||
TimeSpan? promptLifetime = null) => new(devices, prompts,
|
||||
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10),
|
||||
promptLifetime ?? TimeSpan.FromMinutes(1));
|
||||
|
||||
private sealed class MutableDevices : IMicrophoneDeviceProvider
|
||||
{
|
||||
private readonly Channel<bool> polls = Channel.CreateUnbounded<bool>();
|
||||
public volatile MicrophoneDevice[] Available = [];
|
||||
public Action? BeforeRead { get; set; }
|
||||
|
||||
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
|
||||
{
|
||||
BeforeRead?.Invoke();
|
||||
polls.Writer.TryWrite(true);
|
||||
return Available;
|
||||
}
|
||||
|
||||
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
|
||||
=> new(Available, Available.FirstOrDefault());
|
||||
|
||||
public async Task WaitForPollsAsync()
|
||||
{
|
||||
while (polls.Reader.TryRead(out _)) { }
|
||||
for (var i = 0; i < 3; i++)
|
||||
await polls.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ImmediatelyAcceptedPrompt(Action beforeAcceptance) : IMicrophoneSwitchPromptService
|
||||
{
|
||||
public bool WasShown { get; private set; }
|
||||
|
||||
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
WasShown = true;
|
||||
beforeAcceptance();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledPrompts : IMicrophoneSwitchPromptService
|
||||
{
|
||||
private readonly Channel<Prompt> requests = Channel.CreateUnbounded<Prompt>();
|
||||
public bool HasMore => requests.Reader.TryPeek(out _);
|
||||
public Task<Prompt> NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
public async Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
var prompt = new Prompt(microphone, cancellationToken);
|
||||
requests.Writer.TryWrite(prompt);
|
||||
return await prompt.Response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Prompt(MicrophoneDevice Device, CancellationToken Cancellation)
|
||||
{
|
||||
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,90 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ProjectKnowledgeToolTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("name: Incomplete project")]
|
||||
[InlineData("description: Missing name")]
|
||||
[InlineData("name: ' '\ndescription: Missing name")]
|
||||
public async Task CatalogUsesLegacyFallbackForIncompleteMetadata(string frontmatter)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectRoot = Path.Combine(root, "Projects", "Alpha");
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), $"---\n{frontmatter}\n---\nNotes");
|
||||
var tools = new MeetingSummaryTools(CreateArtifacts(root),
|
||||
new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
|
||||
|
||||
using var catalog = JsonDocument.Parse(await tools.ListProjects());
|
||||
|
||||
var project = Assert.Single(catalog.RootElement.EnumerateArray());
|
||||
Assert.Equal("Alpha", project.GetProperty("displayname").GetString());
|
||||
Assert.Equal("", project.GetProperty("description").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectMetadataWriteEnforcesDescriptionLimitAndPreservesOtherContent()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectRoot = Path.Combine(root, "Projects", "Alpha");
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: Alpha\n---");
|
||||
var tools = new MeetingSummaryTools(artifacts,
|
||||
new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
|
||||
var original = $"---\nname: Alpha Platform\ndescription: {new string('a', 256)}\nowner: Ada\n---\nUser-authored project notes.";
|
||||
Assert.Equal("Alpha/PROJECT.md", await tools.WriteProjectFile("Alpha", "PROJECT.md", original));
|
||||
|
||||
var rejected = await tools.WriteProjectFile("Alpha", "PROJECT.md",
|
||||
$"description: {new string('b', 257)}", from: 3, to: 3);
|
||||
|
||||
Assert.StartsWith("Refused:", rejected);
|
||||
Assert.Equal(original, await File.ReadAllTextAsync(Path.Combine(projectRoot, "PROJECT.md")));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "PROJECT.md", "No metadata", replace_file: true));
|
||||
Assert.Equal("Alpha/PROJECT.md", await tools.WriteProjectFile("Alpha", "PROJECT.md",
|
||||
"description: Updated project purpose", from: 3, to: 3));
|
||||
var updated = await tools.ReadProjectFile("Alpha", "PROJECT.md");
|
||||
Assert.Contains("owner: Ada", updated);
|
||||
Assert.Contains("User-authored project notes.", updated);
|
||||
Assert.Contains("Updated project purpose", await tools.ListProjects());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CatalogListsAllProjectsWithMetadataAndLegacyFallbackWithoutGrantingReadAccess()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Alpha"));
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Legacy"));
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Malformed"));
|
||||
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "PROJECT.md"),
|
||||
"---\nname: Alpha Platform\ndescription: Meeting automation\n---\nPrivate project notes");
|
||||
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "AGENTS.md"), "Private instructions");
|
||||
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Malformed", "PROJECT.md"), "---\nname: [broken\n---");
|
||||
var tools = new MeetingSummaryTools(CreateArtifacts(root),
|
||||
new MeetingAssistantOptions { Vault = { ProjectsFolder = projectsRoot } });
|
||||
|
||||
var catalog = await tools.ListProjects();
|
||||
|
||||
using var json = JsonDocument.Parse(catalog);
|
||||
var projects = json.RootElement.EnumerateArray().ToArray();
|
||||
Assert.Equal(3, projects.Length);
|
||||
Assert.Equal("Alpha", projects[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Alpha Platform", projects[0].GetProperty("displayname").GetString());
|
||||
Assert.Equal("Meeting automation", projects[0].GetProperty("description").GetString());
|
||||
Assert.Equal("Legacy", projects[1].GetProperty("displayname").GetString());
|
||||
Assert.Equal("", projects[1].GetProperty("description").GetString());
|
||||
Assert.Equal("Malformed", projects[2].GetProperty("displayname").GetString());
|
||||
Assert.Equal("", projects[2].GetProperty("description").GetString());
|
||||
Assert.DoesNotContain("Private", catalog);
|
||||
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsOperateOnProjectsBoundInMeetingFrontmatter()
|
||||
{
|
||||
@@ -48,7 +128,7 @@ public sealed class ProjectKnowledgeToolTests
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("MeetingAssistant", await tools.ListProjects());
|
||||
Assert.Contains("MeetingAssistant", await tools.ListProjects());
|
||||
Assert.Equal("README.md\nnotes/context.md", await tools.ListProjectFiles("MeetingAssistant"));
|
||||
Assert.Equal("Second alpha line\nThird beta line", await tools.ReadProjectFile("MeetingAssistant", "README.md", 2, 99));
|
||||
|
||||
@@ -56,15 +136,15 @@ public sealed class ProjectKnowledgeToolTests
|
||||
|
||||
Assert.Equal("MeetingAssistant/notes/summary.md", writeResult);
|
||||
Assert.Equal("# Project Update", await File.ReadAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "summary.md")));
|
||||
Assert.Equal("IgnoredProject/ignored.md", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
|
||||
Assert.Equal("alpha should not be searched\nchanged", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
|
||||
Assert.Equal("alpha should not be searched", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
|
||||
Assert.Equal(
|
||||
"README.md:2 Second alpha line",
|
||||
await tools.Search("alpha"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListProjectsAcceptsScalarProjectFrontmatter()
|
||||
public async Task ProjectFileAccessAcceptsScalarProjectFrontmatter()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
@@ -88,7 +168,8 @@ public sealed class ProjectKnowledgeToolTests
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("MeetingAssistant", await tools.ListProjects());
|
||||
Assert.Equal("MeetingAssistant/notes.md", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "Bound project"));
|
||||
Assert.Equal("Bound project", await tools.ReadProjectFile("MeetingAssistant", "notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,8 +181,11 @@ public sealed class ProjectKnowledgeToolTests
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
var projectFile = Path.Combine(projectRoot, "notes.md");
|
||||
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree\nfour");
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: MeetingAssistant\n---\n");
|
||||
var tools = new MeetingSummaryTools(
|
||||
CreateArtifacts(root),
|
||||
artifacts,
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
@@ -132,8 +216,13 @@ public sealed class ProjectKnowledgeToolTests
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: MeetingAssistant\n---\n");
|
||||
var outsidePath = Path.Combine(projectsRoot, "outside.md");
|
||||
await File.WriteAllTextAsync(outsidePath, "Preserve outside content");
|
||||
var tools = new MeetingSummaryTools(
|
||||
CreateArtifacts(root),
|
||||
artifacts,
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
@@ -144,6 +233,7 @@ public sealed class ProjectKnowledgeToolTests
|
||||
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MissingProject", "notes.md", "content"));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "../outside.md", "content"));
|
||||
Assert.Equal("Preserve outside content", await File.ReadAllTextAsync(outsidePath));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, insert: 1));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true));
|
||||
|
||||
@@ -3,11 +3,92 @@ using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.ExceptionServices;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class PyannoteDiarizationWarmupHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HostedServiceStopsSafelyWhenShutdownRequestsOverlap()
|
||||
{
|
||||
const int iterations = 64;
|
||||
const int shutdownCallers = 8;
|
||||
using var applicationLifetime = new CancellationTokenSource();
|
||||
var finalizer = new PyannoteTranscriptFinalizer(
|
||||
new BlockingCommandRunner(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
using var callersReady = new Barrier(shutdownCallers + 1);
|
||||
var shutdownErrors = new ConcurrentQueue<ExceptionDispatchInfo>();
|
||||
PyannoteDiarizationWarmupHostedService? service = null;
|
||||
var shutdownTasks = Enumerable.Range(0, shutdownCallers)
|
||||
.Select(_ => Task.Factory.StartNew(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var iteration = 0; iteration < iterations; iteration++)
|
||||
{
|
||||
WaitForShutdownCallers();
|
||||
try
|
||||
{
|
||||
service!.StopAsync(CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
shutdownErrors.Enqueue(ExceptionDispatchInfo.Capture(exception));
|
||||
}
|
||||
WaitForShutdownCallers();
|
||||
if (!shutdownErrors.IsEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
shutdownErrors.Enqueue(ExceptionDispatchInfo.Capture(exception));
|
||||
}
|
||||
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default))
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
for (var iteration = 0; iteration < iterations; iteration++)
|
||||
{
|
||||
service = new PyannoteDiarizationWarmupHostedService(
|
||||
finalizer,
|
||||
new FakeLaunchProfileOptionsProvider(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
|
||||
await service.StartAsync(applicationLifetime.Token);
|
||||
WaitForShutdownCallers();
|
||||
WaitForShutdownCallers();
|
||||
if (!shutdownErrors.IsEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Task.WhenAll(shutdownTasks).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
if (shutdownErrors.TryPeek(out var shutdownError))
|
||||
{
|
||||
shutdownError.Throw();
|
||||
}
|
||||
|
||||
void WaitForShutdownCallers()
|
||||
{
|
||||
Assert.True(
|
||||
callersReady.SignalAndWait(TimeSpan.FromSeconds(5)),
|
||||
"Timed out coordinating overlapping shutdown requests.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedServiceWarmsEnabledValidationRuntimeWithoutBlockingStartup()
|
||||
{
|
||||
|
||||
@@ -448,9 +448,14 @@ public sealed class RecordingCoordinatorTests
|
||||
[Fact]
|
||||
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
|
||||
{
|
||||
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault");
|
||||
var transcriptPath = Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md");
|
||||
var meetingNotePath = Path.Combine(vaultRoot, "Meetings", "Notes", "20260519-meeting.md");
|
||||
var assistantContextFolder = Path.Combine(vaultRoot, "Meetings", "Assistant Context");
|
||||
var summariesFolder = Path.Combine(vaultRoot, "Meetings", "Summaries");
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
|
||||
var transcriptStore = new InMemoryTranscriptStore(transcriptPath);
|
||||
var noteStore = new InMemoryMeetingNoteStore(meetingNotePath);
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
|
||||
var noteOpener = new CapturingMeetingNoteOpener();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
@@ -468,25 +473,27 @@ public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context",
|
||||
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
|
||||
AssistantContextFolder = assistantContextFolder,
|
||||
SummariesFolder = summariesFolder
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityClock: clock);
|
||||
|
||||
var status = await coordinator.StartAsync(CancellationToken.None);
|
||||
var assistantContextPath = Path.Combine(assistantContextFolder, "20260519-1003-context.md");
|
||||
var summaryPath = Path.Combine(summariesFolder, "20260519-1003-summary.md");
|
||||
|
||||
Assert.True(status.IsRecording);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath);
|
||||
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", artifactStore.CreatedArtifacts?.SummaryPath);
|
||||
Assert.Equal(meetingNotePath, status.MeetingNotePath);
|
||||
Assert.Equal(transcriptPath, noteStore.SavedNote?.Frontmatter.Transcript);
|
||||
Assert.Equal(assistantContextPath, noteStore.SavedNote?.Frontmatter.AssistantContext);
|
||||
Assert.Equal(summaryPath, noteStore.SavedNote?.Frontmatter.Summary);
|
||||
Assert.Equal(meetingNotePath, noteOpener.OpenedPath);
|
||||
Assert.Equal(meetingNotePath, artifactStore.CreatedArtifacts?.MeetingNotePath);
|
||||
Assert.Equal(transcriptPath, artifactStore.CreatedArtifacts?.TranscriptPath);
|
||||
Assert.Equal(assistantContextPath, artifactStore.CreatedArtifacts?.AssistantContextPath);
|
||||
Assert.Equal(summaryPath, artifactStore.CreatedArtifacts?.SummaryPath);
|
||||
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
||||
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
|
||||
|
||||
@@ -2209,8 +2216,8 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal(1, processed);
|
||||
Assert.Empty(backlog.Items);
|
||||
Assert.Equal(["english"], pipelineFactory.ProfileNames);
|
||||
var segment = Assert.Single(transcriptStore.ReplacedSegments);
|
||||
Assert.Contains("english chunk:4", segment.Text, StringComparison.Ordinal);
|
||||
Assert.Single(transcriptStore.Segments, segment => segment.Text.Contains("english chunk:4", StringComparison.Ordinal));
|
||||
Assert.Contains(transcriptStore.Segments, segment => segment.Text.Contains("may duplicate earlier transcript passages", StringComparison.Ordinal));
|
||||
Assert.Equal(stoppedAt, noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
@@ -2221,6 +2228,167 @@ public sealed class RecordingCoordinatorTests
|
||||
workflowEvent.TranscriptLineText?.Contains("english chunk:4", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" \t", false)]
|
||||
[InlineData("<Reconnecting... 1/5>", true)]
|
||||
public async Task OfflineReplayWithoutSpeechPreservesTranscriptAndRecoveryFilesUntilSuccessfulRetry(
|
||||
string? replayText, bool isMarker)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var audioPath = Path.Combine(root, "queued.wav");
|
||||
using (var writer = new NAudio.Wave.WaveFileWriter(audioPath, new NAudio.Wave.WaveFormat(16000, 16, 1)))
|
||||
{
|
||||
writer.Write([1, 0, 2, 0], 0, 4);
|
||||
}
|
||||
|
||||
var options = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts" },
|
||||
Recording = new RecordingOptions { TemporaryRecordingsFolder = root }
|
||||
});
|
||||
var transcriptStore = new VaultTranscriptStore(options, NullLogger<VaultTranscriptStore>.Instance);
|
||||
var noteStore = new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var summaryPipeline = new TranscriptReadingSummaryPipeline();
|
||||
var workflowEngine = new CapturingMeetingWorkflowEngine();
|
||||
var backlog = new FileOfflineTranscriptionBacklog(options, NullLogger<FileOfflineTranscriptionBacklog>.Instance);
|
||||
var startedAt = DateTimeOffset.Now.AddMinutes(-5);
|
||||
var stoppedAt = startedAt.AddMinutes(4);
|
||||
var session = await transcriptStore.CreateSessionAsync(options.Value, startedAt, CancellationToken.None);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "meeting.md"), session.TranscriptPath,
|
||||
Path.Combine(root, "context.md"), Path.Combine(root, "summary.md"));
|
||||
var note = await noteStore.SaveAsync(new MeetingNote(
|
||||
artifacts.MeetingNotePath,
|
||||
new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = "Queued meeting", StartTime = startedAt,
|
||||
Transcript = session.TranscriptPath,
|
||||
AssistantContext = artifacts.AssistantContextPath,
|
||||
Summary = artifacts.SummaryPath
|
||||
},
|
||||
"Existing user notes"), CancellationToken.None);
|
||||
await transcriptStore.UpdateMetadataAsync(session, artifacts, note, CancellationToken.None);
|
||||
await transcriptStore.AppendLineAsync(session, "[00:00:01] Ada: Preserve this live transcript.", CancellationToken.None);
|
||||
await File.AppendAllTextAsync(session.TranscriptPath, "User correction: keep the original speaker names.");
|
||||
var originalTranscript = await File.ReadAllBytesAsync(session.TranscriptPath);
|
||||
var originalBody = MeetingArtifactFrontmatterRenderer.Split(await File.ReadAllTextAsync(session.TranscriptPath)).Body;
|
||||
var originalNote = await File.ReadAllBytesAsync(note.Path);
|
||||
var originalAudio = await File.ReadAllBytesAsync(audioPath);
|
||||
var item = new OfflineTranscriptionBacklogItem(
|
||||
"queued", audioPath, session.TranscriptPath, note.Path,
|
||||
artifacts.AssistantContextPath, artifacts.SummaryPath, startedAt, stoppedAt, "default");
|
||||
await backlog.EnqueueAsync(item, CancellationToken.None);
|
||||
var replaySegments = new List<TranscriptionSegment>();
|
||||
if (replayText is not null)
|
||||
{
|
||||
replaySegments.Add(new TranscriptionSegment(
|
||||
TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", replayText,
|
||||
isMarker ? TranscriptionSegmentKind.Marker : TranscriptionSegmentKind.Speech));
|
||||
}
|
||||
|
||||
var processor = new OfflineTranscriptionBacklogProcessor(
|
||||
backlog,
|
||||
new TestSpeechRecognitionPipelineFactory(new SequencedStreamingTranscriptionProvider(replaySegments)),
|
||||
transcriptStore, noteStore, artifactStore, summaryPipeline, workflowEngine,
|
||||
CreateRecordingDictationWordProvider(new FixedDictationWordStore([])), options,
|
||||
NullLogger<OfflineTranscriptionBacklogProcessor>.Instance);
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var processed = await processor.ProcessPendingAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(originalTranscript, await File.ReadAllBytesAsync(session.TranscriptPath));
|
||||
Assert.Equal(0, processed);
|
||||
Assert.Equal(originalNote, await File.ReadAllBytesAsync(note.Path));
|
||||
Assert.Equal(originalAudio, await File.ReadAllBytesAsync(audioPath));
|
||||
var reloadedBacklog = new FileOfflineTranscriptionBacklog(options, NullLogger<FileOfflineTranscriptionBacklog>.Instance);
|
||||
Assert.Equal(item, Assert.Single(await reloadedBacklog.ListAsync(CancellationToken.None)));
|
||||
Assert.Null(summaryPipeline.Transcript);
|
||||
Assert.Empty(artifactStore.States);
|
||||
Assert.Empty(workflowEngine.Events);
|
||||
}
|
||||
|
||||
replaySegments.Clear();
|
||||
replaySegments.Add(new TranscriptionSegment(
|
||||
TimeSpan.Zero, TimeSpan.FromSeconds(1), "Ada", "Preserve this live transcript. Recovered speech."));
|
||||
|
||||
Assert.Equal(1, await processor.ProcessPendingAsync(CancellationToken.None));
|
||||
var recoveredTranscript = await File.ReadAllTextAsync(session.TranscriptPath);
|
||||
Assert.Contains("Recovered speech.", recoveredTranscript, StringComparison.Ordinal);
|
||||
var recoveredBody = MeetingArtifactFrontmatterRenderer.Split(recoveredTranscript).Body;
|
||||
Assert.StartsWith(originalBody, recoveredBody, StringComparison.Ordinal);
|
||||
Assert.Equal(2, recoveredBody.Split("Preserve this live transcript.", StringSplitOptions.None).Length - 1);
|
||||
var appended = recoveredBody[originalBody.Length..];
|
||||
Assert.StartsWith(Environment.NewLine + "## Offline transcription recovery", appended, StringComparison.Ordinal);
|
||||
Assert.Contains("may duplicate earlier transcript passages", appended, StringComparison.Ordinal);
|
||||
Assert.Contains("Treat repeated passages as the same discussion when summarizing", appended, StringComparison.Ordinal);
|
||||
Assert.True(appended.IndexOf("may duplicate", StringComparison.Ordinal) < appended.IndexOf("Recovered speech.", StringComparison.Ordinal));
|
||||
Assert.Equal(stoppedAt, (await noteStore.ReadAsync(note.Path, CancellationToken.None)).Frontmatter.EndTime);
|
||||
Assert.Equal(recoveredTranscript, summaryPipeline.Transcript);
|
||||
Assert.Equal(new[] { AssistantContextState.Summarizing, AssistantContextState.Finished }, artifactStore.States);
|
||||
Assert.Empty(await backlog.ListAsync(CancellationToken.None));
|
||||
Assert.False(File.Exists(audioPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopTimeoutKeepsSummaryApprovalAliveWithoutQueuingOfflineReplay()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var summary = new WaitingForApprovalSummaryPipeline();
|
||||
var backlog = new InMemoryOfflineTranscriptionBacklog();
|
||||
var artifacts = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(),
|
||||
artifacts, new InMemoryRecordedAudioStore(), summary,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
TranscriptionProvider = "azure-speech",
|
||||
StopProcessingTimeout = TimeSpan.FromMilliseconds(500)
|
||||
}
|
||||
}), NullLogger<MeetingRecordingCoordinator>.Instance, offlineTranscriptionBacklog: backlog);
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
var stopping = coordinator.StopAsync(CancellationToken.None);
|
||||
await summary.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
var status = await stopping.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.False(status.IsRecording);
|
||||
Assert.False(summary.Cancellation.IsCancellationRequested);
|
||||
Assert.Empty(backlog.Items);
|
||||
}
|
||||
finally { summary.Release.TrySetResult(); }
|
||||
await WaitUntilAsync(() => artifacts.States.Contains(AssistantContextState.Finished), "Approved summary did not finish.");
|
||||
}
|
||||
|
||||
private sealed class WaitingForApprovalSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public CancellationToken Cancellation { get; private set; }
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken)
|
||||
{
|
||||
Cancellation = cancellationToken;
|
||||
Started.TrySetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "Approved and completed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
|
||||
{
|
||||
@@ -4587,6 +4755,19 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TranscriptReadingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
public string? Transcript { get; private set; }
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath, cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok", true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly bool succeeded;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Threading.Channels;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MeetingAssistant.Workflow;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SummaryAgentRequestTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("denied")]
|
||||
[InlineData("expired")]
|
||||
[InlineData("shutdown")]
|
||||
public async Task WorkflowDenialExpirationAndShutdownRemovePromptWithoutStartingAgent(string outcome)
|
||||
{
|
||||
var prompts = new ControlledPrompts();
|
||||
var editor = new RecordingEditor();
|
||||
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance,
|
||||
outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10));
|
||||
using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger<WorkflowChangeRequestService>.Instance);
|
||||
await requests.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
Assert.StartsWith("Requested", requests.Request("Add association rule", "Given Alpha, then associate Alpha.", "meeting.md"));
|
||||
var prompt = await prompts.NextAsync();
|
||||
if (outcome == "denied") prompt.Response.SetResult(false);
|
||||
if (outcome == "shutdown") await requests.StopAsync(CancellationToken.None);
|
||||
await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
prompt.Response.TrySetResult(true);
|
||||
}
|
||||
finally { await requests.StopAsync(CancellationToken.None); }
|
||||
Assert.Empty(editor.Prompts);
|
||||
Assert.StartsWith("Refused", requests.Request("Too late", "Do something", "meeting.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidRequestsAreRefusedBeforeNotification()
|
||||
{
|
||||
var fixture = await CreateAsync();
|
||||
var original = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
|
||||
var prompts = new ControlledPrompts();
|
||||
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance);
|
||||
using var requests = new WorkflowChangeRequestService(approvals, new RecordingEditor(), NullLogger<WorkflowChangeRequestService>.Instance);
|
||||
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals, workflowRequests: requests);
|
||||
|
||||
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("../Alpha", "Relevant"));
|
||||
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", new string('a', 101)));
|
||||
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", "two\nlines"));
|
||||
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", ""));
|
||||
Assert.StartsWith("Refused", tools.RequestWorkflowChange(new string('a', 101), "Detailed scenario"));
|
||||
Assert.StartsWith("Refused", tools.RequestWorkflowChange("Intent", " "));
|
||||
Assert.StartsWith("Refused", tools.RequestWorkflowChange("two\nlines", "Detailed scenario"));
|
||||
Assert.False(prompts.HasMore);
|
||||
Assert.Equal(original, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowRequestReturnsImmediatelyAndApprovalStartsItsDetailedPromptOnce()
|
||||
{
|
||||
var fixture = await CreateAsync();
|
||||
var prompts = new ControlledPrompts();
|
||||
var editor = new RecordingEditor();
|
||||
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance);
|
||||
using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger<WorkflowChangeRequestService>.Instance);
|
||||
await requests.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
const string detail = "Given a meeting with Alpha in its title, when metadata arrives, then add project Alpha.";
|
||||
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, workflowRequests: requests);
|
||||
|
||||
var result = tools.RequestWorkflowChange("Associate Alpha meetings automatically", detail);
|
||||
|
||||
Assert.StartsWith("Requested", result);
|
||||
var prompt = await prompts.NextAsync();
|
||||
Assert.Contains("Workflow change requested: Associate Alpha meetings automatically", prompt.Prompt.Title);
|
||||
Assert.Empty(editor.Prompts);
|
||||
await tools.WriteSummary("The summary can finish while approval is pending.", "Summary finished");
|
||||
Assert.True(tools.SummaryWasWritten);
|
||||
|
||||
prompt.Response.SetResult(true);
|
||||
Assert.Equal(detail, await editor.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.False(prompt.Response.TrySetResult(true));
|
||||
Assert.Single(editor.Prompts);
|
||||
await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
finally { await requests.StopAsync(CancellationToken.None); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("denied")]
|
||||
[InlineData("expired")]
|
||||
[InlineData("canceled")]
|
||||
[InlineData("removed")]
|
||||
public async Task UnapprovedOrStaleAssociationNeverChangesNoteOrProjectAccess(string outcome)
|
||||
{
|
||||
var fixture = await CreateAsync();
|
||||
var original = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
|
||||
var prompts = new ControlledPrompts();
|
||||
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance,
|
||||
outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10));
|
||||
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var request = tools.RequestProjectAssociation("Alpha", "Relevant project", cancellation.Token);
|
||||
var prompt = await prompts.NextAsync();
|
||||
|
||||
if (outcome == "denied") prompt.Response.SetResult(false);
|
||||
if (outcome == "canceled") await cancellation.CancelAsync();
|
||||
if (outcome == "removed")
|
||||
{
|
||||
Directory.Delete(Path.Combine(fixture.Options.Vault.ProjectsFolder, "Alpha"), recursive: true);
|
||||
prompt.Response.SetResult(true);
|
||||
}
|
||||
if (outcome == "canceled")
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => request);
|
||||
else
|
||||
Assert.DoesNotContain("Approved", await request.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
|
||||
await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
prompt.Response.TrySetResult(true);
|
||||
Assert.Equal(original, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
|
||||
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssociationWaitsForApprovalThenPreservesLatestNoteAndUnlocksProjectWithInstructions()
|
||||
{
|
||||
var fixture = await CreateAsync();
|
||||
var prompts = new ControlledPrompts();
|
||||
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance);
|
||||
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals);
|
||||
var request = tools.RequestProjectAssociation("alpha", "Transcript discusses the Alpha launch");
|
||||
var prompt = await prompts.NextAsync();
|
||||
Assert.Contains("Alpha Platform", prompt.Prompt.Message);
|
||||
Assert.Contains("Transcript discusses", prompt.Prompt.Message);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), prompt.Prompt.Timeout);
|
||||
Assert.False(request.IsCompleted);
|
||||
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "notes.md", "unauthorized"));
|
||||
var latestNote = "---\ntitle: Edited by user\nprojects: Existing\ncustom: keep-me\n---\n\nUser's latest notes.";
|
||||
await File.WriteAllTextAsync(fixture.Artifacts.MeetingNotePath, latestNote);
|
||||
|
||||
prompt.Response.SetResult(true);
|
||||
var result = await request.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.StartsWith("Approved", result);
|
||||
Assert.Contains("Alpha instructions", result);
|
||||
Assert.Contains("Alpha Platform", result);
|
||||
var note = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
|
||||
Assert.Contains("Edited by user", note);
|
||||
Assert.Contains("custom: keep-me", note);
|
||||
Assert.Contains("User's latest notes.", note);
|
||||
Assert.Contains("Existing", note);
|
||||
Assert.Contains("Alpha", note);
|
||||
Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!, "*.tmp"));
|
||||
Assert.Equal("Alpha instructions", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
|
||||
Assert.Equal("Alpha/notes.md", await tools.WriteProjectFile("Alpha", "notes.md", "approved"));
|
||||
Assert.Contains("notes.md:1 approved", await tools.Search("approved", ["Alpha"]));
|
||||
Assert.StartsWith("Approved", await tools.RequestProjectAssociation("Alpha", "Already associated"));
|
||||
Assert.False(prompts.HasMore);
|
||||
}
|
||||
|
||||
private static async Task<(MeetingSessionArtifacts Artifacts, MeetingAssistantOptions Options)> CreateAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var project = Path.Combine(root, "Projects", "Alpha");
|
||||
Directory.CreateDirectory(project);
|
||||
var artifacts = new MeetingSessionArtifacts(Path.Combine(root, "meeting.md"), Path.Combine(root, "transcript.md"),
|
||||
Path.Combine(root, "context.md"), Path.Combine(root, "summary.md"));
|
||||
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\ntitle: Planning\nprojects: []\n---\nUser notes.");
|
||||
await File.WriteAllTextAsync(Path.Combine(project, "PROJECT.md"),
|
||||
"---\nname: Alpha Platform\ndescription: Meeting automation\n---");
|
||||
await File.WriteAllTextAsync(Path.Combine(project, "AGENTS.md"), "Alpha instructions");
|
||||
return (artifacts, new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
|
||||
}
|
||||
|
||||
private sealed class RecordingEditor : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
public List<string> Prompts { get; } = [];
|
||||
public TaskCompletionSource<string> Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public void Show() { }
|
||||
public void ShowWithPrompt(string initialPrompt)
|
||||
{
|
||||
Prompts.Add(initialPrompt);
|
||||
Started.TrySetResult(initialPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledPrompts : IAgentApprovalPromptService
|
||||
{
|
||||
private readonly Channel<PendingPrompt> requests = Channel.CreateUnbounded<PendingPrompt>();
|
||||
public bool HasMore => requests.Reader.TryPeek(out _);
|
||||
public Task<PendingPrompt> NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
|
||||
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new PendingPrompt(prompt, cancellationToken);
|
||||
requests.Writer.TryWrite(request);
|
||||
try { return await request.Response.Task.WaitAsync(cancellationToken); }
|
||||
finally { request.Removed.TrySetResult(); }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingPrompt(AgentApprovalPrompt Prompt, CancellationToken Cancellation)
|
||||
{
|
||||
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Removed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,11 @@ public sealed class TaskbarIconTests
|
||||
[SupportedOSPlatform("windows")]
|
||||
public void TaskbarIconGlyphsAreVisuallyCentered()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var state in new[]
|
||||
{
|
||||
RecordingProcessState.Idle,
|
||||
|
||||
@@ -7,16 +7,16 @@ public sealed class VaultPathTests
|
||||
[Fact]
|
||||
public void ResolveUsesVaultBaseFolderForRelativePaths()
|
||||
{
|
||||
var baseFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault");
|
||||
var vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
|
||||
BaseFolder = baseFolder
|
||||
};
|
||||
|
||||
var resolved = VaultPath.Resolve(vault, @"Meetings\Transcripts");
|
||||
var relativePath = Path.Combine("Meetings", "Transcripts");
|
||||
var resolved = VaultPath.Resolve(vault, relativePath);
|
||||
|
||||
Assert.Equal(
|
||||
@"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta\Meetings\Transcripts",
|
||||
resolved);
|
||||
Assert.Equal(Path.Combine(baseFolder, relativePath), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -24,11 +24,12 @@ public sealed class VaultPathTests
|
||||
{
|
||||
var vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
|
||||
BaseFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-vault")
|
||||
};
|
||||
var absolutePath = Path.Combine(Path.GetTempPath(), "meeting-assistant-other");
|
||||
|
||||
var resolved = VaultPath.Resolve(vault, @"C:\Other\Path");
|
||||
var resolved = VaultPath.Resolve(vault, absolutePath);
|
||||
|
||||
Assert.Equal(@"C:\Other\Path", resolved);
|
||||
Assert.Equal(absolutePath, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ public sealed class VenvResemblyzerVoiceEncoderTests
|
||||
command => command.Arguments.Any(
|
||||
argument => argument.EndsWith("encode.py", StringComparison.Ordinal)));
|
||||
Assert.EndsWith(
|
||||
Path.Combine("Scripts", "python.exe"),
|
||||
OperatingSystem.IsWindows()
|
||||
? Path.Combine("Scripts", "python.exe")
|
||||
: Path.Combine("bin", "python"),
|
||||
encodingCommand.FileName,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(2, encodingCommand.Arguments.Count);
|
||||
|
||||
@@ -995,6 +995,30 @@ public sealed class WorkflowRulesEditorTests
|
||||
AssertCompletedActivity(viewModel, ["Thinking..."], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovedWorkflowPromptStartsImmediatelyInItsOwnConversation()
|
||||
{
|
||||
var existing = new WorkflowRulesEditorChatViewModel(new BlockingRulesEditorPipeline("Other work"))
|
||||
{
|
||||
Draft = "My unfinished draft"
|
||||
};
|
||||
var pipeline = new BlockingRulesEditorPipeline("Workflow updated.");
|
||||
var requested = new WorkflowRulesEditorChatViewModel(pipeline);
|
||||
const string prompt = "Given Alpha in the meeting title, when metadata arrives, then associate project Alpha.";
|
||||
|
||||
var task = requested.SendPromptAsync(prompt);
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(requested.IsThinking);
|
||||
Assert.Empty(pipeline.LastConversation);
|
||||
Assert.Equal(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt), requested.Messages[0].Message);
|
||||
Assert.Equal("My unfinished draft", existing.Draft);
|
||||
Assert.Empty(existing.Messages);
|
||||
pipeline.Release.SetResult();
|
||||
await task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.False(requested.IsThinking);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsAgentErrorWhenRequestTimesOut()
|
||||
{
|
||||
|
||||
@@ -32,7 +32,14 @@ Calendar-Feature is refined, Golem feature needs more work.
|
||||
|
||||
PROJECT.md should use this structure:
|
||||
|
||||
Maintain its YAML `name` as the project's display name and `description` as a grounded description of its purpose in at most 256 characters. These fields help summarizers identify missing project associations. Preserve other frontmatter and body content when updating them.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Project display name
|
||||
description: Concise project purpose and distinguishing context.
|
||||
---
|
||||
|
||||
# Executive Summary
|
||||
|
||||
<Elevator Pitch>
|
||||
|
||||
@@ -28,6 +28,12 @@ public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditor
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"Automatic workflow-change conversations require the Windows approval UI. Use the interactive macOS agent instead.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MacOsWorkflowRulesEditorSession
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
<MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
|
||||
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
|
||||
<MacOsAudioCaptureInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/Info.plist</MacOsAudioCaptureInfoPlist>
|
||||
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
||||
<MacOsAudioCaptureBundlePath>Native/MeetingAssistantAudioCapture.app</MacOsAudioCaptureBundlePath>
|
||||
<MacOsAudioCaptureOutputPath>$(MacOsAudioCaptureBundlePath)/Contents/MacOS/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
|
||||
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
|
||||
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
|
||||
@@ -29,6 +30,10 @@
|
||||
<MacOsMeetingIntegrationsSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/main.swift</MacOsMeetingIntegrationsSource>
|
||||
<MacOsMeetingIntegrationsInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsMeetingIntegrations/Info.plist</MacOsMeetingIntegrationsInfoPlist>
|
||||
<MacOsMeetingIntegrationsOutputPath>Native/macos-meeting-integrations</MacOsMeetingIntegrationsOutputPath>
|
||||
<MacOsLauncherSource>$(MSBuildProjectDirectory)/Native/MacOsLauncher/main.swift</MacOsLauncherSource>
|
||||
<MacOsLauncherInfoPlist>$(MSBuildProjectDirectory)/Native/MacOsLauncher/Info.plist</MacOsLauncherInfoPlist>
|
||||
<MacOsLauncherOutputPath>Native/macos-meeting-assistant-launcher</MacOsLauncherOutputPath>
|
||||
<MacOsApplicationBundlePath>MeetingAssistant.app</MacOsApplicationBundlePath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -53,7 +58,7 @@
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
|
||||
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
|
||||
<PackageReference Include="H.NotifyIcon" Version="2.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -83,9 +88,32 @@
|
||||
AfterTargets="Build"
|
||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||
<MakeDir Directories="$(TargetDir)Native" />
|
||||
<MakeDir Directories="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/MacOS" />
|
||||
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(TargetDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsAudioCaptureInfoPlist)" "$(MacOsAudioCaptureSource)" -o "$(TargetDir)$(MacOsAudioCaptureOutputPath)"" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(TargetDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit "$(MacOsDesktopControlsSource)" -o "$(TargetDir)$(MacOsDesktopControlsOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework CoreGraphics -framework EventKit -framework ImageIO -framework UniformTypeIdentifiers -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$(MacOsMeetingIntegrationsInfoPlist)" "$(MacOsMeetingIntegrationsSource)" -o "$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)"" />
|
||||
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 "$(MacOsLauncherSource)" -o "$(TargetDir)$(MacOsLauncherOutputPath)"" />
|
||||
</Target>
|
||||
|
||||
<Target
|
||||
Name="PackageMacOsApplication"
|
||||
AfterTargets="Publish"
|
||||
DependsOnTargets="CopyMeetingAssistantConfigToPublishRoot;CopyMacOsNativeHelpersToPublish"
|
||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||
<RemoveDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)" />
|
||||
<MakeDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/MacOS" />
|
||||
<MakeDir Directories="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Resources/app" />
|
||||
<ItemGroup>
|
||||
<MacOsPublishedRuntimeFiles Include="$(PublishDir)**/*" Exclude="$(PublishDir)$(MacOsApplicationBundlePath)/**/*" />
|
||||
</ItemGroup>
|
||||
<Copy
|
||||
SourceFiles="@(MacOsPublishedRuntimeFiles)"
|
||||
DestinationFiles="@(MacOsPublishedRuntimeFiles->'$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Resources/app/%(RecursiveDir)%(Filename)%(Extension)')" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsLauncherOutputPath)" DestinationFiles="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/MacOS/MeetingAssistant" />
|
||||
<Copy SourceFiles="$(MacOsLauncherInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsApplicationBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - --requirements '=designated => identifier "cloud.schweigert.meeting-assistant"' "$(PublishDir)$(MacOsApplicationBundlePath)"" />
|
||||
</Target>
|
||||
|
||||
<Target
|
||||
@@ -93,7 +121,10 @@
|
||||
AfterTargets="Publish"
|
||||
Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
|
||||
<MakeDir Directories="$(PublishDir)Native" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
<MakeDir Directories="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/MacOS" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureOutputPath)" />
|
||||
<Copy SourceFiles="$(MacOsAudioCaptureInfoPlist)" DestinationFiles="$(PublishDir)$(MacOsAudioCaptureBundlePath)/Contents/Info.plist" />
|
||||
<Exec Command="/usr/bin/codesign --force --deep --sign - "$(PublishDir)$(MacOsAudioCaptureBundlePath)"" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
<Copy SourceFiles="$(TargetDir)$(MacOsMeetingIntegrationsOutputPath)" DestinationFolder="$(PublishDir)Native" />
|
||||
</Target>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>MeetingAssistant</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cloud.schweigert.meeting-assistant</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Meeting Assistant</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Meeting Assistant reads calendar metadata for meeting notes and recording prompts.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,70 @@
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
private struct MeetingAssistantLauncher {
|
||||
private static let serviceDirectoryName = "MeetingAssistant"
|
||||
|
||||
static func main() {
|
||||
let runtimeDirectory = Bundle.main.bundleURL
|
||||
.appendingPathComponent("Contents/Resources/app", isDirectory: true)
|
||||
let assemblyPath = runtimeDirectory.appendingPathComponent("MeetingAssistant.dll").path
|
||||
let environmentFile = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support")
|
||||
.appendingPathComponent(serviceDirectoryName)
|
||||
.appendingPathComponent("config/meeting-assistant.env")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: assemblyPath) else {
|
||||
fail("Meeting Assistant runtime not found at \(assemblyPath)")
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: environmentFile.path) else {
|
||||
fail("Meeting Assistant environment file not found at \(environmentFile.path)")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = [
|
||||
"-c",
|
||||
"set -a; source \"$1\"; set +a; exec /usr/local/share/dotnet/dotnet \"$2\" --contentRoot \"$3\"",
|
||||
"meeting-assistant-launcher",
|
||||
environmentFile.path,
|
||||
assemblyPath,
|
||||
runtimeDirectory.path
|
||||
]
|
||||
process.currentDirectoryURL = runtimeDirectory
|
||||
|
||||
signal(SIGTERM, SIG_IGN)
|
||||
signal(SIGINT, SIG_IGN)
|
||||
|
||||
let terminationSignal = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global())
|
||||
terminationSignal.setEventHandler {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
terminationSignal.resume()
|
||||
|
||||
let interruptSignal = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global())
|
||||
interruptSignal.setEventHandler {
|
||||
if process.isRunning {
|
||||
kill(process.processIdentifier, SIGINT)
|
||||
}
|
||||
}
|
||||
interruptSignal.resume()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
Darwin.exit(process.terminationStatus)
|
||||
} catch {
|
||||
fail("Meeting Assistant launcher failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data("\(message)\n".utf8))
|
||||
Darwin.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,20 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>macos-meeting-audio-capture</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Meeting Assistant Audio Capture</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
||||
</dict>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace MeetingAssistant.Notifications;
|
||||
|
||||
public sealed record AgentApprovalPrompt(string Title, string Message, TimeSpan Timeout);
|
||||
|
||||
public interface IAgentApprovalPromptService
|
||||
{
|
||||
Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopAgentApprovalPromptService : IAgentApprovalPromptService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
public sealed class AgentApprovalService
|
||||
{
|
||||
private readonly IAgentApprovalPromptService prompts;
|
||||
private readonly ILogger<AgentApprovalService> logger;
|
||||
private readonly TimeSpan timeout;
|
||||
|
||||
public AgentApprovalService(IAgentApprovalPromptService prompts, ILogger<AgentApprovalService> logger)
|
||||
: this(prompts, logger, TimeSpan.FromMinutes(10)) { }
|
||||
|
||||
internal AgentApprovalService(IAgentApprovalPromptService prompts, ILogger<AgentApprovalService> logger, TimeSpan timeout)
|
||||
{
|
||||
this.prompts = prompts;
|
||||
this.logger = logger;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(timeout);
|
||||
try
|
||||
{
|
||||
var accepted = await prompts.ConfirmAsync(new AgentApprovalPrompt(title, message, timeout), deadline.Token)
|
||||
.WaitAsync(deadline.Token);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return accepted && !deadline.IsCancellationRequested;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not obtain user approval for {Title}", title);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await deadline.CancelAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#if WINDOWS
|
||||
using System.Collections.Concurrent;
|
||||
using CommunityToolkit.WinUI.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Notifications;
|
||||
|
||||
public sealed class WindowsAgentApprovalPromptService(ILogger<WindowsAgentApprovalPromptService> logger)
|
||||
: IAgentApprovalPromptService, IDisposable
|
||||
{
|
||||
private const string Source = "summary-agent-approval";
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> pending = new();
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
private bool disposed;
|
||||
|
||||
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) return false;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var response = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return false;
|
||||
if (!registered)
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated += OnActivated;
|
||||
registered = true;
|
||||
}
|
||||
pending[id] = response;
|
||||
new ToastContentBuilder()
|
||||
.AddArgument("source", Source).AddArgument("requestId", id)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
.SetToastDuration(ToastDuration.Long)
|
||||
.AddText(prompt.Title)
|
||||
.AddText(prompt.Message)
|
||||
.AddButton(new ToastButton().SetContent("Yes")
|
||||
.AddArgument("source", Source).AddArgument("requestId", id)
|
||||
.AddArgument("response", "yes").SetBackgroundActivation())
|
||||
.AddButton(new ToastButton().SetContent("No")
|
||||
.AddArgument("source", Source).AddArgument("requestId", id)
|
||||
.AddArgument("response", "no").SetBackgroundActivation())
|
||||
.Show(toast =>
|
||||
{
|
||||
toast.Tag = id;
|
||||
toast.Group = Source;
|
||||
toast.ExpirationTime = DateTimeOffset.Now.Add(prompt.Timeout);
|
||||
});
|
||||
}
|
||||
|
||||
return await response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.TryRemove(id, out _);
|
||||
try { ToastNotificationManagerCompat.History.Remove(id, Source); }
|
||||
catch (Exception exception) { logger.LogDebug(exception, "Could not remove approval notification {RequestId}", id); }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActivated(ToastNotificationActivatedEventArgsCompat args)
|
||||
{
|
||||
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
||||
if (arguments.GetValueOrDefault("source") != Source ||
|
||||
!arguments.TryGetValue("requestId", out var id) ||
|
||||
!pending.TryRemove(id, out var response)) return;
|
||||
response.TrySetResult(arguments.GetValueOrDefault("response") == "yes");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
disposed = true;
|
||||
if (registered) ToastNotificationManagerCompat.OnActivated -= OnActivated;
|
||||
registered = false;
|
||||
foreach (var response in pending.Values) response.TrySetResult(false);
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@ using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Logging;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
@@ -30,6 +31,9 @@ builder.Services.AddSingleton<IMicrophoneDeviceProvider>(services =>
|
||||
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
|
||||
builder.Services.AddSingleton<IMicrophoneCaptureSourceFactory>(services =>
|
||||
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
|
||||
builder.Services.AddSingleton<IMicrophoneSwitchPromptService, WindowsMicrophoneSwitchPromptService>();
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, WindowsAgentApprovalPromptService>();
|
||||
builder.Services.AddSingleton<MicrophoneSwitchMonitor>();
|
||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||
builder.Services.AddSingleton<SystemAudioSource>();
|
||||
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
||||
@@ -43,6 +47,8 @@ builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMet
|
||||
PortableMeetingAudioRegistration.Add(builder.Services);
|
||||
MacOsMeetingIntegrationRegistration.Add(builder.Services, OperatingSystem.IsMacOS());
|
||||
builder.Services.AddHostedService<MacOsDesktopControlService>();
|
||||
builder.Services.AddSingleton<IMicrophoneSwitchPromptService, NoopMicrophoneSwitchPromptService>();
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, NoopAgentApprovalPromptService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
||||
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
|
||||
@@ -144,6 +150,9 @@ builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRule
|
||||
builder.Services.AddSingleton<MacOsWorkflowRulesEditorSession>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MacOsWorkflowRulesEditorWindowService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<AgentApprovalService>();
|
||||
builder.Services.AddSingleton<WorkflowChangeRequestService>();
|
||||
builder.Services.AddHostedService(services => services.GetRequiredService<WorkflowChangeRequestService>());
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
|
||||
|
||||
@@ -2,5 +2,7 @@ namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
// The caller owns the returned source and disposes it when it implements IDisposable,
|
||||
// including when capture is canceled before enumeration starts.
|
||||
IMeetingAudioSource CreateCapture(MeetingAssistantOptions options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneSwitchPromptService
|
||||
{
|
||||
Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopMicrophoneSwitchPromptService : IMicrophoneSwitchPromptService
|
||||
{
|
||||
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
@@ -33,6 +33,17 @@ public interface ITranscriptStore
|
||||
string line,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
async Task AppendLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> lines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
await AppendLineAsync(session, line, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
|
||||
@@ -199,7 +199,13 @@ internal sealed class MacOsAudioCaptureProcessFactory : IMacOsAudioCaptureProces
|
||||
throw new PlatformNotSupportedException("The macOS audio capture helper can only run on macOS.");
|
||||
}
|
||||
|
||||
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-meeting-audio-capture");
|
||||
var helperPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Native",
|
||||
"MeetingAssistantAudioCapture.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"macos-meeting-audio-capture");
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
|
||||
@@ -416,6 +416,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (run.ContextState is AssistantContextState.Summarizing or AssistantContextState.Finished or AssistantContextState.Error)
|
||||
{
|
||||
logger.LogInformation("Recording has reached summarization; continuing remaining processing in the background after the stop wait expired");
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
if (IsAzureSpeechRun(run))
|
||||
{
|
||||
logger.LogWarning("Timed out while draining Azure Speech transcription after recording stop; queueing offline transcription backlog item");
|
||||
|
||||
@@ -8,22 +8,30 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
private readonly IMicrophoneCaptureSourceFactory captureSources;
|
||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||
private readonly TimeSpan recoveryDelay;
|
||||
private readonly MicrophoneSwitchMonitor? switchMonitor;
|
||||
private readonly MicrophoneDeviceSelection? selection;
|
||||
|
||||
public MicrophoneAudioSource(
|
||||
IMicrophoneCaptureSourceFactory captureSources,
|
||||
ILogger<MicrophoneAudioSource> logger)
|
||||
: this(captureSources, logger, DefaultRecoveryDelay)
|
||||
ILogger<MicrophoneAudioSource> logger,
|
||||
MicrophoneSwitchMonitor? switchMonitor = null,
|
||||
MicrophoneDeviceSelection? selection = null)
|
||||
: this(captureSources, logger, DefaultRecoveryDelay, switchMonitor, selection)
|
||||
{
|
||||
}
|
||||
|
||||
internal MicrophoneAudioSource(
|
||||
IMicrophoneCaptureSourceFactory captureSources,
|
||||
ILogger<MicrophoneAudioSource> logger,
|
||||
TimeSpan recoveryDelay)
|
||||
TimeSpan recoveryDelay,
|
||||
MicrophoneSwitchMonitor? switchMonitor = null,
|
||||
MicrophoneDeviceSelection? selection = null)
|
||||
{
|
||||
this.captureSources = captureSources;
|
||||
this.logger = logger;
|
||||
this.recoveryDelay = recoveryDelay;
|
||||
this.switchMonitor = switchMonitor;
|
||||
this.selection = selection;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
@@ -34,21 +42,44 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using var session = new CaptureSession(selection, cancellationToken);
|
||||
var monitoring = switchMonitor?.RunAsync(session.SwitchTo, session.Token) ?? Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in CaptureWithRecoveryAsync(options, session, session.Token))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Stop();
|
||||
await monitoring;
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> CaptureWithRecoveryAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CaptureSession session,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var failedAttempts = 0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var captureToken = session.BeginCapture();
|
||||
IMeetingAudioSource? captureSource = null;
|
||||
IAsyncEnumerator<AudioChunk>? capture = null;
|
||||
Exception? failure = null;
|
||||
|
||||
try
|
||||
{
|
||||
capture = captureSources
|
||||
.CreateCapture(options)
|
||||
.CaptureAsync(options, cancellationToken)
|
||||
.GetAsyncEnumerator(cancellationToken);
|
||||
captureSource = captureSources.CreateCapture(options);
|
||||
capture = captureSource
|
||||
.CaptureAsync(options, captureToken)
|
||||
.GetAsyncEnumerator(captureToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -56,6 +87,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
failure = exception;
|
||||
}
|
||||
|
||||
using var captureOwner = captureSource as IDisposable;
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
@@ -65,14 +97,14 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
while (!captureToken.IsCancellationRequested)
|
||||
{
|
||||
var hasNext = false;
|
||||
try
|
||||
{
|
||||
hasNext = await capture.MoveNextAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -80,7 +112,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
failure = exception;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||
if (captureToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -102,7 +134,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -112,11 +144,19 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
}
|
||||
}
|
||||
|
||||
var switchRequested = captureToken.IsCancellationRequested;
|
||||
session.EndCapture();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (switchRequested)
|
||||
{
|
||||
logger.LogInformation("Replacing microphone capture after an accepted device-switch notification");
|
||||
continue;
|
||||
}
|
||||
|
||||
failedAttempts++;
|
||||
logger.LogWarning(
|
||||
failure,
|
||||
@@ -142,4 +182,53 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureSession(MicrophoneDeviceSelection? selection, CancellationToken cancellationToken) : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly CancellationTokenSource lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
private CancellationTokenSource? capture;
|
||||
|
||||
public CancellationToken Token => lifetime.Token;
|
||||
|
||||
public CancellationToken BeginCapture()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
capture = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
return capture.Token;
|
||||
}
|
||||
}
|
||||
|
||||
public void EndCapture()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
capture?.Dispose();
|
||||
capture = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SwitchTo(MicrophoneDevice device, CancellationToken promptCancellation)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (lifetime.IsCancellationRequested || promptCancellation.IsCancellationRequested || selection is null) return;
|
||||
selection.Select(device.Id);
|
||||
capture?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) lifetime.Cancel();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
EndCapture();
|
||||
lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneSwitchMonitor
|
||||
{
|
||||
private readonly IMicrophoneDeviceProvider devices;
|
||||
private readonly IMicrophoneSwitchPromptService prompts;
|
||||
private readonly ILogger<MicrophoneSwitchMonitor> logger;
|
||||
private readonly TimeSpan pollInterval;
|
||||
private readonly TimeSpan promptLifetime;
|
||||
|
||||
public MicrophoneSwitchMonitor(
|
||||
IMicrophoneDeviceProvider devices,
|
||||
IMicrophoneSwitchPromptService prompts,
|
||||
ILogger<MicrophoneSwitchMonitor> logger)
|
||||
: this(devices, prompts, logger, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))
|
||||
{
|
||||
}
|
||||
|
||||
internal MicrophoneSwitchMonitor(
|
||||
IMicrophoneDeviceProvider devices,
|
||||
IMicrophoneSwitchPromptService prompts,
|
||||
ILogger<MicrophoneSwitchMonitor> logger,
|
||||
TimeSpan pollInterval,
|
||||
TimeSpan promptLifetime)
|
||||
{
|
||||
this.devices = devices;
|
||||
this.prompts = prompts;
|
||||
this.logger = logger;
|
||||
this.pollInterval = pollInterval;
|
||||
this.promptLifetime = promptLifetime;
|
||||
}
|
||||
|
||||
public async Task RunAsync(Action<MicrophoneDevice, CancellationToken> switchMicrophone, CancellationToken cancellationToken)
|
||||
{
|
||||
var known = (ReadDevices() ?? []).Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var pending = new Dictionary<string, PendingPrompt>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(pollInterval, cancellationToken);
|
||||
var available = ReadDevices();
|
||||
if (available is null) continue;
|
||||
var availableIds = available.Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, prompt) in pending.ToArray())
|
||||
{
|
||||
if (!availableIds.Contains(id)) await prompt.Cancellation.CancelAsync();
|
||||
if (prompt.Task.IsCompleted)
|
||||
{
|
||||
prompt.Cancellation.Dispose();
|
||||
pending.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var device in available)
|
||||
{
|
||||
if (known.Add(device.Id))
|
||||
{
|
||||
var promptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
promptCancellation.CancelAfter(promptLifetime);
|
||||
pending.Add(device.Id, new PendingPrompt(promptCancellation,
|
||||
OfferAsync(device, switchMicrophone, promptCancellation.Token)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var prompt in pending.Values) await prompt.Cancellation.CancelAsync();
|
||||
await Task.WhenAll(pending.Values.Select(prompt => prompt.Task));
|
||||
foreach (var prompt in pending.Values) prompt.Cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OfferAsync(MicrophoneDevice device, Action<MicrophoneDevice, CancellationToken> switchMicrophone,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await prompts.ConfirmSwitchAsync(device, cancellationToken)
|
||||
&& !cancellationToken.IsCancellationRequested
|
||||
&& ReadDevices()?.Any(current => current.Id.Equals(device.Id, StringComparison.OrdinalIgnoreCase)) == true
|
||||
&& !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
switchMicrophone(device, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not offer microphone {MicrophoneName}", device.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<MicrophoneDevice>? ReadDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
return devices.GetAvailableMicrophones();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not check for newly available microphones");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingPrompt(CancellationTokenSource Cancellation, Task Task);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource, IDisposable
|
||||
{
|
||||
private readonly IWaveIn capture;
|
||||
private readonly string sourceName;
|
||||
private readonly ILogger logger;
|
||||
private int disposed;
|
||||
|
||||
public NaudioCaptureAudioSource(
|
||||
IWaveIn capture,
|
||||
@@ -22,13 +23,15 @@ internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureWith(capture, sourceName, logger, cancellationToken);
|
||||
return CaptureWith(cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
IWaveIn capture,
|
||||
string sourceName,
|
||||
ILogger logger,
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 0) capture.Dispose();
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<AudioChunk>();
|
||||
@@ -83,7 +86,7 @@ internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
}
|
||||
finally
|
||||
{
|
||||
capture.Dispose();
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,25 @@ public sealed class OfflineTranscriptionBacklogProcessor
|
||||
pipelineOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!finishedSegments.Any(segment =>
|
||||
segment.Kind == TranscriptionSegmentKind.Speech && !string.IsNullOrWhiteSpace(segment.Text)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Offline transcription replay returned no speech text; preserving the existing transcript and recovery files.");
|
||||
}
|
||||
|
||||
var lines = await TransformTranscriptLinesAsync(artifacts, runOptions, finishedSegments, cancellationToken);
|
||||
await transcriptStore.ReplaceLinesAsync(session, lines, cancellationToken);
|
||||
await transcriptStore.AppendLinesAsync(session,
|
||||
[
|
||||
"",
|
||||
"## Offline transcription recovery",
|
||||
"",
|
||||
"> The following transcript was recovered by replaying the entire recording. "
|
||||
+ "It may duplicate earlier transcript passages. "
|
||||
+ "Treat repeated passages as the same discussion when summarizing.",
|
||||
"",
|
||||
.. lines
|
||||
], cancellationToken);
|
||||
|
||||
meetingNote.Frontmatter.EndTime = item.InferredEndTime ?? DateTimeOffset.Now;
|
||||
var completedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
||||
|
||||
@@ -49,6 +49,25 @@ public sealed class VaultTranscriptStore : ITranscriptStore
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task AppendLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> lines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return WithFileGateAsync(
|
||||
session.TranscriptPath,
|
||||
() => File.AppendAllTextAsync(
|
||||
session.TranscriptPath,
|
||||
string.Concat(lines.Select(line => line + Environment.NewLine)),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#if WINDOWS
|
||||
using System.Collections.Concurrent;
|
||||
using CommunityToolkit.WinUI.Notifications;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class WindowsMicrophoneSwitchPromptService : IMicrophoneSwitchPromptService, IDisposable
|
||||
{
|
||||
private const string Source = "meeting-microphone-switch";
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> pending = new();
|
||||
private readonly ILogger<WindowsMicrophoneSwitchPromptService> logger;
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
|
||||
public WindowsMicrophoneSwitchPromptService(ILogger<WindowsMicrophoneSwitchPromptService> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) return false;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var response = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!registered)
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated += OnActivated;
|
||||
registered = true;
|
||||
}
|
||||
|
||||
pending[id] = response;
|
||||
new ToastContentBuilder()
|
||||
.AddArgument("source", Source)
|
||||
.AddArgument("promptId", id)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
.SetToastDuration(ToastDuration.Long)
|
||||
.AddText("New microphone available")
|
||||
.AddText($"Switch this meeting to {microphone.Name}?")
|
||||
.AddButton(new ToastButton().SetContent("Yes")
|
||||
.AddArgument("source", Source).AddArgument("promptId", id)
|
||||
.AddArgument("response", "yes").SetBackgroundActivation())
|
||||
.AddButton(new ToastButton().SetContent("No")
|
||||
.AddArgument("source", Source).AddArgument("promptId", id)
|
||||
.AddArgument("response", "no").SetBackgroundActivation())
|
||||
.Show(toast =>
|
||||
{
|
||||
toast.Tag = id;
|
||||
toast.Group = Source;
|
||||
toast.ExpirationTime = DateTimeOffset.Now.AddMinutes(1);
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("Offered newly available microphone {MicrophoneName}", microphone.Name);
|
||||
return await response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.TryRemove(id, out _);
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.History.Remove(id, Source);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not remove microphone notification {PromptId}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActivated(ToastNotificationActivatedEventArgsCompat args)
|
||||
{
|
||||
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
||||
if (arguments.GetValueOrDefault("source") != Source ||
|
||||
!arguments.TryGetValue("promptId", out var id) ||
|
||||
!pending.TryRemove(id, out var response)) return;
|
||||
|
||||
response.TrySetResult(arguments.GetValueOrDefault("response") == "yes");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (registered) ToastNotificationManagerCompat.OnActivated -= OnActivated;
|
||||
registered = false;
|
||||
foreach (var response in pending.Values) response.TrySetResult(false);
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -18,6 +18,12 @@ public sealed class BoundMeetingProjectResolver
|
||||
|
||||
public string ProjectsRoot => VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
|
||||
public List<BoundMeetingProject> GetAllProjects() => !Directory.Exists(ProjectsRoot) ? [] :
|
||||
Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public async Task<List<BoundMeetingProject>> GetBoundProjectsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -33,10 +39,8 @@ public sealed class BoundMeetingProjectResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
return GetAllProjects()
|
||||
.Where(project => projectNames.Contains(project.Name))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,20 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
|
||||
Use delete_identity only when you are certain that an existing speaker identity was wrongfully matched. Provide the exact identity name currently used in the transcript.
|
||||
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
|
||||
Use list_projects first to see which projects are bound to this meeting. Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope.
|
||||
Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope.
|
||||
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
|
||||
If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. When embedding them, encode spaces in image-link targets as `%20`; never leave literal spaces in the link target. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important.
|
||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||
""";
|
||||
|
||||
private const string ProjectAndRequestInstructions = """
|
||||
Project discovery and approval:
|
||||
list_projects returns every configured project's id, displayname, and description; only projects in the current meeting note's projects frontmatter are accessible for reads, writes, search, and historical summaries. Catalog visibility does not grant file access.
|
||||
Before finalizing the summary, use the catalog when meeting evidence suggests a missing project association. Call request_project_association with its exact id and a short evidence-based reason (one line, at most 100 characters). It waits for user approval for up to ten minutes. A denial or timeout grants no access; continue with the existing scope. Approval adds the project to the meeting and returns its current project instructions; follow them immediately.
|
||||
For each associated project, maintain a root PROJECT.md with YAML frontmatter name (display name) and description (at most 256 characters). Describe the project's purpose and distinguishing context so future summarizers can select it. Read existing content first and use write_projectfile to create missing metadata or update it from grounded knowledge, preserving other fields and body notes. Do not invent facts or write unassociated projects.
|
||||
If a workflow improvement would help, use request_workflow_change with a short one-line intention (at most 100 characters) and a detailed self-contained change prompt, preferably a scenario/specification with expected behavior. This returns requested immediately and does not mean approved or implemented. Continue summarizing; only user approval starts the separate settings agent. You cannot change workflows directly.
|
||||
""";
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
|
||||
@@ -50,6 +58,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
||||
? DefaultInitialPrompt
|
||||
: options.Agent.InitialPrompt.Trim();
|
||||
instructions = instructions.TrimEnd() + "\n\n" + ProjectAndRequestInstructions;
|
||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, options, cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(projectInstructions)
|
||||
? instructions
|
||||
@@ -60,44 +69,15 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, options, cancellationToken);
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var blocks = projects.Select(project =>
|
||||
$"# {project.Name}\n\n{project.Instructions.Trim()}");
|
||||
return "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolver = ReferenceEquals(options, this.options)
|
||||
? projectResolver
|
||||
: new BoundMeetingProjectResolver(options);
|
||||
var projects = new List<ProjectInstructions>();
|
||||
var blocks = new List<string>();
|
||||
foreach (var project in await resolver.GetBoundProjectsAsync(artifacts, cancellationToken))
|
||||
{
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (!File.Exists(agentsPath))
|
||||
{
|
||||
continue;
|
||||
blocks.Add(await ProjectMetadata.BuildContextAsync(project, cancellationToken));
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
projects.Add(new ProjectInstructions(project.Name, content));
|
||||
return blocks.Count == 0 ? "" : "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
private sealed record ProjectInstructions(string Name, string Instructions);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using System.Text.Json;
|
||||
using MeetingAssistant.Notifications;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
@@ -16,6 +19,8 @@ public sealed class MeetingSummaryTools
|
||||
private readonly SummaryAgentWriteAudit? writeAudit;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
private readonly AgentApprovalService? approvals;
|
||||
private readonly WorkflowChangeRequestService? workflowRequests;
|
||||
|
||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
||||
: this(artifacts, new MeetingAssistantOptions(), null)
|
||||
@@ -27,7 +32,9 @@ public sealed class MeetingSummaryTools
|
||||
MeetingAssistantOptions options,
|
||||
IDictationWordStore? dictationWordStore = null,
|
||||
SummaryAgentWriteAudit? writeAudit = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
|
||||
AgentApprovalService? approvals = null,
|
||||
WorkflowChangeRequestService? workflowRequests = null)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
this.options = options;
|
||||
@@ -35,6 +42,8 @@ public sealed class MeetingSummaryTools
|
||||
this.writeAudit = writeAudit;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
|
||||
projectResolver = new BoundMeetingProjectResolver(options);
|
||||
this.approvals = approvals;
|
||||
this.workflowRequests = workflowRequests;
|
||||
}
|
||||
|
||||
public Task<string> ReadTranscript(int? @from = null, int? to = null, int? tail = null)
|
||||
@@ -291,8 +300,71 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
public async Task<string> ListProjects()
|
||||
{
|
||||
var projects = await GetBoundProjectsAsync();
|
||||
return string.Join('\n', projects.Select(project => project.Name));
|
||||
var projects = await Task.WhenAll(projectResolver.GetAllProjects().Select(project => ProjectMetadata.ReadAsync(project)));
|
||||
return JsonSerializer.Serialize(projects.Select(project => new
|
||||
{
|
||||
id = project.Id, displayname = project.DisplayName, description = project.Description
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task<string> RequestProjectAssociation(string project_id, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason) || reason.Length > 100 || reason.Contains('\n') || reason.Contains('\r'))
|
||||
return "Refused: reason must be a nonempty single line of at most 100 characters.";
|
||||
var project = projectResolver.GetAllProjects()
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, project_id, StringComparison.OrdinalIgnoreCase));
|
||||
if (project is null) return "Refused: unknown project ID.";
|
||||
var associated = await projectResolver.ReadMeetingProjectNamesAsync(artifacts, cancellationToken);
|
||||
if (!associated.Contains(project.Name))
|
||||
{
|
||||
var metadata = await ProjectMetadata.ReadAsync(project, cancellationToken);
|
||||
if (approvals is null || !await approvals.ConfirmAsync("Project association requested",
|
||||
$"Associate {metadata.DisplayName} ({metadata.Id}) with {Path.GetFileName(artifacts.MeetingNotePath)}?\n{reason}",
|
||||
cancellationToken))
|
||||
return "Denied: project association was not approved.";
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!Directory.Exists(project.Path) || !File.Exists(artifacts.MeetingNotePath))
|
||||
return "Refused: the project or meeting note no longer exists.";
|
||||
await AddProjectAssociationAsync(project.Name, cancellationToken);
|
||||
}
|
||||
|
||||
return "Approved: project is associated with this meeting.\n\n"
|
||||
+ await ProjectMetadata.BuildContextAsync(project, cancellationToken);
|
||||
}
|
||||
|
||||
public string RequestWorkflowChange(string intention, string detailed_prompt)
|
||||
=> workflowRequests?.Request(intention, detailed_prompt, artifacts.MeetingNotePath)
|
||||
?? "Refused: workflow change requests are unavailable.";
|
||||
|
||||
private async Task AddProjectAssociationAsync(string projectId, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitRequired(content,
|
||||
"Meeting note requires frontmatter.", "Meeting note frontmatter is not closed.");
|
||||
var yaml = new YamlStream();
|
||||
yaml.Load(new StringReader(document.Frontmatter));
|
||||
var mapping = (YamlMappingNode)yaml.Documents.Single().RootNode;
|
||||
mapping.Children.TryGetValue(new YamlScalarNode("projects"), out var existing);
|
||||
var projects = existing as YamlSequenceNode ?? new YamlSequenceNode();
|
||||
if (existing is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value))
|
||||
projects.Add(new YamlScalarNode(scalar.Value));
|
||||
if (!projects.Children.OfType<YamlScalarNode>().Any(value => string.Equals(value.Value, projectId, StringComparison.OrdinalIgnoreCase)))
|
||||
projects.Add(new YamlScalarNode(projectId));
|
||||
mapping.Children[new YamlScalarNode("projects")] = projects;
|
||||
using var writer = new StringWriter();
|
||||
yaml.Save(writer, assignAnchors: false);
|
||||
var pendingPath = artifacts.MeetingNotePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(pendingPath, "---\n" + writer + "---\n\n" + document.Body, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Replace(pendingPath, artifacts.MeetingNotePath, destinationBackupFileName: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pendingPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ListProjectFiles(string project)
|
||||
@@ -375,10 +447,18 @@ public sealed class MeetingSummaryTools
|
||||
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
|
||||
}
|
||||
|
||||
var target = ResolveExistingProjectFilePath(project, path);
|
||||
var target = await ResolveBoundProjectFileTargetAsync(project, path);
|
||||
if (target is null)
|
||||
{
|
||||
return "Refused: project does not exist or the path escapes the project folder.";
|
||||
return "Refused: project is not assigned to this meeting, does not exist, or the path escapes the project folder.";
|
||||
}
|
||||
|
||||
var existingContent = File.Exists(target.Path) ? await File.ReadAllTextAsync(target.Path) : "";
|
||||
var updatedContent = AgentFileToolContent.ApplyLineEdit(existingContent, content, editMode);
|
||||
if (string.Equals(Path.GetRelativePath(target.Project.Path, target.Path), "PROJECT.md", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ProjectMetadata.IsValidContent(updatedContent))
|
||||
{
|
||||
return "Refused: PROJECT.md requires YAML frontmatter name and description; description must be at most 256 characters.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
||||
@@ -386,11 +466,11 @@ public sealed class MeetingSummaryTools
|
||||
{
|
||||
await writeAudit.CaptureFileWriteAsync(
|
||||
target.Path,
|
||||
() => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode));
|
||||
() => File.WriteAllTextAsync(target.Path, updatedContent));
|
||||
}
|
||||
else
|
||||
{
|
||||
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
|
||||
await File.WriteAllTextAsync(target.Path, updatedContent);
|
||||
}
|
||||
|
||||
return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}";
|
||||
@@ -589,7 +669,7 @@ public sealed class MeetingSummaryTools
|
||||
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
|
||||
}
|
||||
|
||||
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
|
||||
private async Task<ProjectFileTarget?> ResolveBoundProjectFileTargetAsync(string project, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project) ||
|
||||
string.IsNullOrWhiteSpace(path) ||
|
||||
@@ -598,15 +678,7 @@ public sealed class MeetingSummaryTools
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectsRoot = GetProjectsRoot();
|
||||
if (!Directory.Exists(projectsRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
|
||||
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
||||
var projectFolder = await ResolveBoundProjectAsync(project);
|
||||
if (projectFolder is null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -19,6 +20,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
|
||||
private readonly IDictationWordStore dictationWordStore;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly AgentApprovalService approvals;
|
||||
private readonly WorkflowChangeRequestService workflowRequests;
|
||||
|
||||
public OpenAiMeetingSummaryAgentPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
@@ -28,7 +31,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
IMeetingSummaryFailureWriter failureWriter,
|
||||
IMeetingSummaryInstructionBuilder instructionBuilder,
|
||||
IDictationWordStore dictationWordStore,
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine)
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine,
|
||||
AgentApprovalService approvals,
|
||||
WorkflowChangeRequestService workflowRequests)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
@@ -38,6 +43,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine;
|
||||
this.approvals = approvals;
|
||||
this.workflowRequests = workflowRequests;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
@@ -60,7 +67,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
options,
|
||||
dictationWordStore,
|
||||
writeAudit,
|
||||
meetingWorkflowEngine);
|
||||
meetingWorkflowEngine,
|
||||
approvals,
|
||||
workflowRequests);
|
||||
var tools = CreateTools(meetingTools);
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||
@@ -80,7 +89,11 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(
|
||||
loggerFactory,
|
||||
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
|
||||
client =>
|
||||
{
|
||||
client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync;
|
||||
client.AllowConcurrentInvocation = false;
|
||||
})
|
||||
.Build()
|
||||
.AsAIAgent(
|
||||
instructions,
|
||||
@@ -176,7 +189,15 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjects,
|
||||
"list_projects",
|
||||
"List the configured project folders bound to the current meeting note frontmatter."),
|
||||
"List all configured projects with id, displayname and description. Catalog visibility does not grant access; project files require current meeting association."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.RequestProjectAssociation,
|
||||
"request_project_association",
|
||||
"Request user approval to associate a project_id with this meeting. reason must be one line and at most 100 characters. Waits up to ten minutes; approval adds the project and returns its AGENTS.md if present."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.RequestWorkflowChange,
|
||||
"request_workflow_change",
|
||||
"Request a workflow change with a one-line intention (maximum 100 characters) and a detailed_prompt describing the desired scenario/specification. Returns requested immediately; user approval separately opens and starts the settings agent."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjectFiles,
|
||||
"list_projectfiles",
|
||||
@@ -188,7 +209,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteProjectFile,
|
||||
"write_projectfile",
|
||||
"Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
|
||||
"Write a file inside a project associated with the current meeting. Root PROJECT.md requires YAML name and description (maximum 256 characters). With no line arguments, append or create. Set replace_file=true only for intentional whole-file replacement. With from and to, replace that inclusive 1-based line range; with insert, insert at that line."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListPastProjectMeetings,
|
||||
"list_past_project_meetings",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed record ProjectMetadata(string Id, string DisplayName, string Description)
|
||||
{
|
||||
public static async Task<string> BuildContextAsync(BoundMeetingProject project, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = await ReadAsync(project, cancellationToken);
|
||||
var context = $"# {metadata.Id}\nDisplay name: {metadata.DisplayName}\nDescription: {metadata.Description}";
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (File.Exists(agentsPath))
|
||||
{
|
||||
var instructions = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(instructions)) context += "\n\n" + instructions.Trim();
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
public static bool IsValidContent(string content) => Parse(content) is not null;
|
||||
|
||||
public static async Task<ProjectMetadata> ReadAsync(BoundMeetingProject project, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = Path.Combine(project.Path, "PROJECT.md");
|
||||
var metadata = File.Exists(path) ? Parse(await File.ReadAllTextAsync(path, cancellationToken)) : null;
|
||||
return new(project.Name,
|
||||
string.IsNullOrWhiteSpace(metadata?.Name) ? project.Name : metadata.Name.Trim(),
|
||||
metadata?.Description?.Trim() ?? "");
|
||||
}
|
||||
|
||||
private static Frontmatter? Parse(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter) return null;
|
||||
var metadata = FrontmatterYamlDeserializer.Create().Deserialize<Frontmatter>(document.Frontmatter);
|
||||
return string.IsNullOrWhiteSpace(metadata?.Name) || metadata.Description is null || metadata.Description.Length > 256
|
||||
? null
|
||||
: metadata;
|
||||
}
|
||||
catch (YamlException) { return null; }
|
||||
catch (InvalidDataException) { return null; }
|
||||
}
|
||||
|
||||
private sealed class Frontmatter
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
[YamlMember(Alias = "description")]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -32,14 +32,13 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cancellation = startupCancellation;
|
||||
var cancellation = Interlocked.Exchange(ref startupCancellation, null);
|
||||
var task = startupTask;
|
||||
if (cancellation is null || task is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startupCancellation = null;
|
||||
startupTask = null;
|
||||
await cancellation.CancelAsync();
|
||||
try
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace MeetingAssistant.Workflow;
|
||||
public interface IWorkflowRulesEditorWindowService
|
||||
{
|
||||
void Show();
|
||||
void ShowWithPrompt(string initialPrompt);
|
||||
}
|
||||
|
||||
public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
@@ -18,4 +19,6 @@ public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorW
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is only available on Windows");
|
||||
}
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt) => Show();
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public async Task SendAsync(CancellationToken cancellationToken = default)
|
||||
public Task SendAsync(CancellationToken cancellationToken = default) => SendPromptAsync(Draft, cancellationToken);
|
||||
|
||||
public async Task SendPromptAsync(string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prompt = Draft.Trim();
|
||||
prompt = prompt.Trim();
|
||||
if (string.IsNullOrWhiteSpace(prompt) || IsThinking)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -37,6 +37,26 @@ internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditor
|
||||
dispatcher?.BeginInvoke(ShowOnUiThread);
|
||||
}
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt)
|
||||
{
|
||||
EnsureUiThread();
|
||||
dispatcher?.BeginInvoke(new Action(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
|
||||
var requestedWindow = new WorkflowRulesEditorWpfWindow(viewModel, linkResolver, () => { });
|
||||
requestedWindow.Show();
|
||||
requestedWindow.Activate();
|
||||
await viewModel.SendPromptAsync(initialPrompt);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not start settings agent for an approved workflow request");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void EnsureUiThread()
|
||||
{
|
||||
lock (sync)
|
||||
|
||||
@@ -86,12 +86,16 @@ Pausing transcription keeps the active recognition pipeline and meeting session
|
||||
|
||||
During an active run, microphone creation failures and disconnects are retried every second with fresh endpoint selection. The meeting and system-loopback capture stay active, with microphone silence mixed in until capture resumes. This recovery does not cover a failed system-loopback source.
|
||||
|
||||
A newly available microphone during a meeting triggers a Windows notification with its name and Yes/No actions, including while transcription is paused. Yes switches the current microphone within the same meeting; No or no response keeps capture unchanged. Each device is offered once per meeting, and unanswered prompts expire after one minute.
|
||||
|
||||
Outlook enrichment selects an unambiguous current or imminent appointment. A scheduled prompt shown during an active recording can apply that exact appointment's title, eligible attendees, agenda, and scheduled end without interrupting capture; explicit prompt metadata wins over a slower background lookup.
|
||||
|
||||
## Data And Side Effects
|
||||
|
||||
Meeting Assistant writes meeting notes, transcripts, assistant context, summaries, and project knowledge into the configured Obsidian vault. Assistant context is persistent meeting-specific memory: the summarizer records problems and assumptions, and the interactive agent can read it and append later repairs and conclusions.
|
||||
|
||||
Project discovery uses the `name` and short `description` in each project's `PROJECT.md`. The summarizer can request missing associations through a Yes/No notification that waits up to ten minutes; only approval adds the project and enables its file access. Workflow-change requests return immediately and, when approved, open a separate settings-agent conversation with the proposed change.
|
||||
|
||||
Agents are intentionally stateful. Depending on the invoked tools, they can change workflow rules and appsettings, create or update project and meeting files, change frontmatter, merge or delete speaker identities and samples, run diagnostics, and trigger transcription or summary work. Screenshot OCR can add recognized attendee names to the meeting note after workflow transformation; OCR of images already embedded in the meeting note does not add attendees or modify that note.
|
||||
|
||||
Local runtime state outside the vault includes:
|
||||
@@ -157,13 +161,21 @@ The Windows tray and macOS menu-bar menus expose `Open agent`, which opens the `
|
||||
|
||||
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, global hotkeys, EventKit calendar enrichment/prompts, active-window screenshots, screenshot OCR, speaker identification, FunASR, local diarization, and the AppKit/WebKit workflow editor are available in the portable build. Outlook Classic COM and Windows toast notifications remain Windows-specific implementations.
|
||||
|
||||
The macOS publish output includes a signed `MeetingAssistant.app` bundle. Install that bundle in `/Applications` and launch the background service through its native executable so macOS microphone, calendar, and Screen/System Audio privacy grants are attributed to the stable Meeting Assistant application identity.
|
||||
|
||||
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
|
||||
|
||||
## Development And CI
|
||||
|
||||
Behavior changes are OpenSpec-driven and test-first: update the relevant requirement/scenario, add a failing public behavior test, implement the smallest passing change, run focused tests and then the justified broader suite, and validate the active change with `openspec validate <change-id> --strict`. Documentation-only maintenance does not need a new OpenSpec change.
|
||||
|
||||
The Gitea workflow runs for pull requests, pushes, and manual dispatch. It builds the Windows target on an Ubuntu runner, installs Wine plus a matching Windows .NET SDK, and runs the test project through the Windows host under Wine. It validates source; it does not publish or deploy the workstation application.
|
||||
The Gitea workflow runs for pull requests, pushes, and manual dispatch on the existing `ubuntu-latest` runners. One job explicitly builds the Windows desktop target, installs Wine plus a matching Windows .NET SDK, and runs the portable test project through the Windows host under Wine. Another job builds and tests `net10.0` on Ubuntu, including the managed macOS audio, calendar, screenshot, registration, and desktop-control behavior tests. Its `TZ=Europe/Berlin` setting also exercises the calendar daylight-saving regression. No additional runner labels or host devices are required.
|
||||
|
||||
Ubuntu does not compile the Swift helpers or execute Apple frameworks. Tests requiring the native macOS environment report an explicit skip through `MacOsFact`; they must also be run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`. That build compiles and signs the helpers, and the suite checks packaging, helper self-tests, and native image cropping. Real microphone/system-audio capture and privacy permissions still require the operational checks described above.
|
||||
|
||||
[Docker-OSX](https://github.com/sickcodes/Docker-OSX) runs a macOS VM rather than providing a Wine-style compatibility layer. Its launcher supports software emulation with `KVM=accel=tcg`, so KVM is not an absolute requirement. A supported .NET 10 guest needs macOS 14 or later plus the Swift build tools. The documented `auto` build downloads a preinstalled guest disk through `IMAGE_URL`; its documented ready-made tags and disk downloads were unavailable when checked on 2026-10-03. No verified native guest bootstrap is owned by this repository. CI validates source; it does not publish or deploy the workstation application.
|
||||
|
||||
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) probes macOS startup and disk readiness through an unprivileged TCG guest on the existing Ubuntu Docker runner. It neither installs macOS nor runs application tests; its result is a prerequisite for a future native test job, not verification of macOS CI support.
|
||||
|
||||
## Operations And Limitations
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Native macOS guest on existing Docker infrastructure — 2026-10-03
|
||||
|
||||
Result: real macOS Recovery booted under software emulation, but the bounded experiment did not reach an installed guest or execute the native tests. This is not a passing CI result or a verified workflow recipe.
|
||||
|
||||
## Source and execution boundary
|
||||
|
||||
- Dockur source: [`16a5b470cdd601bae8b05b02d748d7edfb36c12e`](https://github.com/dockur/macos/tree/16a5b470cdd601bae8b05b02d748d7edfb36c12e).
|
||||
- Local environment: existing ARM64 Docker Desktop, linux/amd64 container translation, x86_64 QEMU guest with TCG. This does not establish the runtime or resources of the upstream Ubuntu-x64 runner.
|
||||
- Guest settings: `VERSION=14`, `MANUAL=N`, `KVM=N`, `NETWORK=slirp`, 4 GiB guest RAM, two vCPUs, 64 GiB sparse guest disk. Container memory was limited to 6 GiB.
|
||||
- Container inspection: `Privileged=false`, `CapAdd=null`, `Devices=[]`, ordinary bridge network. Diagnostic ports were published only on localhost. No runner registration, host configuration, device passthrough or new secret was requested.
|
||||
- Candidate archive: clean application/test source at `1164c26846686c1912fd1816cd06de80352e9504`, SHA-256 `30796a27c75792ab87d23dd4c4db991b426d9c55a0e7dc3d4dc45c41056846e1`. It was not executed in the guest.
|
||||
|
||||
## Observed boundary failure
|
||||
|
||||
The container built and downloaded Apple's Recovery image. Recovery mounted its installation-state share, found Sonoma's `startosinstall`, and passed the upstream unattended preflight. Native `sw_vers` and `uname` output identified:
|
||||
|
||||
```text
|
||||
ProductVersion: 14.6.1
|
||||
BuildVersion: 23G93
|
||||
Darwin Kernel Version 23.6.0 ... RELEASE_X86_64 x86_64
|
||||
```
|
||||
|
||||
The first target-selection attempt found no writable disk. Both virtio and SATA trials subsequently exposed `diskutil` failure: it could not use the DiskManagement framework. Its diagnostic listed unavailable DiskArbitration/single-user mode as a possible cause; that cause was not independently established. SATA also exposed an independent missing `io2` QEMU object, corrected through an ordinary QEMU argument without host capabilities.
|
||||
|
||||
The pinned Recovery patch replaces an early `rc.cdrom.sh` block, before normal daemon startup, with a blocking `exec launch.sh`. One final causal trial changed only this padded bootstrap to launch the installer in the background. Its old/new bootstrap SHA-256 values were `73fd171ea4c889f9946f04d6866047fedd417a69a241b3e84db469c13975d897` and `c5fc86f95e9c65dd7dc59536911c81c96928d4f9541b63121c22cad5690f3582`. The launch script also waited for actual `diskutil list physical` success before the unchanged writable-disk/size guards and captured diagnostics after mounting the share.
|
||||
|
||||
That trial still recorded 12 completed native `diskutil` failures and an incomplete thirteenth attempt:
|
||||
|
||||
```text
|
||||
Unable to run because unable to use the DiskManagement framework.
|
||||
Common reasons include, but are not limited to, the DiskArbitration
|
||||
framework being unavailable due to being booted in single-user mode.
|
||||
[exit 1]
|
||||
```
|
||||
|
||||
There was no successful disk enumeration, selected installation target, target erase or `startosinstall` invocation. The background change did not establish a working bootstrap; the remaining daemon/framework cause is unresolved. This result does not prove that macOS under TCG on Ubuntu is impossible.
|
||||
|
||||
## End state and evidence
|
||||
|
||||
Final container: `c4d5b83f5199063df75d43236d610ef9b71c3b39c14b80a649b0e904d13e87c8`. It started at `10:46:24.987543461 UTC`; the authorized final boot boundary was start plus 16 minutes. It was stopped at `11:02:33.810090465 UTC`: `Running=false`, `ExitCode=143`, `OOMKilled=false`. No experiment container remained running.
|
||||
|
||||
Raw evidence is retained locally under `/private/tmp/meeting-assistant-macos-guest-proof-20261003/runs/c4d5b83f5199/`: `guest-disk-platform.log`, `physical-disks-ready.log`, `recovery-install.log`, their capture metadata, and `end-container-inspect.json`/`end-state.json`. Metadata identifies the full container ID, actual start time, capture time and log SHA-256; earlier flat logs are historical and were not treated as success in this run.
|
||||
|
||||
No guest SSH session, official .NET/Apple CLT bootstrap, Swift build or TRX result was obtained. The application's workflow, production runtime and tests were not changed by this experiment. Native Ubuntu CI remains open; local Mac qualification is recorded separately in [the CI evidence](macos-ubuntu-ci-completion.md).
|
||||
@@ -0,0 +1,55 @@
|
||||
# macOS PR validation on existing Ubuntu infrastructure — 2026-10-03
|
||||
|
||||
## Scope and identity
|
||||
|
||||
Source repository: `/Users/dh/Documents/DanielsVault/_ops/meeting-assistant`.
|
||||
Delivery branch: `codex/macos-support` in `Daniel/meeting-assistant`; intended PR target: `Manuel/meeting-assistant:main`, PR #39.
|
||||
Fixed change baseline: `bd35ebc4c81bedf80caabb82481169124775fb36` (already contains main `7b2bcd36310ae6434a4ee6eeb3a47b6d1d431df4`).
|
||||
Implementation and qualification were isolated under `/private/tmp/meeting-assistant-macos-ci-20261003`; the original checkout was clean. Updating that original checkout after publication unexpectedly invoked its existing post-merge deployment hook and restarted the workstation service. The immediately checked recording endpoint reported idle (`state=0`, `isRecording=false`) and health reported OK. Future checkout updates disable Git hooks explicitly. This qualification did not intentionally start a new recording.
|
||||
Review covers this CI/test/shutdown delta. Earlier macOS feature and main-sync evidence remains in the respective OpenSpec implementation receipts; this record is not a fresh review of all earlier branch changes.
|
||||
|
||||
The hashes below identify the substantive implementation published in `1164c26846686c1912fd1816cd06de80352e9504`. The evidence record and generated logs/TRX files are excluded to avoid self-referential hashes. Subsequent factual documentation corrections do not change the reviewed workflow, runtime or tests.
|
||||
|
||||
| Reviewed file | SHA-256 |
|
||||
| --- | --- |
|
||||
| `.gitea/workflows/pr-push-build-and-test.yaml` | `a6251c435c55967ca2babd58c136ba739ed7c18674e93fff96b477f7b7e1366e` |
|
||||
| `MeetingAssistant.Tests/MacOsDesktopControlManifestTests.cs` | `1df35cf6071f3cdae7d06af8566d59bd2af6c8c5c8c6ca9e04c24197a391975e` |
|
||||
| `MeetingAssistant.Tests/MacOsFactAttribute.cs` | `c756088c1efca9dfe7509a8597845d94e2076445a9f7a2ecec08cd41d333ba7e` |
|
||||
| `MeetingAssistant.Tests/MacOsMeetingAudioSourceTests.cs` | `11c3e36bb31a8aa3b7542ff44367bb0e71ad68360e494fa219b77c0ced3e2456` |
|
||||
| `MeetingAssistant.Tests/MacOsMeetingIntegrationTests.cs` | `51337c27d6126e8e37f4e3b268307830678b8365de51b0c9ce49557ac40160f4` |
|
||||
| `MeetingAssistant.Tests/PyannoteDiarizationWarmupHostedServiceTests.cs` | `0d3494860ce044a2e73e7edba11017b910ca5c0207e2a79da8ff08bde2ff0dfe` |
|
||||
| `MeetingAssistant/Transcription/PyannoteDiarizationWarmupHostedService.cs` | `bcabf6007c9c942845336c1dd958852a8d6dcc383225b4bb1ea97903ef2848e8` |
|
||||
| `README.md` | `79e834ae2953ba690defd4ab10a24e08ae2ce2186ab689afc0ab607e579498e9` |
|
||||
| `openspec/specs/meeting-transcription/spec.md` | `c77edffc0b6ea374e250df758a9be76cbc3ee1146422fc374901c02f216676f6` |
|
||||
|
||||
## Requirements and observed evidence
|
||||
|
||||
- Existing Ubuntu infrastructure: both workflow jobs select only `ubuntu-latest`. The native `macos:host` runner requirement is removed. No KVM device, privileged container, new secret, guest image, or runner registration is required by these jobs. YAML parsing, each job's shell syntax (`bash -n`), and `git diff --check` passed.
|
||||
- Executable macOS managed coverage: the complete portable job's restore/build/test shell steps ran in an Ubuntu 24.04 amd64 `.NET 10` container with `TZ=Europe/Berlin`: **572 passed, 5 explicitly skipped, 0 failed, 577 total**. The skipped tests require a macOS environment and are not counted as passed. Three formerly guarded source/registration/approval tests now actually execute on Ubuntu. A separate `linux-x64` RID build passed the source identity test, proving lookup through the extra output-directory level. Raw Linux log: `/private/tmp/meeting-assistant-portable-final.log`; TRX: `/private/tmp/meeting-assistant-macos-ci-20261003/artifacts/tests/portable.trx`.
|
||||
- Native macOS coverage: the same test/runtime source contents built and signed the Swift helpers on this Mac and passed **577/577**, no skips. Raw receipt: `/private/tmp/meeting-assistant-native-ci-20261003/artifacts/tests/macos-warmup-final-green.trx`. This is local qualification; Ubuntu does not compile or execute the Swift helpers. Earlier real capture → Azure transcription → summary evidence is recorded in `openspec/changes/add-macos-desktop-controls/main-sync-completion.md`; no new live recording was started here.
|
||||
- Accurate Windows verification: `win-x64` RID alone does not select the application's Windows desktop TFM. CI now explicitly builds `net10.0-windows10.0.19041.0` through the existing Wine/Windows .NET SDK, then runs the portable tests under that Windows host. A fresh Windows DLL and fresh TRX are mandatory; a zero exit code without executed work is insufficient. Full Wine validation on the current candidate remains pending on the actual Ubuntu-x64 runner. The local Apple-Silicon Docker attempt hit `rosetta error: invalid gdt selector index 5`; it is not a passing Windows result. Raw attempt: `/private/tmp/meeting-assistant-wine-ci.log`.
|
||||
- Safe warmup shutdown: two real macOS full-suite runs exposed `ObjectDisposedException` from `PyannoteDiarizationWarmupHostedService.StopAsync` at `CancelAsync`, in different endpoint fixtures. A public concurrent-stop reproducer with temporarily increased scheduling overlap produced the exact linked-source exception. Under identical instrumentation, changing only cancellation-source ownership to `Interlocked.Exchange` passed; after instrumentation removal all four lifecycle tests and both final suites passed. Raw red/green receipts: `warmup-overlap-linked-red.trx`, `warmup-overlap-instrumented-green.trx`, and `warmup-overlap-final-green.trx` under the native checkout's `artifacts/tests`. The permanent test coordinates real stop callers with bounded waits; a race test is scheduling-sensitive on the original code, so the instrumented counterexample and original full-suite failure remain part of the causal evidence. The existing single-stop test still proves pending model work observes cancellation.
|
||||
- Test isolation: the macOS audio endpoint fixture supplies its own speech pipeline and no longer starts the unrelated external pyannote model warmup. Its actual mixing, recording-stop, and native-process-termination assertions remain. This fixture isolation is not presented as the production race fix.
|
||||
- OpenSpec: CI/build/test plumbing needs no new change under `AGENTS.md`. The runtime shutdown bug is specified as an addendum to the original accepted pyannote warmup requirement in `openspec/specs/meeting-transcription/spec.md`; strict validation passed (`openspec validate meeting-transcription --type spec --strict`). No active change was archived.
|
||||
|
||||
## Standards coverage and sequential review
|
||||
|
||||
Sources: repository `AGENTS.md`, applicable specs, and `code-review` criteria.
|
||||
DRY owns duplicated platform/test/CI knowledge; SOLID owns lifecycle resource ownership, dependency boundaries and test isolation; KISS owns bounded concurrency test clarity, command naming, accurate documentation, and remaining manual standards. Tests, source builds, OpenSpec validation, YAML/shell parsing and whitespace checks provide automated coverage.
|
||||
|
||||
The passes ran sequentially in fresh, independent reviewer contexts against the nine frozen hashes above. Each inspected the delta plus adjacent implementation and repository standards; none edited files or claimed remote CI success.
|
||||
|
||||
- DRY (`/root/review_dry`): clean; platform decisions are centralized in `MacOsFact`, and the two CI host contracts do not justify further indirection.
|
||||
- SOLID (`/root/review_solid`): clean; atomic cancellation ownership, the public stop regression, and the exact fixture dependency removal preserve coherent responsibilities.
|
||||
- KISS (`/root/review_kiss`): clean; bounded stop coordination, CI command names, platform boundaries and README instructions accurately describe the checks.
|
||||
|
||||
No review repair changed the frozen substantive identity. All required structural passes are complete; runtime evidence and remaining Windows/native limitations are recorded separately above.
|
||||
|
||||
The subsequent README/evidence corrections update only observed artifact availability, publication status, and the checkout-hook event. Structural review is `not-required` for this factual documentation delta; the workflow, runtime, tests and binding instructions are unchanged. A separate read-only factual check confirmed the revised `auto` description against Dockerfile.auto and checked the limits against the saved receipts. Whitespace and the final documentation diff were checked; existing test results remain applicable to the unchanged code.
|
||||
|
||||
## Delivery and remaining limits
|
||||
|
||||
State at publication: structural review complete; current-head remote verification pending. This is not a merge-readiness or main-merge receipt.
|
||||
The user authorized preparing this branch for PR CI and merging into main after tests pass. Authenticated Gitea identity `Daniel` has write/admin on the fork and read-only access to `Manuel/meeting-assistant`; it cannot merge there. The earlier local main-sync commits and reviewed implementation were normally pushed to the existing fork branch as `1164c26846686c1912fd1816cd06de80352e9504`; PR #39's description was updated and read back against that exact head.
|
||||
Upstream [run 4145](https://gitea.schweigert.cloud/Manuel/meeting-assistant/actions/runs/4145) was created for this head, with both Ubuntu jobs waiting and no runner assigned. Its live job view explicitly says `Need approval to run workflows for fork pull request.` The current account cannot approve upstream workflows. This is the existing fork-workflow approval gate, not a request for additional infrastructure. Main is not merged; remote Windows and portable checks have not executed for this head.
|
||||
A native macOS guest/Swift CI run on Ubuntu is not implemented. Docker-OSX supports software emulation, but the documented installed-guest downloads and `auto` tags were unavailable when checked. An [isolated unprivileged Dockur/TCG experiment](macos-tcg-guest-feasibility.md) booted an actual macOS 14.6.1 x86_64 Recovery environment, but `diskutil` could not use the DiskManagement framework; the actual cause remains unresolved. The bounded trial ended without an installed guest or native tests, and its container was stopped. No unverified guest bootstrap was made a required PR check. Passing the two current Ubuntu jobs would still leave the user's requested native macOS coverage in Ubuntu CI unmet; the local Mac result does not close that requirement.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Native macOS Recovery diagnostic on the existing Ubuntu runner
|
||||
|
||||
This manual diagnostic tests the unresolved Recovery startup boundary before adding a native macOS application test job. It does not install macOS, erase a guest disk, install .NET or Apple CLT, or run Meeting Assistant tests. A green diagnostic means only that a real macOS 14+ x86_64 Recovery guest has a working launchd system domain, DiskArbitration and exactly one writable 64-GiB guest disk.
|
||||
|
||||
The workflow `.gitea/workflows/macos-native-diagnostic.yaml` has only `workflow_dispatch`; it does not run on ordinary pushes or pull requests. It uses the same `ubuntu-latest` label and existing Docker daemon as the current builds. There are no runner changes, extra host devices, privileged containers, added capabilities, published ports, host networking or new secrets. It fails clearly if the existing Docker daemon cannot fit its bounded resource budget.
|
||||
|
||||
## Helper entry point and invocation
|
||||
|
||||
The orchestration is a .NET 10 file-based C# app at `tools/ci/MacOsNativeDiagnostic.cs`:
|
||||
|
||||
```sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --help
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/pinned/dockur-clone --output artifacts/native-validation
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --output artifacts/native-macos
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos
|
||||
```
|
||||
|
||||
Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docker CLI/socket. The actual execution downloads public Dockur source, upstream build assets, Docker images and Apple Recovery; it does not use workstation credentials. The existing upstream Python UDIF patcher and the Bash hook are retained because they run inside the pinned Linux/macOS boot integration. Independent orchestration and validation remain C#.
|
||||
|
||||
The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable.
|
||||
|
||||
The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Its own native commands retain 45-second watchdogs and a ten-minute readiness phase; the host orchestrator has a 40-minute deadline and the workflow a 45-minute limit.
|
||||
|
||||
Actual remote run 4155 stopped at the first `sw_vers` with exit 143 before kernel, process or service probes ran. The updated hook collects native `uname`, root identity, bootargs, guest CPU features and process context first. It logs each child PID and builtin elapsed time, explicitly tags watchdog TERM, and takes two independently five-second-bounded CPU/state/command snapshots during each `sw_vers` attempt. After an initial platform failure it still collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. A successful native `sw_vers`, native product version and all original identity/service/disk gates remain required. Process state or a retry alone does not establish whether initialization was slow or a service blocked. The upstream AVX2 warning reads host flags; the pinned TCG CPU path configures an Intel guest with AVX/AVX2, so the hook observes actual guest CPU flags without changing host or guest CPU settings.
|
||||
|
||||
## Evidence and cleanup
|
||||
|
||||
Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails.
|
||||
|
||||
While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success.
|
||||
|
||||
Every container/image has a random run token in its ownership label. `finally` cleanup and the workflow's `always()` step inspect that exact label before removing the matching container and its anonymous storage volume, then the matching image. They never remove an unrelated name or volume, prune Docker, modify host settings or restart Meeting Assistant. Temporary source files are deleted only when their local marker matches the same token. Evidence remains available after cleanup.
|
||||
|
||||
The earlier background-only local bootstrap never obtained DiskManagement readiness. This separate LaunchDaemon probe is still an experiment until the actual remote run produces the required native evidence. Full macOS CI support remains unverified until an installed guest subsequently compiles/signs the native helpers and passes all application tests, including all five native tests without skips.
|
||||
|
||||
Remote run 4152 passed Docker access and resource checks but failed before VM startup: the runner's BuildKit could not checksum a dangling `/etc/alternatives/awk.1.gz` link while copying the entire QEMU filesystem. The candidate now derives directly from the same pinned QEMU filesystem image and overwrites its QEMU executable as before. Inspection of that exact digest reports an empty image `Config`, so it adds no inherited environment, user, command or healthcheck. Actual run 4155 built that image and started QEMU/XNU successfully, then failed the first native `sw_vers` after its 45-second watchdog. It did not prove native readiness.
|
||||
@@ -138,7 +138,9 @@ The tray's fine-grained controls expose `Pause transcription` while a meeting is
|
||||
|
||||
On Windows, `Recording:MicrophoneDeviceId` can pin capture to a specific active microphone endpoint id. Leave it blank to follow the Windows default capture endpoint. The tray icon menu also exposes `Microphone`, listing active microphone endpoints with the effective endpoint checked. Selecting a microphone there overrides the configured/default microphone for later recording starts until another microphone is selected or the process exits.
|
||||
|
||||
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. If an Azure Speech meeting cannot drain transcription before `Recording:StopProcessingTimeout`, Meeting Assistant keeps the WAV and writes a durable backlog item under `TemporaryRecordingsFolder\offline-transcription-backlog`. The background backlog worker retries those queued meetings, replays each WAV through a fresh speech pipeline, rewrites the original transcript, completes meeting metadata and summary generation, then removes the backlog item and WAV.
|
||||
During active capture, including paused transcription, active microphone endpoints are checked once per second. A newly available endpoint produces a Windows notification naming the microphone with Yes/No actions; endpoints already present when capture starts are not offered. Yes changes the shared runtime microphone selection and replaces the current microphone capture, preserving the meeting, system audio, transcription session, and pause state. The selection also applies to later recordings until changed or the process exits. No or an unanswered prompt leaves capture unchanged. Each endpoint is offered at most once per meeting. Prompts become invalid after one minute, when the endpoint disappears, or when that recording stops; a stale notification cannot change a later recording.
|
||||
|
||||
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. If an Azure Speech meeting cannot drain transcription before `Recording:StopProcessingTimeout`, Meeting Assistant keeps the WAV and writes a durable backlog item under `TemporaryRecordingsFolder\offline-transcription-backlog`. The background backlog worker retries those queued meetings and replays each WAV through a fresh speech pipeline. A replay without non-blank speech text leaves the artifacts unchanged and retains the backlog item and WAV for another retry. A usable replay preserves the existing transcript body and appends a recovery section explaining that the full recording was replayed and may duplicate earlier passages; the summary agent receives both the original and recovered text with that explanation. The worker then completes meeting metadata and summary generation and removes the backlog item and WAV.
|
||||
|
||||
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees calendar metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings. Windows reads Outlook Classic through COM. macOS reads EventKit calendars, including Outlook accounts synchronized into macOS Calendar, and requires **Calendar Full Access**.
|
||||
|
||||
@@ -391,11 +393,30 @@ Windows captures the foreground window through the existing Win32 provider. macO
|
||||
| `EnableCompaction` | Enables conversation compaction before requests exceed the configured context budget. |
|
||||
| `CompactionRemainingRatio` | Remaining-context ratio that triggers compaction. |
|
||||
| `ResponsesCompactPath` | Relative Responses compaction path, usually `responses/compact`. |
|
||||
| `InitialPrompt` | Optional complete replacement for the default summary-agent system instructions. |
|
||||
| `InitialPrompt` | Optional replacement for the default base summary instructions. Project metadata and approval guidance is appended to both default and custom prompts. |
|
||||
|
||||
After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks.
|
||||
|
||||
The built-in summary-agent instructions treat assistant context as persistent meeting-specific memory. When the agent encounters an unexpected problem, missing information, or an assumption while summarizing, it appends a concise note through `write_context` so later work on the same meeting can use that history. A configured `Agent:InitialPrompt` completely replaces the built-in instructions, so custom prompts must include equivalent guidance when this behavior is desired.
|
||||
The built-in summary-agent instructions treat assistant context as persistent meeting-specific memory. When the agent encounters an unexpected problem, missing information, or an assumption while summarizing, it appends a concise note through `write_context` so later work on the same meeting can use that history. A configured `Agent:InitialPrompt` replaces the built-in base instructions, so custom prompts must include equivalent memory guidance when this behavior is desired. Project metadata and approval guidance is always appended.
|
||||
|
||||
Every direct subfolder of `Vault:ProjectsFolder` is a project whose ID is its folder name. The summarizer maintains a root `PROJECT.md` for associated projects with YAML frontmatter such as:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: Alpha Platform
|
||||
description: Meeting automation and shared project knowledge.
|
||||
---
|
||||
```
|
||||
|
||||
The display name comes from `name`; `description` is limited to 256 characters. Missing or malformed metadata falls back to the folder ID and an empty description. Bound project metadata is included in the initial prompt beside any root `AGENTS.md`, including when no instructions file exists. Both default and custom prompts instruct the agent to keep this metadata grounded and current while preserving other frontmatter and body notes.
|
||||
|
||||
`list_projects` returns the complete catalog as JSON entries with `id`, `displayname`, and `description`. This grants no file access. Reading, writing, searching, and retrieving historical summaries remain scoped to the meeting note's `projects` field. The summarizer uses its existing `write_projectfile` tool to maintain `PROJECT.md`; invalid resulting metadata is refused before writing.
|
||||
|
||||
`request_project_association(project_id, reason)` asks the user through a native Yes/No notification. The reason must be one nonempty line of at most 100 characters. The tool blocks the summarizer until the decision or a ten-minute timeout. Approval adds the canonical ID to the latest meeting note, preserves other metadata and notes, immediately enables project access, and returns the project's metadata and optional `AGENTS.md`. No, timeout, cancellation, or unavailable notifications grant no access. Expired notifications are removed and late actions have no effect. Already associated projects return context without another prompt.
|
||||
|
||||
`request_workflow_change(intention, detailed_prompt)` returns requested immediately. The intention is a nonempty single line of at most 100 characters; the detailed prompt should be a self-contained scenario or specification. Its native notification remains valid for ten minutes and can be accepted after the summary completes. Approval opens a separate settings-agent conversation and submits the complete prompt automatically. Declining or expiration opens nothing. Requests are held in memory and canceled on application shutdown. The summarizer itself receives no workflow-editing tools.
|
||||
|
||||
Once a stopped meeting reaches summarization, expiration of `Recording:StopProcessingTimeout` returns control while that run continues in the background. It does not cancel a summary waiting for project approval or queue that already-transcribed meeting for offline replay. The approval's own ten-minute deadline remains in effect.
|
||||
|
||||
The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear. It can override transcript speaker labels only when the evidence is very certain, and it can delete wrongfully matched identities. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes.
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` cha
|
||||
|
||||
Meeting Assistant detects the host operating system once during application startup and appends that context to the interactive agent prompt. Windows hosts receive PowerShell, Windows-path, Windows-service, and Task Manager guidance. macOS hosts receive zsh, POSIX-path, launchd/LaunchAgent, and Activity Monitor guidance. Other hosts receive neutral portable guidance. The platform context remains present when `WorkflowRulesEditor:InitialPrompt` is configured, and it does not imply that unavailable platform integrations have been implemented.
|
||||
|
||||
The automatic summarizer can propose changes through `request_workflow_change(intention, detailed_prompt)` but has no direct workflow write tool. `intention` is one nonempty line of at most 100 characters; `detailed_prompt` contains the complete desired change, preferably as a scenario/specification. A Windows notification displays `Workflow change requested: ...` with Yes/No actions. The tool returns requested immediately so summarization continues. Yes opens a separate settings-agent window and automatically submits the complete detailed prompt; existing conversations and drafts are left intact. No or ten minutes without approval discards the request and removes the notification. Workflow requests can be approved after their summary finishes but are not persisted across application restarts. The settings agent applies approved requests through its normal validated tools.
|
||||
|
||||
Project association requests follow a separate blocking path. `request_project_association` waits up to ten minutes before the summarizer continues; approval appends the project ID to meeting frontmatter and returns its instructions. This does not emit a new workflow trigger or change the YAML rule schema. See the configuration guide for project catalog metadata and access rules.
|
||||
|
||||
Automatic approval notifications are currently Windows-only. On macOS and other portable hosts, workflow-change and project-association requests are declined without opening an agent conversation or applying the requested change. The existing interactive macOS agent remains available from the menu bar.
|
||||
|
||||
```json
|
||||
{
|
||||
"MeetingAssistant": {
|
||||
@@ -97,6 +103,8 @@ For every event, the engine:
|
||||
|
||||
For `transcript_line` events, the engine returns the possibly updated transcript line to the caller. Live transcript appends first write the original formatted line and keep a reference to that exact body line, then run transcript-line rules out of band. Later transcript segments can continue to be appended while the workflow runs. If rules return a changed line, Meeting Assistant rewrites the referenced line in the transcript file. If a transcript-line rule fails during live recording, Meeting Assistant logs the failure and keeps the original line so later transcript segments can continue to be written.
|
||||
|
||||
Offline transcription recovery applies `transcript_line` transformations to the recovered lines before appending them to the existing transcript. Earlier transcript content is preserved and is not transformed again. Each appended recovery section starts with a visible marker explaining that replaying the entire recording may produce duplicate passages, which the summary agent should treat as the same discussion. The marker bypasses transcript-line rules so the recovery explanation remains intact. A replay without non-blank speech text skips workflow events, transcript writes, and summarization, and retains its backlog item and WAV for another retry.
|
||||
|
||||
Rules are best-effort automation for live transcript persistence. The settings/logs write tool rejects invalid YAML, unknown steps, unsupported set-property fields, trigger or condition entries without a supported key, and step value Razor templates that fail against the workflow template model before writing the rules file. Invalid NCalc expressions can still fail at workflow runtime. Workflow execution logs every triggered rule with its rule name and event type, logs completion with whether the note or transcript line changed, and logs rule failures with the rule name and event type.
|
||||
|
||||
## YAML Shape
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Wine SDK certificate trust
|
||||
|
||||
`scripts/wine-sdk-trust.cs` is a .NET 10 file-based helper for validating the public root-certificate bundles distributed with the Microsoft SDK and importing them into a disposable Wine prefix's Windows `CurrentUser Root` store. It does not disable NuGet signature verification or certificate checks and does not import private keys.
|
||||
|
||||
```sh
|
||||
dotnet publish scripts/wine-sdk-trust.cs -c Release -p:UseAppHost=false -p:PublishAot=false -o artifacts/wine-sdk-trust
|
||||
dotnet artifacts/wine-sdk-trust/WineSdkTrust.dll --validate /path/to/sdk/trustedroots/codesignctl.pem /path/to/sdk/trustedroots/timestampctl.pem
|
||||
```
|
||||
|
||||
Validation is read-only on every OS. Both files must exist and contain public PEM certificates. Explicit non-CA certificates are rejected. Historical self-issued SDK roots without `BasicConstraints` remain valid; certificate expiry is not filtered because trusted bundles also support historical signatures. The helper logs each bundle's certificate count and SHA-256, then the unique certificate count. Use bundles from the verified Microsoft SDK archive and retain those hashes with the SDK provenance.
|
||||
|
||||
For the Windows SDK installed inside a disposable Wine prefix, invoke the published DLL with `--import` and the two authoritative Linux SDK bundle paths, for example:
|
||||
|
||||
```sh
|
||||
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" artifacts/wine-sdk-trust/WineSdkTrust.dll --import \
|
||||
"Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/codesignctl.pem" \
|
||||
"Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/timestampctl.pem"
|
||||
```
|
||||
|
||||
The import mode first requires `OperatingSystem.IsWindows()` and the Wine-specific `wine_get_version` export from `ntdll.dll`, located with `NativeLibrary.TryLoad` and `TryGetExport`. Native Windows is rejected as well as macOS/Linux. Only after both bundles validate does it open `CurrentUser Root` for writing and call `AddRange`. It closes the store, reopens it read-only and asserts that every imported thumbprint is present. There is no localized shell command, interactive prompt or GUI automation. Missing/unknown arguments and import attempts outside Wine return `2`; validation/import failures return `1`; successful validation or verified import returns `0`.
|
||||
|
||||
The write side effect is confined to the selected Wine prefix's current-user root store. Repeated imports are safe. Run it only against the disposable CI prefix. Native validation never opens any certificate store, and native import attempts are rejected before parsing bundles or constructing a store. The normal PR workflow publishes this DLL and imports the bundles before the Windows desktop restore/build. This repair still requires a real Wine restore/build result to establish that it fixes the observed trust failure.
|
||||
|
||||
[Microsoft documents](https://learn.microsoft.com/en-us/dotnet/core/tools/nuget-signed-package-verification) that these SDK bundles originate from its Trusted Root Program and provide code-signing and timestamping roots. The Windows NuGet restore remains responsible for checking actual package signatures; this helper populates the Wine store used for those checks.
|
||||
|
||||
Local qualification on 2026-10-03 used SDK 10.0.203 to publish the DLL and the authoritative bundles from SDK 10.0.401 to validate it on macOS/.NET 10.0.7. Both bundles passed: codesign contained 307 certificates including seven historical roots without constraints; timestamp contained 327 including two such roots; the union contained 372 unique thumbprints. Their SHA-256 hashes were `AAB671F52E5229906B2100727370007EF4B6D2E360B23F2CF12A6D87773BE611` and `5CCB03367B52F047099F07E1653160E8578A83711CB18560C979FEBB65F8CB5D`, respectively. A native `--import` invocation with those same paths returned `2` before parsing bundles or opening a store; unknown arguments returned `2`, and an empty input returned `1`. No local store import was performed. This is helper qualification, not a passing Wine/NuGet result.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Windows desktop build under Wine
|
||||
|
||||
The PR workflow uses the existing `ubuntu-latest` Gitea runner. It builds `net10.0-windows10.0.19041.0` with the Windows .NET SDK under Wine, then runs the portable `net10.0` test suite through that Windows host. The desktop target retains WPF, `WinExe`, x64, and the Windows recording, hotkey, Outlook, screenshot, tray, and notification implementations.
|
||||
|
||||
## Observed failures
|
||||
|
||||
On 2026-10-03, run 4154 at commit `05e23857ddf9e6990b76660e2ef5c1b429bfe66b` used Wine 11 and successfully imported and read back all 372 unique thumbprints from the Microsoft SDK's code-signing and timestamp trust bundles. The Windows SDK targeting-pack diagnostic succeeded, and the Windows desktop restore completed. NuGet signature verification was not disabled. See [the trust helper documentation](wine-sdk-trust.md).
|
||||
|
||||
The desktop build then failed in `WinAppSdkExpandPriContent` from `Microsoft.Windows.SDK.BuildTools.MSIX` 1.7.20250829.1. MakePRI reported `PRI175: 0x80004001 - Dump`, `MakePri failed with error: Not implemented`, and `PRI222`. The following `Root element is missing` exception came from loading the missing dump output; it was a secondary failure. These logs establish a MakePRI compatibility failure under Wine, but do not identify the particular unimplemented Wine API.
|
||||
|
||||
## Dependency correction
|
||||
|
||||
The resolved Windows graph was:
|
||||
|
||||
```text
|
||||
H.NotifyIcon.Uno.WinUI 2.4.1
|
||||
-> H.NotifyIcon 2.4.1
|
||||
-> Microsoft.WindowsAppSDK 1.8.251106002
|
||||
-> Microsoft.WindowsAppSDK.Base 1.8.250831001
|
||||
-> Microsoft.Windows.SDK.BuildTools.MSIX 1.7.20250829.1
|
||||
```
|
||||
|
||||
`UnoTaskbarIconService.Windows.cs` uses `H.NotifyIcon.Core.TrayIconWithContextMenu`, `PopupMenu`, `PopupMenuItem`, `PopupMenuSeparator`, and `PopupSubMenu`. These types come from the already-selected `H.NotifyIcon` 2.4.1 assembly. The application does not use the wrapper's WinUI/Uno XAML controls. The Windows package reference therefore selects `H.NotifyIcon` 2.4.1 directly. This keeps the same core assembly and tray APIs while removing the unused WinUI wrapper and its Windows App SDK graph. `CommunityToolkit.WinUI.Notifications` 7.1.2 remains available for toast notifications and does not introduce Windows App SDK in the selected Windows target.
|
||||
|
||||
The package author's [core-package documentation](https://www.nuget.org/packages/H.NotifyIcon/2.4.1) supports using `H.NotifyIcon` directly, including in console applications. Its `net10.0` dependency group contains `H.GeneratedIcons.System.Drawing` 2.4.1. The [wrapper package](https://www.nuget.org/packages/H.NotifyIcon.Uno.WinUI/2.4.1) adds Windows App SDK for its Windows target.
|
||||
|
||||
PRI expansion discovers referenced asset files that are absent from normal project outputs and adds them to copy-local output. Disabling that target would risk losing real WinUI resource payloads. This correction removes an unused application dependency instead of disabling PRI generation/expansion, creating a dummy PRI file, suppressing signature checks, or changing the desktop target.
|
||||
|
||||
## Qualification and remaining CI evidence
|
||||
|
||||
Local qualification on 2026-10-03 used the existing `mcr.microsoft.com/dotnet/sdk:10.0-noble` container image with SDK 10.0.401. The existing NuGet package cache was a read-only fallback; new package/cache writes went to a separate temporary directory. This command inside the container compiled the complete Windows desktop target:
|
||||
|
||||
```sh
|
||||
dotnet build MeetingAssistant/MeetingAssistant.csproj \
|
||||
-c Release -f net10.0-windows10.0.19041.0 -r win-x64 \
|
||||
-p:EnableWindowsTargeting=true -p:RestoreFallbackFolders=/host-nuget --nologo
|
||||
```
|
||||
|
||||
The command returned zero with `Build succeeded`, zero errors, and four existing NAudio deprecation warnings. The produced runtime configuration retains `Microsoft.WindowsDesktop.App` alongside the .NET and ASP.NET frameworks. The regenerated assets graph contains `H.NotifyIcon/2.4.1`, no `Microsoft.WindowsAppSDK*` packages, and no `Microsoft.Windows.SDK.BuildTools.MSIX`; generated package imports contain no Windows App SDK or MakePRI entry. The output `H.NotifyIcon.dll` is byte-identical to the previously selected cached core assembly, with SHA-256 `0027e443a6121af8fb616c0200d3c63bf4abb72e3c548e119fee1eae321a8368`. No application source or target suppression was required. This Linux cross-build does not establish that Windows `dotnet.exe` succeeds under Wine.
|
||||
|
||||
Completion requires a new Gitea run at the corrected commit: successful Windows desktop build under Wine with a freshly produced `MeetingAssistant.dll`, followed by actual Windows-host test execution with a fresh `wine.trx`, nonzero executed tests, zero failed tests, and a successful job. The workflow removes the previous DLL/TRX at the expected paths before these commands and requires nonempty replacements. The Wine test project targets `net10.0`, so these tests do not prove execution of Windows-TFM-only or WPF/Outlook UI behavior. Native macOS tests report their explicit platform skip outside macOS.
|
||||
@@ -0,0 +1,65 @@
|
||||
# macOS main synchronization — 2026-10-03
|
||||
|
||||
## Candidate and scope
|
||||
|
||||
Repository: `/Users/dh/Documents/DanielsVault/_ops/meeting-assistant`.
|
||||
Authorized delivery: fetch remote main, merge into `codex/macos-support`, deploy and verify on this Mac. No main push or OpenSpec archive.
|
||||
|
||||
Old branch HEAD: `bf6e1e7560c6bdfcebf69ad0e3240b4de647adb6`.
|
||||
Fetched and rechecked remote main: `7b2bcd36310ae6434a4ee6eeb3a47b6d1d431df4`.
|
||||
The initial worktree was clean. Upstream changes are included unchanged except conflict resolution; prior macOS features are preserved.
|
||||
|
||||
Substantive review covers the owned merge resolutions and required portable interface adaptation, not an independent review of all upstream or earlier branch features. Reviewed current content manifest:
|
||||
|
||||
| File | SHA-256 |
|
||||
| --- | --- |
|
||||
| `MeetingAssistant/Program.cs` | `1165364aeb846fc164cc875146b8fd70636dbc2c51e47ae3a373dab75330ce2a` |
|
||||
| `MeetingAssistant/MacOs/MacOsWorkflowRulesEditorWindowService.cs` | `b008ef5893dd1caf20f504e3a9bde390dd8f0e4fe6c31f9725d26fd5007b6e23` |
|
||||
| `MeetingAssistant.Tests/MacOsDesktopControlManifestTests.cs` | `3ff6e8c73830fb350c6f7d89e550a84eacef6acb3c363407ab8eb8a8c2f0dfe5` |
|
||||
| `docs/meeting-workflow-engine.md` | `9792a398f387a96a4fe1a02bfe13f19a9ca30cc869bbbe393f7341e0cbdb3a9e` |
|
||||
| `openspec/changes/add-macos-desktop-controls/specs/meeting-session/spec.md` | `5b730cd96bed5e7dff87690272d5755d8b9d0ef5bcc78a674c6688e152ac635d` |
|
||||
| `openspec/changes/add-macos-desktop-controls/tasks.md` | `877304a11600e513630d495882564dfd083825f163a4c53574f76f3fd2d1f42e` |
|
||||
|
||||
This receipt and generated logs are evidence, excluded from the reviewed behavioral content identity.
|
||||
|
||||
## Requirements and verification
|
||||
|
||||
- Preserve macOS recording/menu/calendar/screenshot/editor registrations: resolved composition inspected; existing native registration and manifest tests pass. Upstream no-op approval dependency is present without replacing real macOS providers.
|
||||
- Reject unavailable automatic approvals without changing the interactive conversation: `MacOsApplicationDeclinesUnavailableAutomaticApprovals` calls the registered approval service through its public interface and observes `false`.
|
||||
- Never silently discard an automatic workflow prompt: `AutomaticWorkflowChangeCannotSilentlyLoseItsPromptOnMacOs` observes `PlatformNotSupportedException`. A compiled red run first reported actual `NotImplementedException`; the subsequent implementation passes.
|
||||
- Existing interactive editor remains available: existing `MacOsApplicationRegistersARealInteractiveAgentWindow` passes; `Show()` is unchanged.
|
||||
- Full portable regression suite: **576 passed, zero failed**, on this Mac. Command: `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true --logger 'trx;LogFileName=macos-main-sync-green.trx'`. Raw red/green receipts: `MeetingAssistant.Tests/TestResults/macos-main-sync-{red,green}.trx` (generated, not committed).
|
||||
- Strict OpenSpec validation passed for `add-macos-desktop-controls`, `add-summary-agent-requests`, and `prompt-for-new-microphone`. `git diff --check` passed.
|
||||
|
||||
The new upstream approval notifications are Windows-only. On portable hosts approval requests are declined as permitted by the upstream notification-unavailable scenario. No new native notification feature is claimed.
|
||||
|
||||
## Standards and sequential review
|
||||
|
||||
Rules: repository `AGENTS.md`, the active macOS desktop-control scenario, and upstream automatic approval unavailability requirements.
|
||||
Coverage: public-interface tests/duplication → DRY; coherent dependencies, explicit platform failure, lifecycle preservation, source ownership → SOLID; minimal sync scope, naming/readability, spec/docs/task consistency → KISS. Typechecking and whitespace are covered by build/tests and diff check.
|
||||
|
||||
- DRY `/root/review_dry`: clean; verified all six manifest hashes and surrounding composition/approval code. No duplication needing consolidation. Public-interface tests satisfy repository guidance.
|
||||
- SOLID `/root/review_solid`: clean; verified all six hashes, interface/callers and native registrations. Explicit unsupported handoff is caught by its caller; normal interactive editor is unchanged. No abstraction needed.
|
||||
- KISS `/root/review_kiss`: clean; verified the same six hashes at merge commit `fc0edd812dd22a03e6eb5e3b3bfdd0fa080fc010`. Inspected nearby interfaces/callers and raw TRX evidence. Minimal structure, clear names, consistent spec/docs/tasks; no binding violations or material risks.
|
||||
|
||||
## Delivery and runtime evidence
|
||||
|
||||
State: technically complete for the reviewed code; final production-configuration restoration accompanies the evidence-only closeout commit.
|
||||
The existing updater can skip managed-only bundle changes. This deployment must replace the complete signed bundle, not just the loose runtime.
|
||||
Recoverable backup: `/Users/dh/Library/Application Support/MeetingAssistant/backups/main-sync-20261003.DtKbYS`.
|
||||
Isolated test/evidence directory: `/private/tmp/meeting-assistant-main-sync.gABedp`.
|
||||
Configuration and credentials remain external; a temporary root-level smoke overlay is restored byte-for-byte after testing. No genuine meeting artifacts are test cleanup targets.
|
||||
|
||||
### Operational verification
|
||||
|
||||
- The installed merge build reports `1.0.0+fc0edd812dd22a03e6eb5e3b3bfdd0fa080fc010`; staged and installed runtime SHA-256 matched `71b4b2495fcaa5bf790712586073f88d1b6e1dc5fdd37a4893f1623f47d554d5`.
|
||||
- `codesign --verify --deep --strict` passed for staged and installed bundles. The designated requirement remains `identifier "cloud.schweigert.meeting-assistant"`, retaining the stable installed privacy identity.
|
||||
- `/health` returned service `meeting-assistant`, status `ok`; `/diagnostics/platform-capabilities` reported the actual macOS calendar, screenshot and editor implementations. Process inspection confirmed the installed bundle runtime and macOS menu-bar helper.
|
||||
- The native integrations helper `self-test` returned `status: ok` with calendar, prompt, screenshot and crop features. This packaging check does not itself prove all permissions or UI interactions.
|
||||
- First successful real test recording: `20261003-1024`, 77 seconds, system audio through the installed helper and Azure Speech to a transcript and completed LiteLLM summary. The long synthetic utterance was only partly recognized; no perfect recognition claim is made.
|
||||
- Stronger short-utterance check: `20261003-1027`, 26 seconds. All four synthetic sentences were transcribed, including `Die Aufnahme endet jetzt mit dem Wort Kaffeetasse.` The summary includes the identifier and recording end. Assistant context reached `state: finished`; `/recording/status` returned `isRecording:false,state:0`.
|
||||
- Agent-runnable assertion: `/private/tmp/meeting-assistant-main-sync.gABedp/verify-smoke.sh` returned `PASS: real system audio -> Azure transcript including end marker -> LiteLLM summary -> finished/idle`.
|
||||
- Evidence: `vault/Meetings/Transcripts/20261003-1027-transcript.md`, `vault/Meetings/Summaries/20261003-1027-summary.md`, and `runtime-smoke.log` inside the isolated test directory above. Both live native audio helpers produced PCM; microphone gain was zero to avoid recording ambient conversations, so actual spoken microphone transcription was not tested.
|
||||
- An initial test-start timeout was caused by the smoke setup's ten-minute note-opening delay, before capture began. The overlay was corrected to zero and the two real recordings above completed. This was not a runtime audio failure of the new build.
|
||||
|
||||
The closeout commit changes only this evidence record; the reviewed behavioral manifest remains unchanged. Its rebuilt bundle is installed while idle, production env is restored exactly from the preserved original, and final health/status/signature/provenance checks are performed before handoff. No remote push is included in this request.
|
||||
@@ -26,3 +26,9 @@ Selecting `Open agent` on macOS SHALL open a native window titled `Meeting Summa
|
||||
- **WHEN** the user selects `Open agent` from the menu-bar control
|
||||
- **THEN** a `Meeting Summary Agent` window opens
|
||||
- **AND** the user can submit a chat message and receive the interactive agent response
|
||||
|
||||
#### Scenario: unavailable automatic approvals do not alter the macOS agent conversation
|
||||
- **GIVEN** automatic workflow-change approval notifications are unavailable on macOS
|
||||
- **WHEN** the summary agent requests a workflow change or project association
|
||||
- **THEN** the request is declined without opening or changing the interactive agent conversation
|
||||
- **AND** a direct attempt to open an automatic prompt reports the platform limitation instead of silently discarding the prompt
|
||||
|
||||
@@ -16,3 +16,7 @@
|
||||
- [x] 3.2 Run strict OpenSpec validation.
|
||||
- [x] 3.3 Launch Meeting Assistant, start a new recording, and save screenshot evidence.
|
||||
- [x] 3.4 Perform required DRY, SOLID, and KISS refactoring reviews and re-run verification.
|
||||
|
||||
## 4. Main synchronization compatibility
|
||||
|
||||
- [x] 4.1 Preserve macOS service registration and explicitly reject unsupported automatic prompts, with portable behavior tests.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-25
|
||||
@@ -0,0 +1,35 @@
|
||||
## Context
|
||||
|
||||
Project IDs are direct subfolder names. BoundMeetingProjectResolver reads the meeting note on each access. The summarizer already has project read/write tools and an initial section for bound AGENTS.md instructions, but writes currently accept any existing project. Native actionable toasts and a WPF settings-agent window already exist.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Discover projects using compact metadata, extend project access only after approval, maintain metadata through the agent, and forward approved workflow requests to the interactive settings agent.
|
||||
|
||||
**Non-Goals:** Bulk-generate metadata for unassociated projects, let the summarizer edit workflows directly, change project IDs, migrate UI frameworks, or deploy/restart the workstation service.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Store `name` and `description` in the root `PROJECT.md` frontmatter; `name` is the display name, and the directory name remains the stable ID. Missing/invalid metadata falls back to the ID and an empty description. Catalog results contain metadata, never AGENTS.md or arbitrary project content.
|
||||
- Reuse write_projectfile for metadata maintenance. Validate resulting PROJECT.md frontmatter (name and description, maximum 256 description characters) before writing. Preserve ordinary file edit modes. Restrict writes to associated projects, matching existing read/search permissions.
|
||||
- Append metadata and AGENTS.md at the existing initial project section. Add mandatory capability guidance even when a custom base prompt is configured. Instruct the agent to maintain metadata using grounded project knowledge and propose missing associations before finalizing the summary.
|
||||
- Project approval is an awaited tool call, with cancellation and a ten-minute deadline. Return approval plus project metadata and AGENTS.md only after updating the latest meeting note. Preserve other frontmatter and body content; recheck target and cancellation before writing. Write to a temporary file in the same directory and replace the note only after that write completes, so cancellation or a partial write cannot truncate the note. Existing associations return current context without another prompt.
|
||||
- Use an application-owned background request queue for workflow approvals. The tool returns requested immediately; the queue outlives a summary run. Requests also expire after ten minutes. Only acceptance opens a new settings-agent window and automatically submits the complete detailed prompt. A new conversation avoids overwriting drafts or interrupting an existing agent run.
|
||||
- Reuse the installed native toast toolkit behind a testable approval-prompt interface. Timeouts and cancellation remove pending toasts and invalidate late actions. Shutdown cancels pending approvals; requests are not persisted across process restarts.
|
||||
- Keep tool execution sequential so a blocking project approval cannot be bypassed by concurrent tool execution in the same model response.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [User ignores project approval] → continue after ten minutes as denied; no project scope or note change.
|
||||
- [User edits the note while a request is pending] → read again after acceptance and append only the project field, retaining all other current fields and notes.
|
||||
- [Project disappears or becomes invalid while waiting] → refuse the association without expanding access.
|
||||
- [Native UI cannot run in automated tests] → test approval, timeout, scope and handoff through controlled prompt/window services and build the Windows target; record hardware/UI verification limits.
|
||||
- [Summarizer completes before workflow approval] → the application queue retains the request until decision, timeout, or shutdown.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No bulk migration. Existing folders remain discoverable without PROJECT.md, and associated projects gain metadata as the summarizer maintains them. Rollback removes the tools; PROJECT.md and normal meeting associations remain ordinary vault content.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. Workflow notifications use the same ten-minute validity as project notifications; workflow notification intentions are limited to 100 characters for readability.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
The summarizer cannot currently discover missing project associations or propose workflow improvements with explicit user approval. Project folder names alone do not provide enough context for selecting the right project.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Maintain `PROJECT.md` frontmatter with `name` and a description of at most 256 characters through the summarizer's existing project-file tools.
|
||||
- Include available project metadata alongside bound project instructions and expose the full project metadata catalog through `list_projects`.
|
||||
- Add `request_project_association` with a project ID and a reason of at most 100 characters. Wait up to ten minutes for a native Yes/No notification; approval adds the association and returns project instructions.
|
||||
- Enforce meeting association for summarizer project-file writes as well as reads. Catalog visibility alone does not grant project access.
|
||||
- Add nonblocking `request_workflow_change` with a short intention and detailed scenario/specification. Approval opens a settings-agent conversation and immediately submits the detailed request; the summarizer cannot edit workflows directly.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `summary-agent-requests`: User approval for blocking project association and asynchronous workflow-change handoff.
|
||||
|
||||
### Modified Capabilities
|
||||
- `project-knowledge`: Project identity and discovery metadata in `PROJECT.md`.
|
||||
- `agent-project-tools`: Full metadata catalog and association-scoped writes.
|
||||
- `meeting-summary`: Project metadata in initial instructions and guidance for metadata maintenance and requests.
|
||||
|
||||
## Impact
|
||||
|
||||
Summary tools and agent instructions, project lookup and meeting frontmatter, native Windows notifications, the settings-agent window/prompt submission, DI registration, tests, and workflow/configuration documentation. Existing project IDs remain directory names; no bulk vault migration or live service restart is required.
|
||||
@@ -0,0 +1,91 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Agents can use project context tools
|
||||
Meeting Assistant SHALL expose tools that allow agents to look up project information, retrieve keyword-relevant context, and inspect meeting-derived knowledge.
|
||||
|
||||
Project tools SHALL treat each direct subfolder of the configured projects folder as one project. A meeting note binds projects by listing those subfolder names in the `projects` frontmatter field.
|
||||
|
||||
The summary agent SHALL expose these project tools:
|
||||
|
||||
- `list_projects`
|
||||
- `request_project_association`
|
||||
- `request_workflow_change`
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `list_past_project_meetings`
|
||||
- `read_past_project_meeting_summary`
|
||||
- `search`
|
||||
|
||||
The `search` tool SHALL search the requested current-meeting project scope, or all projects assigned to the current meeting when no project scope is supplied.
|
||||
|
||||
The `search` tool SHALL search both configured project files for the scoped projects and past meeting summary notes in the configured summaries folder whose frontmatter projects intersect the scoped projects.
|
||||
|
||||
The `search` tool SHALL refuse requested project scopes that are not assigned to the current meeting.
|
||||
|
||||
#### Scenario: Agent looks up meeting project context
|
||||
- **WHEN** a meeting note identifies a project
|
||||
- **THEN** the agent can retrieve relevant project context through Meeting Assistant tools
|
||||
|
||||
#### Scenario: Agent discovers the full project catalog
|
||||
- **WHEN** the agent calls `list_projects`
|
||||
- **THEN** it receives every configured project with ID, display name, and description
|
||||
- **AND** this catalog does not grant access to unassociated project files or AGENTS.md
|
||||
|
||||
#### Scenario: Agent reads a clamped project file range
|
||||
- **WHEN** the agent reads a project file with optional line bounds outside the file length
|
||||
- **THEN** `read_projectfile` clamps the requested range to the available file lines without failing
|
||||
|
||||
#### Scenario: Agent searches project knowledge
|
||||
- **WHEN** the agent calls `search` with .NET regular expression keywords
|
||||
- **THEN** Meeting Assistant runs the search against the requested projects or the meeting-bound projects and returns matches as `filename:line text`
|
||||
|
||||
#### Scenario: Agent searches past project meeting summaries
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches for a keyword
|
||||
- **THEN** Meeting Assistant returns matches from both `Project X` project files and matching past summary notes
|
||||
|
||||
#### Scenario: Agent search rejects out-of-scope project
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches with project scope `Project Z`
|
||||
- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting
|
||||
|
||||
### Requirement: Agents can write files in existing projects
|
||||
Meeting Assistant SHALL expose a `write_projectfile` tool that can create or update files inside an existing project folder.
|
||||
|
||||
The target project SHALL be an existing direct subfolder of the configured projects folder and SHALL be associated with the current meeting. The target file path SHALL stay inside that project folder. Newly approved associations SHALL take effect immediately for all project tools.
|
||||
|
||||
When no line edit arguments are supplied and `replace_file` is not true, `write_projectfile` SHALL append the supplied content to the file and SHALL create the file when it does not exist.
|
||||
|
||||
When `replace_file` is true, `write_projectfile` SHALL replace the whole file with the supplied content.
|
||||
|
||||
When both `from` and `to` are supplied, `write_projectfile` SHALL replace the inclusive 1-based line range with the supplied content.
|
||||
|
||||
When `insert` is supplied, `write_projectfile` SHALL insert the supplied content at that 1-based line position.
|
||||
|
||||
Meeting Assistant SHALL refuse ambiguous writes that combine `replace_file` with line edit arguments.
|
||||
|
||||
#### Scenario: Project file is appended or created
|
||||
- **WHEN** the agent writes a project file without line edit arguments
|
||||
- **THEN** Meeting Assistant appends the supplied content to an existing file
|
||||
- **AND** creates the file with the supplied content when it does not exist
|
||||
|
||||
#### Scenario: Project file is explicitly replaced
|
||||
- **WHEN** the agent writes a project file with `replace_file` set to true
|
||||
- **THEN** Meeting Assistant writes the supplied content as the complete file content
|
||||
|
||||
#### Scenario: Project file line range is replaced
|
||||
- **WHEN** the agent writes a project file with `from` and `to` line numbers
|
||||
- **THEN** Meeting Assistant replaces the inclusive line range with the supplied content
|
||||
|
||||
#### Scenario: Project file content is inserted
|
||||
- **WHEN** the agent writes a project file with an `insert` line number
|
||||
- **THEN** Meeting Assistant inserts the supplied content at that line position
|
||||
|
||||
#### Scenario: Project file write target is invalid
|
||||
- **WHEN** the target project is missing, is not associated with the meeting, or the target path escapes the project folder
|
||||
- **THEN** Meeting Assistant refuses the project file write
|
||||
|
||||
#### Scenario: Project file write mode is ambiguous
|
||||
- **WHEN** the agent combines `replace_file` with `from`, `to`, or `insert`
|
||||
- **THEN** Meeting Assistant refuses the project file write
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Summary agent receives bound project instructions
|
||||
Meeting Assistant SHALL append a project section for projects bound to the meeting note frontmatter. Each entry SHALL identify the project ID and include its display name and description from PROJECT.md when available. For each bound project with a root AGENTS.md, the entry SHALL also include that file's content. Metadata SHALL appear even when AGENTS.md is absent. Unassociated project instructions SHALL NOT be appended.
|
||||
|
||||
#### Scenario: Project AGENTS files are appended
|
||||
- **GIVEN** the meeting note frontmatter lists projects Alpha and Beta with root AGENTS.md files
|
||||
- **WHEN** the summary agent is created
|
||||
- **THEN** its project section includes both project IDs and their instructions
|
||||
|
||||
#### Scenario: Project metadata without instructions is appended
|
||||
- **GIVEN** a bound project has PROJECT.md metadata but no AGENTS.md
|
||||
- **WHEN** the summary agent is created
|
||||
- **THEN** its entry contains ID, display name and description
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Summarizer maintains project metadata and proposes missing associations
|
||||
Both default and custom summary-agent instructions SHALL explain the full metadata catalog, the distinction between catalog visibility and project access, and the approval process. They SHALL instruct the agent to propose plausible missing associations with concise evidence before finalizing the summary and to maintain associated PROJECT.md name and description (maximum 256 characters). They SHALL explain that workflow improvements require a short intention and a detailed request and are executed only by the settings agent after user approval.
|
||||
|
||||
#### Scenario: Custom base prompt still receives capability guidance
|
||||
- **WHEN** a custom summary base prompt is configured
|
||||
- **THEN** the resulting instructions still explain metadata maintenance, association approvals and workflow-change requests
|
||||
@@ -0,0 +1,18 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Projects have compact discovery metadata
|
||||
Each project SHALL use a root `PROJECT.md` with YAML frontmatter `name` and `description` for agent-maintained discovery metadata. `name` SHALL be the display name; the project ID SHALL remain its direct subfolder name. The summarizer SHALL be instructed to create and maintain this metadata for associated projects from grounded project knowledge, preserving other frontmatter and body content. The description SHALL contain at most 256 characters.
|
||||
|
||||
Catalog lookup SHALL tolerate missing or malformed metadata by falling back to the ID as display name and an empty description. Metadata lookup SHALL NOT grant access to project files or instructions.
|
||||
|
||||
#### Scenario: Project has metadata
|
||||
- **WHEN** a project has valid name and description frontmatter
|
||||
- **THEN** catalog lookup exposes its ID, display name, and description
|
||||
|
||||
#### Scenario: Legacy project has no metadata
|
||||
- **WHEN** a project has no PROJECT.md or no usable frontmatter
|
||||
- **THEN** it remains discoverable with its ID as display name and an empty description
|
||||
|
||||
#### Scenario: Agent writes invalid metadata
|
||||
- **WHEN** the summarizer attempts a PROJECT.md update without valid name/description or with a description longer than 256 characters
|
||||
- **THEN** the write is refused without changing the existing file
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Summarizer can request project association
|
||||
The summarizer SHALL expose `request_project_association` accepting an existing project ID and a nonempty single-line reason of at most 100 characters. Invalid requests SHALL be refused without a notification or file changes.
|
||||
|
||||
The tool SHALL show a native notification identifying the meeting and project, explaining the association request and reason, with Yes/No actions. Tool completion and further summarizer work SHALL wait for the decision for up to ten minutes. No, expiration, notification unavailability, or canceled summarization SHALL NOT add the project. Expiration SHALL remove the notification, invalidate late activation, and return denial.
|
||||
|
||||
Once a stopped run has reached summarization, the recording stop-processing deadline SHALL return control while summary processing continues in the background. It SHALL NOT cancel a summary awaiting approval or queue an already-transcribed recording for offline replay.
|
||||
|
||||
On Yes, the tool SHALL append the canonical project ID to the latest meeting-note projects frontmatter without duplicates, preserving other fields and body. Project tools SHALL immediately honor the updated scope, and the result SHALL report approval with project metadata and the root AGENTS.md content when present. No unapproved AGENTS.md content SHALL be returned. Already associated projects SHALL return their current context without prompting.
|
||||
|
||||
#### Scenario: Approval extends scope during the same summary
|
||||
- **GIVEN** a catalog project is not associated with the meeting
|
||||
- **WHEN** the summarizer requests association and the user chooses Yes
|
||||
- **THEN** the meeting note gains that project and the same agent can read/write it
|
||||
- **AND** the result includes its AGENTS.md if present
|
||||
|
||||
#### Scenario: Denial or timeout preserves scope
|
||||
- **WHEN** the user chooses No or ten minutes elapse
|
||||
- **THEN** the waiting tool reports denial, the notification disappears, and scope and meeting note remain unchanged
|
||||
|
||||
#### Scenario: User edits note during approval
|
||||
- **WHEN** the user changes other meeting frontmatter or notes while an association request waits
|
||||
- **AND** then approves the project
|
||||
- **THEN** the project is added without losing those edits
|
||||
|
||||
#### Scenario: Invalid or stale request cannot grant access
|
||||
- **WHEN** the ID is unknown, reason exceeds 100 characters, the target disappears, or summarization is canceled
|
||||
- **THEN** the request cannot expand project access
|
||||
|
||||
#### Scenario: Stop wait expires while summary approval is pending
|
||||
- **GIVEN** transcription has completed and summarization is waiting for a user decision
|
||||
- **WHEN** the recording stop-processing timeout expires
|
||||
- **THEN** stopping returns with summarization still active
|
||||
- **AND** the summary continues with its own approval deadline without an offline transcription backlog item
|
||||
|
||||
### Requirement: Summarizer can request workflow changes without editing workflows
|
||||
The summarizer SHALL expose `request_workflow_change` accepting a nonempty single-line intention of at most 100 characters and a nonempty detailed change prompt, preferably a scenario or specification. The tool SHALL return requested without waiting for approval, and the summarizer SHALL continue normally without direct workflow-editing capabilities.
|
||||
|
||||
A native notification SHALL show `Workflow change requested: ...` with the intention and Yes/No actions. The detailed prompt SHALL be retained by the application independently of summary completion. No or ten-minute expiration SHALL discard the request without opening the editor or changing workflows. Late activation SHALL have no effect.
|
||||
|
||||
Yes SHALL open a new settings-agent conversation and immediately submit the full detailed prompt once. An existing open conversation or draft SHALL remain intact. Application shutdown SHALL cancel and remove pending notifications.
|
||||
|
||||
#### Scenario: Workflow request does not block the summary
|
||||
- **WHEN** the agent requests a workflow change
|
||||
- **THEN** the tool returns requested while approval remains pending
|
||||
- **AND** the summary can complete without opening the settings agent
|
||||
|
||||
#### Scenario: Approved workflow request starts settings agent
|
||||
- **GIVEN** the summary run has completed and its workflow request is pending
|
||||
- **WHEN** the user chooses Yes
|
||||
- **THEN** the settings-agent window opens and starts the detailed prompt exactly once
|
||||
|
||||
#### Scenario: Denied or expired workflow request does nothing
|
||||
- **WHEN** the user declines or ignores a workflow request for ten minutes
|
||||
- **THEN** its notification disappears and no editor or workflow change is started
|
||||
@@ -0,0 +1,35 @@
|
||||
## 1. Project catalog and metadata
|
||||
|
||||
- [x] 1.1 Test and implement full metadata catalog with legacy/malformed fallback and scoped file access.
|
||||
- [x] 1.2 Test PROJECT.md metadata writes and enforce the description limit without damaging existing files.
|
||||
- [x] 1.3 Include bound metadata beside AGENTS.md and instruct default/custom agents to maintain it and request missing associations.
|
||||
|
||||
## 2. Project approval
|
||||
|
||||
- [x] 2.1 Test blocking approval, immediate scope update, returned AGENTS.md and preservation of concurrent user note edits.
|
||||
- [x] 2.2 Handle invalid IDs/reasons, existing associations, denial, timeout, cancellation and stale actions.
|
||||
- [x] 2.3 Register the tool and native Yes/No notification with ten-minute validity and sequential summary tool execution.
|
||||
- [x] 2.4 Keep summarization alive after the recording stop wait expires so approval does not trigger transcription cancellation or offline replay.
|
||||
|
||||
## 3. Workflow-change requests
|
||||
|
||||
- [x] 3.1 Test and implement nonblocking application-owned workflow requests that survive summary completion.
|
||||
- [x] 3.2 Open a separate settings-agent conversation and automatically submit approved detail; preserve existing drafts and runs.
|
||||
- [x] 3.3 Cover rejection, expiration, shutdown, invalid requests and exactly-once acceptance.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Update workflow and configuration documentation.
|
||||
- [x] 4.2 Review changed code and surrounding integration; fix actionable findings.
|
||||
- [x] 4.3 Run relevant tests, full solution tests, Windows build and strict OpenSpec validation.
|
||||
- [x] 4.4 Verify through the safest direct operational surface and document native UI/live-provider limits.
|
||||
|
||||
Verification (2026-09-25): all 552 solution tests pass, including 20 focused project/catalog/request tests. The solution builds the Windows target successfully with the four existing NAudio obsolete-API warnings. Strict validation passes for this change and the existing microphone change; `git diff --check` is clean.
|
||||
|
||||
Two independent review passes covered project metadata/access and notification/queue/UI lifecycle, including surrounding recording-stop integration. Incomplete metadata now consistently falls back to the project ID, and traversal coverage uses an associated project so it actually exercises path containment. Project association writes prepare a same-directory temporary file before replacing the meeting note; successful requests leave no temporary file behind.
|
||||
|
||||
Public-tool behavior tests inspect real generated meeting and project files with controlled approval/window services. They verify latest-note preservation, immediate scope changes, returned instructions, rejection/expiry/cancellation, nonblocking workflow requests, and automatic submission of approved detail to a fresh settings-agent conversation. A coordinator regression test verifies that the stop wait cannot cancel a summary awaiting approval or enqueue unnecessary offline replay.
|
||||
|
||||
The running application's recording-status endpoint was checked read-only and reported idle. The service was not updated or restarted. Native toast activation/removal, actual WPF window isolation, and a live model invoking these tools were not exercised, because those require running the new Windows build and interacting with its notifications. Sequential tool execution is configured and compiled; the model pipeline itself was not exercised with simultaneous tool calls. These operational checks remain required before acceptance or archival.
|
||||
|
||||
Authorized local start (2026-09-25): after confirming idle status and logs, the documented restart helper published and started the Release build in `tmp/meeting-assistant-runtime/run-20260925-115711`. `/health` reports `ok`, `/recording/status` reports idle, the tray icon was created, and Resemblyzer warm-up completed without stderr errors. The published application DLL matches the Release output by SHA-256. This verifies startup of the new build; native approval interactions and live-model tool invocation remain unverified.
|
||||
@@ -24,4 +24,8 @@ After configured reconnect attempts are exhausted, Azure should emit a longer di
|
||||
|
||||
If a stopped Azure Speech meeting cannot finish draining before `Recording:StopProcessingTimeout`, the coordinator queues the completed temporary WAV and artifact paths in a persisted offline backlog, releases the active recording slot, and keeps the WAV from startup cleanup. This lets the user start more meetings while Azure or the network is still unavailable.
|
||||
|
||||
The backlog processor retries queued items in the background. Each item creates a fresh speech recognition pipeline for the original launch profile, streams the queued WAV into that pipeline, rewrites the original transcript file with the finished lines, updates meeting-note and transcript metadata, runs transcript-line workflow transformations, transitions the assistant context through summarizing to finished/error, runs the normal summary pipeline, then removes the backlog item and deletes the temporary WAV. Failed processing leaves the backlog item and WAV in place for a later retry.
|
||||
The backlog processor retries queued items in the background. Each item creates a fresh speech recognition pipeline for the original launch profile and streams the queued WAV into that pipeline. Results without non-blank speech text leave all artifacts and recovery files unchanged for a later retry.
|
||||
|
||||
A usable replay preserves the existing transcript body and appends a visible recovery section before updating meeting metadata and running the normal summary pipeline. The recovery marker explains that the full recording was replayed, earlier passages may be duplicated, and repeated content should be treated as the same discussion when summarizing. Recovered lines still receive transcript-line workflow transformations; the explanatory marker is written directly so it remains visible. The application does not try to deduplicate or reconcile live and recovered recognition results.
|
||||
|
||||
After appending, the processor updates meeting-note and transcript metadata, transitions the assistant context through summarizing to finished/error, and runs the normal summary pipeline. Completed processing removes the backlog item and temporary WAV. Failed processing leaves both in place for a later retry; an additional replay may append another clearly marked recovery section without discarding earlier content.
|
||||
|
||||
+24
-2
@@ -27,7 +27,11 @@ When Azure Speech is still unavailable after recording stops and transcription c
|
||||
|
||||
When a stopped meeting is persisted to the durable transcription backlog, Meeting Assistant SHALL release the active recording slot so another meeting can be recorded while the stopped meeting waits for Azure Speech to become available.
|
||||
|
||||
When Azure Speech becomes available again, Meeting Assistant SHALL retry durable backlog items, rewrite the transcript from the recorded WAV, run the normal post-transcription meeting completion and summary flow, and remove the backlog item after successful completion.
|
||||
When Azure Speech becomes available again, Meeting Assistant SHALL retry durable backlog items, append the recovered transcript from the recorded WAV without replacing the existing transcript body, run the normal post-transcription meeting completion and summary flow, and remove the backlog item after successful completion.
|
||||
|
||||
Before appending recovered lines, Meeting Assistant SHALL insert a visible recovery marker explaining that the following section was recovered by replaying the entire recording and may duplicate earlier transcript passages. The marker SHALL tell the summarizer to treat repeated passages as the same discussion. The marker and appended lines SHALL be available to the summary agent together with the preserved earlier transcript. Existing transcript text, speaker labels, and user edits SHALL remain unchanged.
|
||||
|
||||
An offline replay that returns no speech segments with non-blank text SHALL be treated as an unsuccessful attempt. This includes empty results, blank speech segments, and results containing only status markers. Meeting Assistant SHALL preserve the existing transcript and meeting artifacts unchanged, SHALL skip meeting completion and summarization, and SHALL retain the durable backlog item and its WAV for a later retry.
|
||||
|
||||
When Meeting Assistant starts, it SHALL preserve WAV files that are referenced by durable transcription backlog items instead of deleting them as stale temporary recordings.
|
||||
|
||||
@@ -73,6 +77,24 @@ When Meeting Assistant starts, it SHALL preserve WAV files that are referenced b
|
||||
#### Scenario: Durable Azure backlog resumes after connectivity returns
|
||||
- **GIVEN** a stopped Azure meeting exists in the durable transcription backlog
|
||||
- **WHEN** Azure Speech can transcribe the recorded WAV
|
||||
- **THEN** Meeting Assistant rewrites the transcript from the recorded WAV
|
||||
- **THEN** Meeting Assistant preserves the existing transcript body and appends a recovery marker followed by the transcript from the recorded WAV
|
||||
- **AND** the marker explains the recovery and possible duplicates to the summary agent
|
||||
- **AND** runs meeting completion and summarization
|
||||
- **AND** removes the durable backlog item and its temporary WAV after successful completion
|
||||
|
||||
#### Scenario: Empty offline replay preserves the live transcript and recovery files
|
||||
- **GIVEN** a stopped Azure meeting has an existing live transcript and a durable backlog item referencing its WAV
|
||||
- **WHEN** the offline replay returns no transcript segments
|
||||
- **THEN** Meeting Assistant leaves the transcript and meeting artifacts unchanged
|
||||
- **AND** does not run meeting completion or summarization
|
||||
- **AND** retains the backlog item and WAV for the next retry
|
||||
- **WHEN** a later retry produces a transcript from the WAV
|
||||
- **THEN** Meeting Assistant appends the recovery marker and recovered transcript without replacing earlier content and completes the meeting normally
|
||||
- **AND** removes the backlog item and WAV only after that successful completion
|
||||
|
||||
#### Scenario: Offline status markers or blank speech do not count as successful transcription
|
||||
- **GIVEN** a stopped Azure meeting has an existing live transcript and a durable backlog item referencing its WAV
|
||||
- **WHEN** the offline replay returns only status markers or speech segments with blank text
|
||||
- **THEN** Meeting Assistant preserves the existing transcript and meeting artifacts
|
||||
- **AND** skips meeting completion and summarization
|
||||
- **AND** retains the backlog item and WAV for another retry
|
||||
|
||||
@@ -9,3 +9,17 @@
|
||||
- [x] Run focused tests, full solution tests, and `openspec validate azure-speech-offline-resilience --strict`.
|
||||
- [x] Add a durable offline backlog for stopped Azure meetings across process restarts.
|
||||
- [x] Replay queued WAV files through a fresh speech pipeline and finish transcript metadata, meeting context state, summary generation, and backlog cleanup.
|
||||
|
||||
## Bug-fix addendum: Preserve transcripts during offline replay
|
||||
|
||||
- [x] Specify that replay without speech text preserves the transcript, backlog, and WAV until a successful retry.
|
||||
- [x] Reproduce the data-loss bug with real transcript and backlog files, then reject empty, blank-text, and marker-only replay results before any artifact writes.
|
||||
- [x] Verify retained files survive repeated retries and a later successful replay still completes and cleans up normally.
|
||||
- [x] Run focused regression tests, full solution tests, and strict OpenSpec validation.
|
||||
|
||||
- [x] Preserve existing transcript content on successful replay and append a recovery marker explaining possible duplicates before the recovered text.
|
||||
- [x] Verify the summary agent receives earlier text, the recovery explanation, and appended text together, then rerun the relevant tests and strict validation.
|
||||
|
||||
Verification: the regression cases exercise the backlog processor with real WAV, transcript, meeting-note, and durable JSON files. Each case checks byte-for-byte preservation through two unsuccessful attempts and reloads the backlog from disk. A later usable replay preserves the original body, including a user correction without a trailing newline, and appends the marker and recovered text. A transcript-reading summary stub verifies that both versions, including an intentional duplicate passage, are available with the explanation before backlog/WAV cleanup. All 525 solution tests and strict validation of this change and the accepted transcription spec pass; independent review found no actionable issues. The Azure response is deterministic test input; no live Azure outage was induced and the running workstation service was not restarted.
|
||||
|
||||
Authorized local start (2026-09-25): the Release build containing this fix was subsequently published and started after confirming the workstation instance was idle. Health and startup logs are successful; the full solution now has 552 passing tests. No live Azure outage was induced.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-25
|
||||
@@ -0,0 +1,33 @@
|
||||
## Context
|
||||
|
||||
MicrophoneAudioSource already retries failed capture sources without ending the composite microphone/system-audio stream. WindowsMicrophoneDeviceProvider enumerates active endpoints on demand. Native actionable Windows notifications already use CommunityToolkit.WinUI.Notifications.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Discover newly available microphones during active capture, ask before switching, and replace only the microphone capture lifetime when accepted. Preserve the recording, paused state, transcription session, and system audio.
|
||||
|
||||
**Non-Goals:** Change tray selection semantics, automatically prefer newly connected hardware, introduce a new notification framework, or restart the service.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Poll active endpoints once per second within each microphone capture session. This reuses existing enumeration and works independently of incoming audio chunks. An OS-specific device-event subscription would add another platform lifecycle without improving the current requirement materially.
|
||||
- Establish the initial endpoint IDs as the baseline and offer each newly seen ID at most once per meeting. Declining or ignoring a prompt does not interrupt audio and does not cause repeated prompts.
|
||||
- Keep discovery/prompt handling in a separate monitor with a narrow prompt-service boundary. The microphone source owns replacement of its current capture token; the shared runtime selection is updated only on a still-valid affirmative response.
|
||||
- Bind monitoring and prompt cancellation to the originating capture session. Cancel prompts when their device disappears or the session ends. Prompts expire after one minute and stale actions cannot alter another meeting.
|
||||
- Use the existing native toast toolkit and a neutral no-op prompt implementation. Display the device name with explicit Yes/No actions.
|
||||
- Recheck prompt cancellation after device lookup and at the selection update. Capture sources that own native resources are disposed even when a switch cancels their stream before enumeration starts.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Device discovery can lag by one polling interval. Capture continues during discovery and while waiting for a response.
|
||||
- Hardware can disappear after acceptance. The existing capture-recovery loop handles creation or capture failures and keeps system audio running.
|
||||
- Switching has an unavoidable short microphone gap. Only microphone capture is canceled; the enclosing recording and transcription are retained.
|
||||
- Windows controls actual notification display duration. One-minute expiration controls action validity; it does not guarantee on-screen persistence.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No data migration or new configuration is needed. Verify through deterministic capture sources and a Windows build, with native UI/device verification only when it can avoid interrupting a real meeting.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
A microphone connected during a healthy recording is currently only discovered on later device enumeration or capture recovery. Users need an explicit choice to use a newly connected headset without ending the meeting.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Watch active microphone endpoints while microphone capture is running, including paused transcription.
|
||||
- Offer a native Windows notification naming each newly available microphone with Yes/No actions.
|
||||
- Switch only the microphone capture source when accepted, preserving system audio, transcription, and meeting artifacts.
|
||||
- Ignore declined, expired, disconnected-device, and ended-meeting prompts.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-recording`: prompt for newly available microphones and safely switch the active microphone on acceptance.
|
||||
|
||||
## Impact
|
||||
|
||||
Microphone capture orchestration, Windows notifications, dependency registration, recording documentation, and deterministic capture/notification tests. Reuses the existing Windows notification toolkit; no new UI framework or configuration migration.
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Newly available microphones can be selected during a meeting
|
||||
While microphone capture for a meeting is active, including while transcription is paused, Meeting Assistant SHALL monitor newly available microphone endpoints and show a native Windows notification naming each newly discovered microphone and offering affirmative and negative actions.
|
||||
|
||||
Devices already available when capture starts SHALL NOT trigger a notification. Each device ID SHALL be offered at most once per meeting. Discovery and unanswered prompts SHALL NOT block audio capture.
|
||||
|
||||
An affirmative response SHALL select the named microphone and replace only the active microphone capture source. The meeting, transcript session, artifacts, transcription pause state, and system-audio capture SHALL remain active. The selection SHALL also be used by later recording starts until another runtime selection or application exit.
|
||||
|
||||
A negative response or expiration SHALL leave microphone selection and capture unchanged. Prompts SHALL expire after one minute and SHALL become invalid when the originating capture ends or the target device disappears. A stale response SHALL NOT change a later meeting's microphone selection.
|
||||
|
||||
#### Scenario: Newly connected microphone is offered
|
||||
- **GIVEN** a meeting is recording from an existing microphone
|
||||
- **WHEN** another microphone becomes available
|
||||
- **THEN** a notification names that microphone and offers Yes and No
|
||||
- **AND** audio continues while the notification is unanswered
|
||||
- **AND** repeated observations of the same device do not produce more notifications
|
||||
|
||||
#### Scenario: User accepts the new microphone
|
||||
- **GIVEN** the notification refers to a still-available microphone in the active meeting
|
||||
- **WHEN** the user chooses Yes
|
||||
- **THEN** subsequent microphone audio comes from that microphone
|
||||
- **AND** the existing meeting, system audio, and transcription session continue
|
||||
|
||||
#### Scenario: User declines or ignores the new microphone
|
||||
- **WHEN** the user chooses No or the prompt expires
|
||||
- **THEN** capture and selection remain on the existing microphone
|
||||
|
||||
#### Scenario: Device disappears or meeting ends before acceptance
|
||||
- **GIVEN** a new-microphone prompt is pending
|
||||
- **WHEN** its device disappears or its meeting capture ends
|
||||
- **THEN** the prompt is canceled and later activation cannot switch the microphone
|
||||
|
||||
#### Scenario: Existing devices do not prompt on start
|
||||
- **WHEN** a meeting starts with multiple available microphones
|
||||
- **THEN** the initial device list becomes the discovery baseline without showing notifications
|
||||
|
||||
#### Scenario: Paused transcription still offers a new microphone
|
||||
- **GIVEN** a meeting's transcription is paused while audio capture remains active
|
||||
- **WHEN** a microphone becomes available and the user accepts its notification
|
||||
- **THEN** the microphone source changes while transcription remains paused
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. Discovery and prompt lifetime
|
||||
|
||||
- [x] 1.1 Add behavior tests and a monitor that offers newly available endpoints once per capture session without blocking capture.
|
||||
- [x] 1.2 Test and handle rejection, expiration, disappearance, and ended-session responses safely.
|
||||
|
||||
## 2. Capture switching and native UI
|
||||
|
||||
- [x] 2.1 Test and implement accepted microphone switches within the existing capture stream, retaining disconnect recovery.
|
||||
- [x] 2.2 Add native Windows Yes/No notifications and register the monitor and prompt service.
|
||||
- [x] 2.3 Cover cancellation during confirmation lookup and switching or ending the meeting during capture creation; reject stale confirmation and dispose abandoned capture resources.
|
||||
|
||||
## 3. Verification and documentation
|
||||
|
||||
- [x] 3.1 Document discovery, prompt validity, and active switching.
|
||||
- [x] 3.2 Review the implementation, run focused and full solution tests, build Windows, and validate this change strictly.
|
||||
- [x] 3.3 Verify through the safest available operational surface and record any native hardware/UI verification limits.
|
||||
|
||||
Verification: all 11 focused microphone tests and all 534 solution tests pass. The solution build includes the Windows target and succeeds with the four existing NAudio obsolete-API warnings. Strict OpenSpec validation passes. Independent review identified confirmation-cancellation and capture-ownership races; regression tests reproduced them before the fixes, and the follow-up review confirmed both are resolved.
|
||||
|
||||
The capture behavior tests exercise the public audio stream and shared selection with controlled devices and prompts: acceptance replaces samples in the same stream, while decline, expiry, removal, and ended sessions prevent a switch. The running application's recording-status endpoint was checked read-only and reported idle. A physical microphone connection and native toast activation were not tested: no controllable hardware hotplug was available in this session, and the running service was not updated or restarted. Native hardware/UI verification remains a follow-up before operational acceptance or archival.
|
||||
|
||||
Authorized local start (2026-09-25): the current Release build was subsequently published and started using the documented restart helper after another idle check. Health, idle status, tray creation, and model warm-up are successful. The complete solution now has 552 passing tests. Physical microphone hotplug and toast activation remain unverified.
|
||||
@@ -43,6 +43,10 @@ On Windows, Meeting Assistant SHALL retain the existing NAudio microphone and WA
|
||||
|
||||
On macOS, Meeting Assistant SHALL capture the default microphone through AVFoundation and computer output through ScreenCaptureKit without requiring a virtual audio device.
|
||||
|
||||
The macOS native audio helper SHALL be packaged and launched from a signed application bundle with the stable bundle identifier `cloud.schweigert.meeting-assistant.audio-capture` and a microphone usage description so macOS privacy grants apply to background LaunchAgent capture and persist across deployments.
|
||||
|
||||
The macOS background service SHALL be launched through a signed application bundle with the stable bundle identifier `cloud.schweigert.meeting-assistant` plus microphone and calendar usage descriptions, with the managed runtime nested under that bundle so macOS attributes privacy-sensitive child processes to the Meeting Assistant application identity. The bundle SHALL use a stable designated code requirement for that identifier so routine deployments remain compatible with its macOS privacy grant.
|
||||
|
||||
The macOS capture adapter SHALL convert both native sources to signed 16-bit PCM using the active run's configured sample rate and channel count before passing chunks to the existing managed mixing pipeline.
|
||||
|
||||
The macOS capture adapter SHALL stop its native capture process when the recording capture token is cancelled.
|
||||
@@ -154,6 +158,12 @@ When no runtime microphone override is selected, the checked microphone SHALL be
|
||||
- **AND** captures computer output through ScreenCaptureKit
|
||||
- **AND** passes both signed 16-bit PCM streams through the existing mixer
|
||||
|
||||
#### Scenario: macOS background capture uses a stable privacy identity
|
||||
- **GIVEN** Meeting Assistant runs as a macOS LaunchAgent
|
||||
- **WHEN** it starts the native audio helper
|
||||
- **THEN** it launches the executable from the signed Meeting Assistant audio-capture application bundle
|
||||
- **AND** macOS evaluates Microphone and Screen Recording/System Audio access against the bundle identifier `cloud.schweigert.meeting-assistant.audio-capture`
|
||||
|
||||
#### Scenario: macOS capture uses run audio format
|
||||
- **GIVEN** an active macOS recording configures a sample rate and channel count
|
||||
- **WHEN** the native capture helpers start
|
||||
|
||||
@@ -141,7 +141,11 @@ When Azure Speech transcription fails after meeting audio has been captured, Mee
|
||||
|
||||
When a stopped meeting is persisted to the durable transcription backlog, Meeting Assistant SHALL release the active recording slot so another meeting can be recorded while the stopped meeting waits for Azure Speech to become available.
|
||||
|
||||
When Azure Speech becomes available again, Meeting Assistant SHALL retry durable backlog items, rewrite the transcript from the recorded WAV, run the normal post-transcription meeting completion and summary flow, and remove the backlog item after successful completion.
|
||||
When Azure Speech becomes available again, Meeting Assistant SHALL retry durable backlog items, append the recovered transcript from the recorded WAV without replacing the existing transcript body, run the normal post-transcription meeting completion and summary flow, and remove the backlog item after successful completion.
|
||||
|
||||
Before appending recovered lines, Meeting Assistant SHALL insert a visible recovery marker explaining that the following section was recovered by replaying the entire recording and may duplicate earlier transcript passages. The marker SHALL tell the summarizer to treat repeated passages as the same discussion. The marker and appended lines SHALL be available to the summary agent together with the preserved earlier transcript. Existing transcript text, speaker labels, and user edits SHALL remain unchanged.
|
||||
|
||||
An offline replay that returns no speech segments with non-blank text SHALL be treated as an unsuccessful attempt. This includes empty results, blank speech segments, and results containing only status markers. Meeting Assistant SHALL preserve the existing transcript and meeting artifacts unchanged, SHALL skip meeting completion and summarization, and SHALL retain the durable backlog item and its WAV for a later retry.
|
||||
|
||||
When Meeting Assistant starts, it SHALL preserve WAV files that are referenced by durable transcription backlog items instead of deleting them as stale temporary recordings.
|
||||
|
||||
@@ -193,10 +197,28 @@ When Meeting Assistant starts, it SHALL preserve WAV files that are referenced b
|
||||
#### Scenario: Durable Azure backlog resumes after connectivity returns
|
||||
- **GIVEN** a stopped Azure meeting exists in the durable transcription backlog
|
||||
- **WHEN** Azure Speech can transcribe the recorded WAV
|
||||
- **THEN** Meeting Assistant rewrites the transcript from the recorded WAV
|
||||
- **THEN** Meeting Assistant preserves the existing transcript body and appends a recovery marker followed by the transcript from the recorded WAV
|
||||
- **AND** the marker explains the recovery and possible duplicates to the summary agent
|
||||
- **AND** runs meeting completion and summarization
|
||||
- **AND** removes the durable backlog item and its temporary WAV after successful completion
|
||||
|
||||
#### Scenario: Empty offline replay preserves the live transcript and recovery files
|
||||
- **GIVEN** a stopped Azure meeting has an existing live transcript and a durable backlog item referencing its WAV
|
||||
- **WHEN** the offline replay returns no transcript segments
|
||||
- **THEN** Meeting Assistant leaves the transcript and meeting artifacts unchanged
|
||||
- **AND** does not run meeting completion or summarization
|
||||
- **AND** retains the backlog item and WAV for the next retry
|
||||
- **WHEN** a later retry produces a transcript from the WAV
|
||||
- **THEN** Meeting Assistant appends the recovery marker and recovered transcript without replacing earlier content and completes the meeting normally
|
||||
- **AND** removes the backlog item and WAV only after that successful completion
|
||||
|
||||
#### Scenario: Offline status markers or blank speech do not count as successful transcription
|
||||
- **GIVEN** a stopped Azure meeting has an existing live transcript and a durable backlog item referencing its WAV
|
||||
- **WHEN** the offline replay returns only status markers or speech segments with blank text
|
||||
- **THEN** Meeting Assistant preserves the existing transcript and meeting artifacts
|
||||
- **AND** skips meeting completion and summarization
|
||||
- **AND** retains the backlog item and WAV for another retry
|
||||
|
||||
### Requirement: FunASR can provide speaker-attributed streaming transcription
|
||||
Meeting Assistant SHALL provide a FunASR speech recognition pipeline that streams PCM audio to a configured FunASR WebSocket endpoint.
|
||||
|
||||
@@ -573,3 +595,8 @@ When pyannote secondary validation is enabled, Meeting Assistant SHALL start a n
|
||||
- **WHEN** Meeting Assistant starts
|
||||
- **THEN** it begins preparing the configured pyannote runtime image and model cache without waiting for the first validation request
|
||||
- **AND** application startup is not blocked by the warm-up task
|
||||
|
||||
#### Scenario: Overlapping shutdown requests stop startup warm-up safely
|
||||
- **GIVEN** the pyannote startup warm-up hosted service has started
|
||||
- **WHEN** application shutdown calls its stop lifecycle concurrently
|
||||
- **THEN** it cancels pending warm-up and completes cleanup without a disposed-resource exception
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#:property PublishAot=false
|
||||
#:property UseAppHost=false
|
||||
#:property AssemblyName=WineSdkTrust
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
if (args.Length != 3 || (args[0] != "--validate" && args[0] != "--import"))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: WineSdkTrust <--validate|--import> <codesignctl.pem> <timestampctl.pem>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var import = args[0] == "--import";
|
||||
if (import && (!OperatingSystem.IsWindows() || !IsWine()))
|
||||
{
|
||||
Console.Error.WriteLine("REFUSED: --import requires Windows under Wine (ntdll.dll!wine_get_version). No certificate store was opened.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
|
||||
Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
|
||||
Console.WriteLine($"Mode: {args[0]}");
|
||||
var certificates = new X509Certificate2Collection();
|
||||
try
|
||||
{
|
||||
foreach (var path in args.Skip(1))
|
||||
{
|
||||
if (!File.Exists(path) || new FileInfo(path).Length == 0)
|
||||
{
|
||||
throw new InvalidDataException($"The SDK certificate bundle is missing or empty: {path}");
|
||||
}
|
||||
|
||||
var bundle = new X509Certificate2Collection();
|
||||
try
|
||||
{
|
||||
bundle.ImportFromPemFile(path);
|
||||
if (bundle.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException($"The SDK bundle contains no PEM certificates: {path}");
|
||||
}
|
||||
|
||||
var legacyRoots = 0;
|
||||
foreach (var certificate in bundle)
|
||||
{
|
||||
if (certificate.HasPrivateKey)
|
||||
{
|
||||
throw new InvalidDataException($"The SDK bundle must contain public certificates only: {certificate.Thumbprint}");
|
||||
}
|
||||
|
||||
var constraints = certificate.Extensions.OfType<X509BasicConstraintsExtension>().SingleOrDefault();
|
||||
if (constraints is { CertificateAuthority: false })
|
||||
{
|
||||
throw new InvalidDataException($"The SDK bundle contains a non-CA certificate: {certificate.Thumbprint}");
|
||||
}
|
||||
if (constraints is null)
|
||||
{
|
||||
// Microsoft also ships historical roots without the BasicConstraints extension.
|
||||
if (!certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData))
|
||||
{
|
||||
throw new InvalidDataException($"A certificate without CA constraints is not self-issued: {certificate.Thumbprint}");
|
||||
}
|
||||
legacyRoots++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Bundle: {Path.GetFullPath(path)}");
|
||||
Console.WriteLine($" SHA256: {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))}");
|
||||
Console.WriteLine($" Certificates: {bundle.Count}; historical self-issued roots without BasicConstraints: {legacyRoots}");
|
||||
certificates.AddRange(bundle);
|
||||
bundle.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var certificate in bundle)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var thumbprints = certificates.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Console.WriteLine($"Unique SDK certificate thumbprints: {thumbprints.Count}");
|
||||
if (!import)
|
||||
{
|
||||
Console.WriteLine("PASS: both SDK bundles validated; no certificate store was opened.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
|
||||
{
|
||||
store.Open(OpenFlags.ReadWrite);
|
||||
store.AddRange(certificates);
|
||||
}
|
||||
|
||||
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var installed = store.Certificates;
|
||||
try
|
||||
{
|
||||
var installedThumbprints = installed.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missing = thumbprints.Except(installedThumbprints).ToArray();
|
||||
if (missing.Length != 0)
|
||||
{
|
||||
throw new CryptographicException($"SDK certificates missing after import: {string.Join(", ", missing)}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var certificate in installed)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"PASS: all {thumbprints.Count} SDK certificate thumbprints verified in CurrentUser Root.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"FAIL: {exception.GetType().Name}: {exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var certificate in certificates)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsWine()
|
||||
{
|
||||
if (!NativeLibrary.TryLoad("ntdll.dll", out var library))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return NativeLibrary.TryGetExport(library, "wine_get_version", out _);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeLibrary.Free(library);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
|
||||
// .NET 10 file-based CI diagnostic. See docs/macos-native-diagnostic.md.
|
||||
return await NativeDiagnostic.Execute(args);
|
||||
|
||||
static class NativeDiagnostic
|
||||
{
|
||||
const string DockurCommit = "16a5b470cdd601bae8b05b02d748d7edfb36c12e";
|
||||
const string OwnerLabel = "org.meeting-assistant.native-diagnostic";
|
||||
const long GuestDiskBytes = 64L * 1024 * 1024 * 1024;
|
||||
const long ContainerMemoryBytes = 6L * 1024 * 1024 * 1024;
|
||||
const int MaximumCapturedCharacters = 8 * 1024 * 1024;
|
||||
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
||||
const string OriginalBootstrap = "[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m\n";
|
||||
const string MountOnlyBootstrap = "[ ! -e /tmp/m ]&& /sbin/mount_9p installstate >/dev/null 2>&1; : >/tmp/m\n";
|
||||
static readonly string OriginalDaemon = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
\t<key>Label</key>
|
||||
\t<string>com.apple.recoveryosd</string>
|
||||
\t<key>OnDemand</key>
|
||||
\t<false/>
|
||||
\t<key>ProcessType</key>
|
||||
\t<string>App</string>
|
||||
\t<key>EnablePressuredExit</key>
|
||||
\t<false/>
|
||||
\t<key>ProgramArguments</key>
|
||||
\t<array>
|
||||
\t\t<string>/usr/libexec/recoveryosd</string>
|
||||
\t</array>
|
||||
</dict>
|
||||
</plist>
|
||||
""".Replace("\\t", "\t", StringComparison.Ordinal);
|
||||
static readonly string DiagnosticDaemon = (OriginalDaemon + "\n")
|
||||
.Replace("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n", "", StringComparison.Ordinal)
|
||||
.Replace("\t\t<string>/usr/libexec/recoveryosd</string>", "\t\t<string>/bin/bash</string>\n\t\t<string>/Volumes/installstate/launch.sh</string>", StringComparison.Ordinal);
|
||||
|
||||
public static async Task<int> Execute(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Contains("--help"))
|
||||
{
|
||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run|--cleanup|--validate [--output artifacts/native-macos] [--source existing-dockur-clone]");
|
||||
return 0;
|
||||
}
|
||||
var output = Path.GetFullPath(Option(args, "--output") ?? "artifacts/native-macos");
|
||||
if (args.Contains("--validate"))
|
||||
{
|
||||
ValidateContracts();
|
||||
if (Option(args, "--source") is { } source)
|
||||
await PrepareSource(Path.GetFullPath(source), output, "validation", false, CancellationToken.None);
|
||||
Console.WriteLine("Source patch contracts and diagnostic result validation passed; no Docker or guest execution occurred.");
|
||||
return 0;
|
||||
}
|
||||
if (args.Contains("--cleanup"))
|
||||
return await Cleanup(output) ? 0 : 1;
|
||||
if (!args.Contains("--run")) throw new ArgumentException("Choose --run, --cleanup or --validate.");
|
||||
Directory.CreateDirectory(output);
|
||||
var statePath = Path.Combine(output, "owned-resources.json");
|
||||
if (File.Exists(statePath)) throw new InvalidOperationException("Output already contains a run identity; choose a fresh directory or clean up its run first.");
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var work = Path.Combine(Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), "meeting-assistant-native-" + token);
|
||||
var state = new OwnedResources(token, "meeting-assistant-native-" + token, "meeting-assistant-native-diagnostic:" + token, work);
|
||||
Save(statePath, state);
|
||||
Directory.CreateDirectory(work);
|
||||
File.WriteAllText(Path.Combine(work, "run.owner"), token);
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(40));
|
||||
using var signal = OperatingSystem.IsLinux() ? PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); }) : null;
|
||||
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
var outcome = "failed";
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
throw new InvalidOperationException("This diagnostic runs on the existing Linux/x64 runner only.");
|
||||
ValidateContracts();
|
||||
var sourceCommit = (await Command("git", ["rev-parse", "HEAD"], output, "candidate-commit", deadline.Token)).Output.Trim();
|
||||
Save(Path.Combine(output, "run-metadata.json"), new { token, startedUtc = DateTimeOffset.UtcNow, sourceCommit, dockurCommit = DockurCommit, runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"), server = Environment.GetEnvironmentVariable("GITHUB_SERVER_URL"), architecture = RuntimeInformation.ProcessArchitecture.ToString(), deadlineMinutes = 40 });
|
||||
var info = await Command("docker", ["info", "--format", "{{json .}}"], output, "docker-info", deadline.Token);
|
||||
using (var document = JsonDocument.Parse(info.Output))
|
||||
{
|
||||
var data = document.RootElement;
|
||||
if (data.GetProperty("OSType").GetString() != "linux" || data.GetProperty("Architecture").GetString() is not ("x86_64" or "amd64"))
|
||||
throw new InvalidOperationException("The existing Docker daemon is not Linux/x64; this diagnostic does not reconfigure it.");
|
||||
if (data.GetProperty("NCPU").GetInt32() < 2 || data.GetProperty("MemTotal").GetInt64() < ContainerMemoryBytes)
|
||||
throw new InvalidOperationException("Existing Docker resources cannot fit this bounded 2-CPU/6-GiB diagnostic; no infrastructure change was requested.");
|
||||
}
|
||||
await Command("sh", ["-c", "cat /proc/meminfo; printf '\n[cgroup]\n'; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true; printf '\n[workspace disk]\n'; df -Pk ."], output, "runner-resources", deadline.Token);
|
||||
var available = System.Text.RegularExpressions.Regex.Match(File.ReadAllText("/proc/meminfo"), @"(?m)^MemAvailable:\s+(\d+) kB$");
|
||||
if (!available.Success || long.Parse(available.Groups[1].Value) < 5L * 1024 * 1024)
|
||||
throw new InvalidOperationException("Existing runner memory has less than the 5-GiB available diagnostic budget; no infrastructure change was requested.");
|
||||
var source = Path.Combine(work, "dockur");
|
||||
await Command("git", ["clone", "--no-checkout", "https://github.com/dockur/macos.git", source], output, "dockur-clone", deadline.Token);
|
||||
await Command("git", ["-C", source, "checkout", "--detach", DockurCommit], output, "dockur-checkout", deadline.Token);
|
||||
var actualCommit = (await Command("git", ["-C", source, "rev-parse", "HEAD"], output, "dockur-commit", deadline.Token)).Output.Trim();
|
||||
if (actualCommit != DockurCommit) throw new InvalidOperationException("Dockur source pin mismatch.");
|
||||
await PrepareSource(source, output, token, true, deadline.Token);
|
||||
await Command("docker", ["build", "--platform", "linux/amd64", "--label", OwnerLabel + "=" + token, "--tag", state.ImageTag, source], output, "docker-build", deadline.Token, echo: true);
|
||||
var imageInspect = await Command("docker", ["image", "inspect", state.ImageTag], output, "image-inspect", deadline.Token);
|
||||
using (var image = JsonDocument.Parse(imageInspect.Output))
|
||||
state = state with { ImageId = image.RootElement[0].GetProperty("Id").GetString() };
|
||||
Save(statePath, state);
|
||||
var create = await Command("docker", ["create", "--name", state.ContainerName, "--label", OwnerLabel + "=" + token, "--memory", "6g", "--memory-swap", "6g", "--cpus", "2", "--shm-size", "512m", "--log-opt", "max-size=8m", "--log-opt", "max-file=1", "--env", "KVM=N", "--env", "NETWORK=slirp", "--env", "DISPLAY=web", "--env", "MANUAL=N", "--env", "VERSION=14", "--env", "RAM_SIZE=4G", "--env", "CPU_CORES=2", "--env", "DISK_SIZE=64G", "--env", "DISK_TYPE=sata", "--env", "ARGUMENTS=-object iothread,id=io2", state.ImageTag], output, "docker-create", deadline.Token);
|
||||
var id = create.Output.Trim();
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$")) throw new InvalidOperationException("Docker did not return a container identity.");
|
||||
state = state with { ContainerId = id };
|
||||
Save(statePath, state);
|
||||
await Command("docker", ["inspect", id], output, "container-created", deadline.Token);
|
||||
AssertContainer(File.ReadAllText(Path.Combine(output, "container-created.stdout.log")), token);
|
||||
await Command("docker", ["start", id], output, "docker-start", deadline.Token);
|
||||
Console.WriteLine("The owned unprivileged TCG guest is starting. Success requires native macOS 14+/x86_64 and a writable 64-GiB disk; no installer will run.");
|
||||
var recoveryStarted = Stopwatch.StartNew();
|
||||
var heartbeat = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
deadline.Token.ThrowIfCancellationRequested();
|
||||
await CaptureGuest(id, output, deadline.Token);
|
||||
var resultPath = Path.Combine(output, "guest-result.json");
|
||||
if (File.Exists(resultPath))
|
||||
{
|
||||
var result = File.ReadAllText(resultPath);
|
||||
ValidateResult(result, token);
|
||||
Console.WriteLine("Native Recovery readiness passed. This run has not installed macOS, .NET, CLT, or run Meeting Assistant tests.");
|
||||
outcome = "readiness-passed";
|
||||
break;
|
||||
}
|
||||
var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token);
|
||||
if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result.");
|
||||
if (heartbeat.Elapsed >= TimeSpan.FromSeconds(60))
|
||||
{
|
||||
Console.WriteLine($"[native-diagnostic] phase=recovery; elapsed={recoveryStarted.Elapsed.TotalMinutes:F1} minutes; container=running; readiness=pending");
|
||||
heartbeat.Restart();
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = exception is OperationCanceledException ? "The explicit 40-minute diagnostic deadline or cancellation was reached." : exception.Message;
|
||||
Console.Error.WriteLine(error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
using var captureDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
try { await CaptureGuest(state.ContainerId ?? state.ContainerName, output, captureDeadline.Token, true, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Final evidence capture: " + exception.Message); }
|
||||
try { PrintGuestProof(output, state.Token); } catch (Exception exception) { Console.Error.WriteLine("Native proof output: " + exception.Message); }
|
||||
var clean = await Cleanup(output);
|
||||
if (!clean) { outcome = "failed"; error = (error ?? "") + " Owned-resource cleanup failed; inspect cleanup evidence."; }
|
||||
Save(Path.Combine(output, "outcome.json"), new { token, outcome, error, completedUtc = DateTimeOffset.UtcNow });
|
||||
}
|
||||
return outcome == "readiness-passed" ? 0 : 1;
|
||||
}
|
||||
|
||||
static string? Option(string[] args, string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index < 0 ? null : index + 1 < args.Length ? args[index + 1] : throw new ArgumentException("Missing value for " + name);
|
||||
}
|
||||
|
||||
static void ValidateContracts()
|
||||
{
|
||||
XDocument.Parse(DiagnosticDaemon);
|
||||
if (Encoding.UTF8.GetByteCount(DiagnosticDaemon) > Encoding.UTF8.GetByteCount(OriginalDaemon + "\n")) throw new InvalidOperationException("Daemon replacement exceeds original file.");
|
||||
var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "14.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 });
|
||||
ValidateResult(good, "validation");
|
||||
foreach (var invalid in new[] { good.Replace("14.6.1", "13.6.1"), good.Replace("x86_64", "arm64"), good.Replace("\"readOnly\":false", "\"readOnly\":true"), good.Replace("\"success\":true", "\"success\":false"), good.Replace("68719476736", "17179869184"), good.Replace("validation", "stale") })
|
||||
{
|
||||
try { ValidateResult(invalid, "validation"); } catch (InvalidOperationException) { continue; }
|
||||
throw new InvalidOperationException("Diagnostic validator accepted an invalid/stale result.");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task PrepareSource(string source, string output, string token, bool writeSource, CancellationToken cancellation)
|
||||
{
|
||||
Directory.CreateDirectory(output);
|
||||
var patchPath = Path.Combine(source, "src/install/recovery/patch.py");
|
||||
var originalPatch = File.ReadAllText(patchPath);
|
||||
if (Hash(Encoding.UTF8.GetBytes(originalPatch)) != "84f13db88c02edbf5ce21a39571fe58f12bebf5b0886c2d012f16ddbaed45323") throw new InvalidOperationException("Pinned Recovery patcher hash mismatch.");
|
||||
var patch = ReplaceOnce(originalPatch, OriginalBootstrap, MountOnlyBootstrap);
|
||||
var oldConstants = "RECOVERY_ORIGINAL = b\"/usr/libexec/recoveryosd\"\nRECOVERY_REPLACEMENT = b\"/private/etc/rc.cdrom.sh\"";
|
||||
var daemon = OriginalDaemon + "\n";
|
||||
var constants = "RECOVERY_ORIGINAL = b'''" + daemon + "'''\nRECOVERY_REPLACEMENT = b'''" + DiagnosticDaemon + "'''.ljust(len(RECOVERY_ORIGINAL), b\" \")";
|
||||
patch = ReplaceOnce(patch, oldConstants, constants);
|
||||
var dockerPath = Path.Combine(source, "Dockerfile");
|
||||
// The existing runner's BuildKit cannot checksum dangling manpage links during COPY /.
|
||||
// This pinned filesystem image has an empty Config; FROM preserves the same runtime defaults.
|
||||
var dockerfile = ReplaceOnce(File.ReadAllText(dockerPath), "FROM scratch AS base\nCOPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / /\n", "FROM qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df AS base\n");
|
||||
dockerfile = ReplaceAllExact(dockerfile, "--from=qemux/qemu-macos:latest ", "--from=qemux/qemu-macos:latest@sha256:af64297171228f27d5f616249e18f6ad5e2fbc79c1cc517252521e8bcd8eadaa ", 2);
|
||||
var entryPath = Path.Combine(source, "src/entry.sh");
|
||||
var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n");
|
||||
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
||||
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
||||
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", hook), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) })
|
||||
File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false));
|
||||
Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
||||
await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation);
|
||||
if (!writeSource) return;
|
||||
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
||||
File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false));
|
||||
File.WriteAllText(entryPath, entry, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), hook, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
||||
static string ReplaceAllExact(string text, string oldValue, string newValue, int expected)
|
||||
{
|
||||
var count = text.Split(oldValue, StringSplitOptions.None).Length - 1;
|
||||
if (count != expected) throw new InvalidOperationException($"Pinned source contract expected {expected} match(es), found {count}: {oldValue.Split('\n')[0]}");
|
||||
return text.Replace(oldValue, newValue, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
static void ValidateResult(string json, string token)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var result = document.RootElement;
|
||||
if (result.GetProperty("token").GetString() != token || !result.GetProperty("success").GetBoolean() || !Version.TryParse(result.GetProperty("osVersion").GetString(), out var version) || version.Major < 14 || result.GetProperty("architecture").GetString() != "x86_64" || result.GetProperty("uid").GetInt32() != 0 || !System.Text.RegularExpressions.Regex.IsMatch(result.GetProperty("disk").GetString() ?? "", "^/dev/disk[0-9]+$") || result.GetProperty("diskBytes").GetInt64() != GuestDiskBytes || result.GetProperty("readOnly").GetBoolean() || new[] { "systemExit", "diskArbitrationExit", "recoveryExit", "diskListExit" }.Any(key => result.GetProperty(key).GetInt32() != 0))
|
||||
throw new InvalidOperationException("The fresh guest receipt did not prove native macOS 14+/x86_64, service readiness and the writable 64-GiB disk.");
|
||||
}
|
||||
|
||||
static void AssertContainer(string json, string token)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var container = document.RootElement[0];
|
||||
var config = container.GetProperty("HostConfig");
|
||||
if (container.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != token || config.GetProperty("Privileged").GetBoolean() || config.GetProperty("NetworkMode").GetString() != "default" && config.GetProperty("NetworkMode").GetString() != "bridge" || config.GetProperty("Memory").GetInt64() != ContainerMemoryBytes || new[] { "CapAdd", "Devices", "DeviceRequests", "Binds", "PortBindings" }.Any(key => config.TryGetProperty(key, out var value) && value.ValueKind != JsonValueKind.Null && (value.ValueKind == JsonValueKind.Array ? value.GetArrayLength() != 0 : value.EnumerateObject().Any())))
|
||||
throw new InvalidOperationException("Created container exceeds the owned/unprivileged diagnostic boundary.");
|
||||
}
|
||||
|
||||
static async Task CaptureGuest(string id, string output, CancellationToken cancellation, bool final = false, string? token = null)
|
||||
{
|
||||
if (final && token is not null) await CaptureMonitor(id, output, token, cancellation);
|
||||
var logs = await Command("docker", ["logs", "--tail", "3000", id], output, "container", cancellation, requireSuccess: false);
|
||||
foreach (var file in new[] { ("proof.log", "guest-proof.log"), ("result.json", "guest-result.json") })
|
||||
{
|
||||
var result = await Command("docker", ["exec", id, "cat", "/dev/shm/installstate/" + file.Item1], output, "capture-" + file.Item1, cancellation, requireSuccess: false);
|
||||
if (result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Output)) File.WriteAllText(Path.Combine(output, file.Item2), result.Output);
|
||||
}
|
||||
await Command("docker", ["exec", id, "sh", "-c", "printf '[qemu]\n'; qemu-system-x86_64 --version | head -n 1; printf '[Recovery hash]\n'; test ! -f /storage/14/setup.dmg || sha256sum /storage/14/setup.dmg; printf '[resources]\n'; df -Pk /storage; cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 2>/dev/null || true"], output, "guest-container-resources", cancellation, requireSuccess: false);
|
||||
}
|
||||
|
||||
static async Task CaptureMonitor(string id, string output, string token, CancellationToken cancellation)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
int? monitorExit = null, copyExit = null;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(id, "^[0-9a-f]{64}$") || !System.Text.RegularExpressions.Regex.IsMatch(token, "^[0-9a-f]{32}$")) throw new InvalidOperationException("No saved owned container identity for the optional monitor capture.");
|
||||
var inspection = await Command("docker", ["inspect", id], output, "capture-monitor-container", deadline.Token);
|
||||
AssertContainer(inspection.Output, token);
|
||||
using (var document = JsonDocument.Parse(inspection.Output))
|
||||
if (document.RootElement[0].GetProperty("Id").GetString() != id || !document.RootElement[0].GetProperty("State").GetProperty("Running").GetBoolean()) throw new InvalidOperationException("Owned guest container is no longer running for the optional monitor capture.");
|
||||
var screen = "/dev/shm/native-diagnostic-screen-" + token + ".ppm";
|
||||
var monitor = await Command("docker", ["exec", id, "sh", "-c", """
|
||||
test -S /run/shm/monitor.sock || exit 1
|
||||
rm -f -- "$1" || exit 1
|
||||
printf 'info status\nscreendump %s\n' "$1" | /usr/bin/timeout -s KILL 5 /usr/bin/nc.openbsd -q 1 -w 2 -U /run/shm/monitor.sock
|
||||
monitor_exit=$?
|
||||
printf '\n[monitor-exit] %s\n' "$monitor_exit"
|
||||
[ "$monitor_exit" -eq 0 ] || exit "$monitor_exit"
|
||||
bytes=$(stat -c%s "$1") || exit 1
|
||||
[ "$bytes" -gt 0 ] && [ "$bytes" -le 8388608 ] || exit 1
|
||||
printf '[screen-bytes] %s\n' "$bytes"
|
||||
""", "native-monitor", screen], output, "capture-monitor", deadline.Token, requireSuccess: false);
|
||||
monitorExit = monitor.ExitCode;
|
||||
if (monitorExit != 0) throw new InvalidOperationException("Optional monitor status/screenshot command exited " + monitorExit + ".");
|
||||
var copy = await Command("docker", ["cp", id + ":" + screen, Path.Combine(output, "guest-screen-" + token + ".ppm")], output, "capture-monitor-screen", deadline.Token, requireSuccess: false);
|
||||
copyExit = copy.ExitCode;
|
||||
if (copyExit != 0) throw new InvalidOperationException("Optional monitor screenshot copy exited " + copyExit + ".");
|
||||
}
|
||||
catch (Exception exception) { error = exception.Message; Console.Error.WriteLine("Optional final monitor capture: " + error); }
|
||||
finally { Save(Path.Combine(output, "monitor-capture.json"), new { token, monitorExit, copyExit, success = error is null, error, capturedUtc = DateTimeOffset.UtcNow }); }
|
||||
}
|
||||
|
||||
static async Task<bool> Cleanup(string output)
|
||||
{
|
||||
var path = Path.Combine(output, "owned-resources.json");
|
||||
if (!File.Exists(path)) return true;
|
||||
var state = JsonSerializer.Deserialize<OwnedResources>(File.ReadAllText(path), JsonOptions) ?? throw new InvalidOperationException("Invalid owned-resource receipt.");
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(state.Token, "^[0-9a-f]{32}$") || state.ContainerName != "meeting-assistant-native-" + state.Token || state.ImageTag != "meeting-assistant-native-diagnostic:" + state.Token) throw new InvalidOperationException("Invalid cleanup ownership identity.");
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||
try
|
||||
{
|
||||
foreach (var kind in new[] { "container", "image" })
|
||||
{
|
||||
var name = kind == "container" ? state.ContainerName : state.ImageTag;
|
||||
var inspect = await Command("docker", [kind, "inspect", name], output, "cleanup-" + kind + "-inspect", deadline.Token, requireSuccess: false);
|
||||
if (inspect.ExitCode != 0)
|
||||
{
|
||||
if (inspect.Error.Contains("No such object", StringComparison.Ordinal) || inspect.Error.Contains("No such container", StringComparison.Ordinal) || inspect.Error.Contains("No such image", StringComparison.Ordinal)) continue;
|
||||
throw new InvalidOperationException("Cannot establish owned " + kind + " absence: " + inspect.Error);
|
||||
}
|
||||
using var document = JsonDocument.Parse(inspect.Output);
|
||||
var resource = document.RootElement[0];
|
||||
if (resource.GetProperty("Config").GetProperty("Labels").GetProperty(OwnerLabel).GetString() != state.Token) throw new InvalidOperationException("Cleanup refuses a resource without this run's exact ownership label.");
|
||||
var id = resource.GetProperty("Id").GetString()!;
|
||||
var expectedId = kind == "container" ? state.ContainerId : state.ImageId;
|
||||
if (expectedId is not null && expectedId != id) throw new InvalidOperationException("Cleanup refuses a resource whose ID changed after creation.");
|
||||
await Command("docker", kind == "container" ? ["rm", "--force", "--volumes", id] : ["image", "rm", id], output, "cleanup-" + kind + "-remove", deadline.Token);
|
||||
}
|
||||
if (Path.GetFileName(state.WorkDirectory) == "meeting-assistant-native-" + state.Token && File.Exists(Path.Combine(state.WorkDirectory, "run.owner")) && File.ReadAllText(Path.Combine(state.WorkDirectory, "run.owner")) == state.Token) Directory.Delete(state.WorkDirectory, true);
|
||||
Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = true, completedUtc = DateTimeOffset.UtcNow });
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Save(Path.Combine(output, "cleanup.json"), new { state.Token, success = false, error = exception.Message, completedUtc = DateTimeOffset.UtcNow });
|
||||
Console.Error.WriteLine("Owned diagnostic cleanup failed: " + exception.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<CommandResult> Command(string executable, string[] arguments, string output, string label, CancellationToken cancellation, bool requireSuccess = true, bool echo = false)
|
||||
{
|
||||
if (!label.StartsWith("capture-", StringComparison.Ordinal) && label is not "container" and not "container-running" and not "guest-container-resources")
|
||||
Console.WriteLine("[native-diagnostic] " + label);
|
||||
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var commandToken = commandCancellation.Token;
|
||||
var start = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
start.Environment["GIT_TERMINAL_PROMPT"] = "0";
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start " + executable);
|
||||
async Task<string> Read(StreamReader reader, string stream)
|
||||
{
|
||||
var captured = new StringBuilder();
|
||||
var buffer = new char[8192];
|
||||
using var log = new StreamWriter(Path.Combine(output, label + "." + stream + ".log"), false, new UTF8Encoding(false));
|
||||
while (true)
|
||||
{
|
||||
var count = await reader.ReadAsync(buffer.AsMemory(), commandToken);
|
||||
if (count == 0) break;
|
||||
if (captured.Length + count > MaximumCapturedCharacters)
|
||||
{
|
||||
commandCancellation.Cancel();
|
||||
throw new InvalidOperationException(label + " exceeded its bounded diagnostic log size.");
|
||||
}
|
||||
captured.Append(buffer, 0, count);
|
||||
await log.WriteAsync(buffer.AsMemory(0, count), commandToken);
|
||||
await log.FlushAsync(commandToken);
|
||||
if (echo) Console.Write(new string(buffer, 0, count));
|
||||
}
|
||||
return captured.ToString();
|
||||
}
|
||||
var stdout = Read(process.StandardOutput, "stdout");
|
||||
var stderr = Read(process.StandardError, "stderr");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(commandToken));
|
||||
var result = new CommandResult(process.ExitCode, await stdout, await stderr);
|
||||
if (requireSuccess && result.ExitCode != 0) throw new InvalidOperationException($"{label} exited {result.ExitCode}: {result.Error[..Math.Min(result.Error.Length, 1500)]}");
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
|
||||
static void PrintGuestProof(string output, string token)
|
||||
{
|
||||
var path = Path.Combine(output, "guest-proof.log");
|
||||
if (!File.Exists(path)) { Console.WriteLine("[native-diagnostic] No native guest proof was captured."); return; }
|
||||
var proof = File.ReadAllText(path).Replace(token, "<run-id>", StringComparison.Ordinal);
|
||||
const int budget = 512 * 1024;
|
||||
if (proof.Length > budget)
|
||||
proof = proof[..(64 * 1024)] + "\n[native-diagnostic] Middle of proof omitted from CI stdout; complete bounded proof is retained in the artifact.\n" + proof[^((budget - 64 * 1024))..];
|
||||
Console.WriteLine("[native-diagnostic] Final native guest proof:");
|
||||
Console.Write(proof);
|
||||
}
|
||||
static void Save(string path, object value)
|
||||
{
|
||||
var temporary = path + ".tmp";
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
sealed record OwnedResources(string Token, string ContainerName, string ImageTag, string WorkDirectory, string? ContainerId = null, string? ImageId = null);
|
||||
sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/bin/bash
|
||||
# Existing macOS Recovery/launchd runtime hook; never installs or erases anything.
|
||||
set -u
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
PROOF_LOG="$STATE_DIR/proof.log"
|
||||
RESULT="$STATE_DIR/result.json"
|
||||
EXPECTED_BYTES=68719476736
|
||||
MAX_LOG_BYTES=4194304
|
||||
os_version=""
|
||||
architecture=""
|
||||
uid=-1
|
||||
system_exit=-1
|
||||
arbitration_exit=-1
|
||||
recovery_exit=-1
|
||||
disk_list_exit=-1
|
||||
selected_disk=""
|
||||
disk_bytes=0
|
||||
|
||||
count=0
|
||||
while [ ! -d "$STATE_DIR" ] && (( count < 120 )); do
|
||||
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
[ -d "$STATE_DIR" ] || exit 1
|
||||
: > "$PROOF_LOG" || exit 1
|
||||
rm -f "$RESULT" "$RESULT.tmp"
|
||||
printf '[proof-token] %s\n' "$PROOF_TOKEN" >> "$PROOF_LOG"
|
||||
|
||||
finish() {
|
||||
local success="$1" reason="$2"
|
||||
printf '[proof-result] %s: %s\n' "$success" "$reason" >> "$PROOF_LOG"
|
||||
printf '{"token":"%s","success":%s,"reason":"%s","osVersion":"%s","architecture":"%s","uid":%s,"systemExit":%s,"diskArbitrationExit":%s,"recoveryExit":%s,"diskListExit":%s,"disk":"%s","diskBytes":%s,"readOnly":false}\n' \
|
||||
"$PROOF_TOKEN" "$success" "$reason" "$os_version" "$architecture" "$uid" \
|
||||
"$system_exit" "$arbitration_exit" "$recovery_exit" "$disk_list_exit" \
|
||||
"$selected_disk" "$disk_bytes" > "$RESULT.tmp"
|
||||
/bin/mv -f "$RESULT.tmp" "$RESULT" || exit 1
|
||||
# Keep the service alive for the bounded host diagnostic to capture evidence.
|
||||
while :; do sleep 60; done
|
||||
}
|
||||
|
||||
run_command() {
|
||||
local name="$1"
|
||||
shift
|
||||
local process timer sleeper exit_code started observer=""
|
||||
LAST_OUTPUT="/tmp/native-diagnostic-$name.out"
|
||||
printf '\n[proof-command] %s:' "$name" >> "$PROOF_LOG"
|
||||
printf ' %s' "$@" >> "$PROOF_LOG"
|
||||
printf '\n' >> "$PROOF_LOG"
|
||||
started=$SECONDS
|
||||
"$@" > "$LAST_OUTPUT" 2>&1 &
|
||||
process=$!
|
||||
printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >> "$PROOF_LOG"
|
||||
(
|
||||
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||
sleep 45 &
|
||||
sleeper=$!
|
||||
wait "$sleeper"
|
||||
printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG"
|
||||
kill -TERM "$process" 2>/dev/null || :
|
||||
sleep 2 & sleeper=$!; wait "$sleeper"
|
||||
kill -KILL "$process" 2>/dev/null || :
|
||||
) &
|
||||
timer=$!
|
||||
if [[ "$name" = platform || "$name" = platform-warm ]]; then
|
||||
# Observers never extend the independent 45-second command deadline.
|
||||
(
|
||||
local sample_pid="" sample_timer="" pause_pid="" pause sample_exit
|
||||
trap 'kill -KILL "$sample_pid" 2>/dev/null || :; kill -TERM "$sample_timer" "$pause_pid" 2>/dev/null || :; exit 0' TERM INT
|
||||
for pause in 10 15; do
|
||||
sleep "$pause" & pause_pid=$!; wait "$pause_pid"
|
||||
printf '[proof-process] %s child=%s elapsed=%ss fields=pid,ppid,stat,cpu-time,elapsed,cpu-percent,wchan,comm\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG"
|
||||
/bin/ps -p "$process" -o pid=,ppid=,stat=,time=,etime=,pcpu=,wchan=,comm= >> "$PROOF_LOG" 2>&1 &
|
||||
sample_pid=$!
|
||||
(
|
||||
local sample_sleeper=""
|
||||
trap 'kill "$sample_sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||
sleep 5 & sample_sleeper=$!; wait "$sample_sleeper"
|
||||
kill -KILL "$sample_pid" 2>/dev/null || :
|
||||
) & sample_timer=$!
|
||||
wait "$sample_pid"; sample_exit=$?
|
||||
kill -TERM "$sample_timer" 2>/dev/null || :; wait "$sample_timer" 2>/dev/null || :
|
||||
printf '[proof-process-exit] %s %s\n' "$name" "$sample_exit" >> "$PROOF_LOG"
|
||||
sample_pid=""; sample_timer=""; pause_pid=""
|
||||
done
|
||||
) & observer=$!
|
||||
fi
|
||||
wait "$process"
|
||||
exit_code=$?
|
||||
kill -TERM "$timer" 2>/dev/null || :
|
||||
wait "$timer" 2>/dev/null || :
|
||||
if [ -n "$observer" ]; then
|
||||
kill -TERM "$observer" 2>/dev/null || :
|
||||
wait "$observer" 2>/dev/null || :
|
||||
fi
|
||||
/usr/bin/tail -c 524288 "$LAST_OUTPUT" >> "$PROOF_LOG"
|
||||
printf '\n[proof-duration] %s child=%s elapsed=%ss\n' "$name" "$process" "$((SECONDS - started))" >> "$PROOF_LOG"
|
||||
printf '\n[proof-exit] %s\n' "$exit_code" >> "$PROOF_LOG"
|
||||
LAST_EXIT="$exit_code"
|
||||
local size
|
||||
size=$(/usr/bin/stat -f '%z' "$PROOF_LOG" 2>/dev/null || printf '0')
|
||||
(( size <= MAX_LOG_BYTES )) || finish false diagnostic_log_budget_exceeded
|
||||
return 0
|
||||
}
|
||||
|
||||
# Collect cheap native identity/context before the first framework-dependent probe.
|
||||
run_command kernel /usr/bin/uname -a
|
||||
(( LAST_EXIT == 0 )) || finish false uname_failed
|
||||
run_command architecture /usr/bin/uname -m
|
||||
(( LAST_EXIT == 0 )) || finish false architecture_probe_failed
|
||||
architecture=$(cat "$LAST_OUTPUT")
|
||||
[ "$architecture" = x86_64 ] || finish false unexpected_guest_architecture
|
||||
run_command account /usr/bin/id
|
||||
run_command uid /usr/bin/id -u
|
||||
(( LAST_EXIT == 0 )) || finish false uid_probe_failed
|
||||
uid=$(cat "$LAST_OUTPUT")
|
||||
[ "$uid" = 0 ] || finish false recovery_account_not_root
|
||||
run_command bootargs /usr/sbin/sysctl kern.bootargs
|
||||
run_command cpu /usr/sbin/sysctl machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features
|
||||
run_command parent /bin/ps -p "$$" -p "$PPID" -o pid=,ppid=,comm=
|
||||
run_command processes /bin/ps -axo pid,ppid,comm
|
||||
run_command platform /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
run_command system /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
if (( platform_exit != 0 )); then
|
||||
printf '[proof-retry] sw_vers once after native service context; same 45-second deadline\n' >> "$PROOF_LOG"
|
||||
run_command platform-warm /usr/bin/sw_vers
|
||||
platform_exit="$LAST_EXIT"
|
||||
fi
|
||||
(( platform_exit == 0 )) || finish false sw_vers_failed
|
||||
run_command version /usr/bin/sw_vers -productVersion
|
||||
(( LAST_EXIT == 0 )) || finish false product_version_failed
|
||||
os_version=$(cat "$LAST_OUTPUT")
|
||||
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || finish false product_version_invalid
|
||||
(( ${os_version%%.*} >= 14 )) || finish false unsupported_macos_version
|
||||
|
||||
# Bound readiness independently of the host's 40-minute overall deadline.
|
||||
readiness_start=$SECONDS
|
||||
attempt=0
|
||||
while (( SECONDS - readiness_start < 600 )); do
|
||||
attempt=$((attempt + 1))
|
||||
printf '\n[readiness-attempt] %s\n' "$attempt" >> "$PROOF_LOG"
|
||||
run_command disks /usr/sbin/diskutil list physical
|
||||
disk_list_exit="$LAST_EXIT"
|
||||
if (( disk_list_exit == 0 )); then
|
||||
disk_list=$(cat "$LAST_OUTPUT")
|
||||
candidates=0
|
||||
while IFS= read -r disk; do
|
||||
[ -n "$disk" ] || continue
|
||||
run_command "info-$disk" /usr/sbin/diskutil info "/dev/$disk"
|
||||
(( LAST_EXIT == 0 )) || continue
|
||||
info=$(cat "$LAST_OUTPUT")
|
||||
if printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$info" | grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || continue
|
||||
size=$(printf '%s\n' "$info" | sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' | head -n 1)
|
||||
[[ "$size" =~ ^[0-9]+$ ]] || continue
|
||||
(( size == EXPECTED_BYTES )) || continue
|
||||
candidates=$((candidates + 1))
|
||||
selected_disk="/dev/$disk"
|
||||
disk_bytes="$size"
|
||||
printf '[writable-target] %s %s bytes\n' "$selected_disk" "$disk_bytes" >> "$PROOF_LOG"
|
||||
done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p')
|
||||
(( candidates <= 1 )) || finish false ambiguous_writable_64g_disks
|
||||
if (( candidates == 1 )); then
|
||||
# Re-probe live launchd domains after disk readiness, preserving native exits.
|
||||
run_command system_ready /bin/launchctl print system
|
||||
system_exit="$LAST_EXIT"
|
||||
run_command arbitration_ready /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
arbitration_exit="$LAST_EXIT"
|
||||
run_command recovery_ready /bin/launchctl print system/com.apple.recoveryosd
|
||||
recovery_exit="$LAST_EXIT"
|
||||
(( system_exit == 0 && arbitration_exit == 0 && recovery_exit == 0 )) || finish false service_domain_not_ready
|
||||
finish true native_recovery_and_writable_64g_disk_ready
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
finish false disk_management_or_writable_target_not_ready
|
||||
Reference in New Issue
Block a user