forked from Manuel/meeting-assistant
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb24e82eb | ||
|
|
2e2d702e29 | ||
|
|
4b7fd160ef | ||
|
|
81a3b9c7d2 | ||
|
|
9164fe451f | ||
|
|
6122be2cef | ||
|
|
9735db1cdc | ||
|
|
17fb74dd74 | ||
|
|
c01ae13185 | ||
|
|
720a43158c | ||
|
|
227884723a | ||
|
|
25989cf0eb | ||
|
|
94a70b2005 | ||
|
|
45d7bde71f | ||
|
|
4606de0696 | ||
|
|
40281b57a5 | ||
|
|
c92e62bdf5 | ||
|
|
b40234d3b5 | ||
|
|
0a30a1ca5a | ||
|
|
1b19b08f2e | ||
|
|
6b1896660f | ||
|
|
05e23857dd | ||
|
|
9a91a81992 | ||
|
|
83726a2233 | ||
|
|
4840b8e3be | ||
|
|
445bff99ce | ||
|
|
1164c26846 | ||
|
|
bd35ebc4c8 | ||
|
|
fc0edd812d | ||
|
|
7b2bcd3631 | ||
|
|
bf6e1e7560 | ||
|
|
d1d4b53ee8 | ||
|
|
0e2aa3830f | ||
|
|
d77187e9ec | ||
|
|
6adf6f726b |
@@ -0,0 +1,36 @@
|
||||
name: macOS 14 TCG Recovery prerequisite diagnostic
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
macos-native-diagnostic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 95
|
||||
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: Verify actual AVX2 emulation then probe macOS 14 Recovery
|
||||
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
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Native macOS build and tests on Ubuntu (experimental)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
macos-native-full:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout exact native CI candidate
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup .NET orchestration SDK
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
- name: Run owned macOS 14 TCG guest and all native tests
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --recovery-format raw --output artifacts/native-macos-full
|
||||
- name: Always remove only this run's owned resources
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||
- name: Retain native build, signatures, TRX and guest receipts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: native-macos-full
|
||||
path: artifacts/native-macos-full/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -3,6 +3,13 @@ name: PR and Push Build/Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
# This temporary branch changes only the native prerequisite diagnostic.
|
||||
# Its manual workflow provides that evidence; the PR branch still runs all jobs.
|
||||
branches-ignore:
|
||||
- codex/macos-ci-kvm-compatibility
|
||||
- codex/macos-ci-tcg-supported
|
||||
- codex/macos-native-full-tcg
|
||||
- codex/macos-native-raw-recovery
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -24,37 +31,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 +80,138 @@ 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
|
||||
|
||||
macos-native-full:
|
||||
needs: [build-and-test, portable-build-and-test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout exact native CI candidate
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup .NET orchestration SDK
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
- name: Run owned macOS 14 TCG guest and all native tests
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||
- name: Always remove only this run's owned resources
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||
- name: Retain native build, signatures, TRX and guest receipts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: native-macos-full
|
||||
path: artifacts/native-macos-full/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
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 AutomaticWorkflowChangeCannotSilentlyLoseItsPromptOnMacOs()
|
||||
{
|
||||
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()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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,11 +2,21 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cloud.schweigert.meeting-assistant.audio-capture</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Meeting Assistant Audio Capture</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Meeting Assistant records microphone audio for live meeting transcription.</string>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
projects.Add(new ProjectInstructions(project.Name, content));
|
||||
}
|
||||
blocks.Add(await ProjectMetadata.BuildContextAsync(project, cancellationToken));
|
||||
}
|
||||
|
||||
return projects;
|
||||
return blocks.Count == 0 ? "" : "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
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. After both jobs pass, the prepared workflow requires a native macOS job on the same existing runner label and Docker daemon, using software CPU emulation without host devices or added capabilities.
|
||||
|
||||
The Ubuntu managed-test job does not compile the Swift helpers or execute Apple frameworks; its native macOS tests report an explicit skip through `MacOsFact`. The prepared native job runs an owned macOS 14+ x86_64 guest under TCG on the existing Ubuntu Docker runner. Before downloading Recovery, the actual pinned QEMU binary must execute an AVX/AVX2 instruction probe. The full flow builds all four native helpers, verifies the audio app's signature, and requires all 577 tests to pass with zero skips, including all five native macOS tests. It has a 180-minute job limit with retained evidence and ownership-checked cleanup. Recovery, installation, toolchain operation and this CI path remain unqualified until actual remote guests produce every required receipt. Native tests can also run on a supported Mac with `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj -f net10.0 -c Release -p:EnableWindowsTargeting=true`; real microphone/system-audio capture and privacy permissions still require the operational checks described above.
|
||||
|
||||
The separate manual `.gitea/workflows/macos-native-full.yaml` retains the same Full flow as a diagnostic entry point. CI validates source; it does not publish or deploy the workstation application.
|
||||
|
||||
The separate manual [native Recovery diagnostic](docs/macos-native-diagnostic.md) can isolate macOS startup and disk readiness. It neither installs macOS nor runs application tests. A green Recovery diagnostic alone does not verify macOS build/test CI support; each Full run requires fresh Recovery readiness for its own guest and disk before permitting installation.
|
||||
|
||||
## 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,90 @@
|
||||
# macOS 14 TCG prerequisite diagnostic
|
||||
|
||||
The isolated RAW Recovery comparison starts from Full candidate `2e2d702e294c39f24c6a3e44e5e94ff793d8774b`. Use `dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --recovery-format raw --output artifacts/native-macos-full`; the manual Full workflow includes this option. Its only runtime variable is the Recovery disk's DMG versus RAW backend. The default remains DMG. CPU, macOS version, permissions, native process probe and all time limits are unchanged.
|
||||
|
||||
The existing pinned container's `qemu-img` converts the already-patched DMG, requires sector equality with `qemu-img compare`, and checks that conversion preserved the DMG's SHA256. Both images stay in this run's owned storage. A retained JSON receipt binds both hashes and the successful comparison. The original readonly virtio attachment and I/O thread are preserved; cleanup removes both images with that owned volume. Local `--validate --full --recovery-format raw --source <pristine-pinned-dockur-checkout> --output <fresh-folder>` exercises conversion failures, source mutation and strict backend selection with a mocked QEMU boundary. The generated `raw-recovery-real-qemu-fixture.sh` accepts an already-patched **disposable writable DMG copy** plus destination for an actual container QEMU comparison; it does not patch, mount or boot a guest. This comparison does not yet prove faster guest operation or native application tests.
|
||||
|
||||
This manual candidate uses the existing Ubuntu/x64 Docker runner. Before downloading Apple Recovery it tests actual AVX/AVX2 instruction execution in the pinned QEMU binary, then probes a fresh macOS 14+ Recovery guest. It does not install macOS, erase a disk, provision .NET/CLT or run Meeting Assistant tests. Readiness is only a prerequisite for full native CI.
|
||||
|
||||
## Profile and evidence
|
||||
|
||||
The existing Intel Celeron 1037U has neither AVX nor AVX2. KVM run 4187 at `720a431` reached macOS 13.6/x86_64/root and visible whole writable 64-GiB media. Diskutil timed out after 122 seconds; the sampler produced no report after 61 seconds. The screen remained at the Apple boot progress bar. CPU throttling, memory-limit/OOM events and container swap were zero; memory peaked at 2.67 GB. Host paging occurred. No unsupported-instruction crash or particular IPC wait is proved.
|
||||
|
||||
[CryptexFixup 1.0.5](https://github.com/acidanthera/CryptexFixup/blob/1.0.5/kern_start.cpp) selects the installed/updated Rosetta Cryptex and patches APFS hash checking; it does not replace the running Recovery cache or emulate instructions. macOS 13 is outside the [.NET 10 supported-OS policy](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md). This candidate therefore uses macOS 14 and software CPU emulation without Cryptex. It changes the compatibility profile, not one isolated causal variable; actual success must be measured.
|
||||
|
||||
Earlier TCG run 4159 observed guest AVX2 with the upstream-selected Skylake model. Runs 4161/4163 measured slow native startup and reached the 40-minute host limit before readiness. They predated the UDIF CRC repair at `94a70b2`, reuse of successful sw_vers output and capturing the large Recovery hash only once. They do not qualify this candidate. Host/workflow limits for the read-only mode are 90/95 minutes; a readiness pass does not establish that full installation/build/tests fit the pipeline.
|
||||
|
||||
Run 4188 with Haswell recorded a boot loop; first-reset run 4189 retained repeated supervisor instruction-fetch pagefaults at RIP/CR2 `0x24b0` before native readiness. Run 4190 at `3aaab45` restored `Skylake-Client-v4` and the upstream TCG `-spec-ctrl` mask. The actual AVX/AVX2 ROM passed (exit 33; AVX2-disabled control exit 0), and macOS 14's Darwin 23.6.0 kernel identified the Skylake CPU. It retained one kernel handoff, a running VM and later userspace execution without the earlier reset, but reached the 20-minute diagnostic deadline without the readiness hook. These observations do not identify Haswell as the original cause or qualify native tests.
|
||||
|
||||
Run 4190 used synchronous kernel serial output and QEMU interrupt/register tracing to preserve the failure context. Its kernel explicitly warned that synchronous output impacts performance. The current full candidate uses normal upstream boot arguments and only the existing iothread QEMU argument; it retains actual CPU/staging receipts independently of Docker log rotation. Its existing fresh-readiness gate precedes every installation permit. This allows one bounded full qualification to test boot performance and, only after readiness, installation/build/tests without duplicating the guest startup. No remote full result has qualified this candidate yet.
|
||||
|
||||
Full run 4191 at `9735db1` reached native Recovery: x86_64/root, Darwin 23.6.0 and successful launchd service queries. Both `sw_vers` attempts were stopped by the existing 45-second watchdog at about 50 seconds. Other successful commands took 24–47 seconds, and small log-copy batches took 85–181 seconds. Thus this run proves broad native startup latency and a probe-imposed abort, without proving a permanent `sw_vers` hang. Disk enumeration, installation and application tests were not reached. The next candidate obtains the version from the current guest's SystemVersion plist to reduce process launches; it does not claim that `sw_vers` has become functional.
|
||||
|
||||
Run 4192 at `6122be2` captured the actual 603-byte guest file and strictly parsed version 14.6.1. Its host reply was published successfully, but the guest's reply-existence check timed out before services or disk enumeration. Guest request/timeout UTC timestamps were not retained, so late delivery and 9p visibility cannot be distinguished. The current candidate removes this version reply: the guest publishes its raw file and a provisional version candidate; the host's full XML validation and exact result binding remain mandatory before any installation permit. The later installation-permit transport is still unqualified.
|
||||
|
||||
Run 4193 at `9164fe4` successfully derived the same current-file version in both guest and host. Its single `diskutil list physical` process was stopped at 124 seconds by the 120-second watchdog without output. The observer saw one runnable row and 3.18 seconds of accumulated CPU time, without a stack or proven IPC endpoint. `ioreg` was also stopped before producing output. The post-disk service gates, permit, installation and tests were not reached. A prior modified Recovery14 reference image has byte-identical SystemVersion contents but a different image hash; it contains StorageKit with the `com.apple.storagekitd` and `com.apple.storagekitd.dm` MachServices, not `diskmanagementd`. The next observation targets that actual service family and the own diskutil stack; this reference does not establish the live service state of run 4193.
|
||||
|
||||
Run 4194 at `81a3b9c` stopped the same single query after 606 seconds without output. StorageKit exists but was not running and had never started in the before/live snapshots; the live snapshot covers approximately 103–160 seconds of the query, not its entire lifetime. `sample` hit its own watchdog without even its sampling-start message or report. This does not establish symbolication as the cause. The observed `1T` is a current Timeshare priority, not a thread count or proof of background policy. Neither installation nor tests ran.
|
||||
|
||||
Run 4195 at `4b7fd16` did not reach the new observer: the required `uname -m` timed out at its 45-second limit, was sent TERM at 51 seconds and exited 143 at 55 seconds without output. Later `id`, `sysctl` and targeted `ps` completed, but `uname -a` also timed out. No new QEMU fatal/reset, container OOM, swap or CPU throttling was recorded. The cause remains unknown. The next bounded run changes only the architecture command's limit to the existing UID command's 180 seconds; a successful native `uname -m` result is still mandatory.
|
||||
|
||||
## Entry points and dependencies
|
||||
|
||||
Orchestration remains the .NET 10 file-based app `tools/ci/MacOsNativeDiagnostic.cs`. Existing Bash/Python boot integration is necessary before a guest SDK exists. NASM assembles the CPU probe in the disposable image build, without host/runner installation. No new runner, device, capability, secret or service is used.
|
||||
|
||||
The disposable native process diagnostic uses a small C boundary linked only to libSystem, so it can record entry and kernel observations before the guest has .NET or CLT. Its C# file-based build driver and retained source/compiler/SDK/minimum-OS/import/signature/hash receipt describe the one-time local build. The pipeline receives this diagnostic asset and does not invoke an Apple compiler or request a macOS runner. This asset is not one of the application's four freshly built helpers and cannot qualify a test or readiness gate.
|
||||
|
||||
The probe validates both the target PID and expected parent before reading role/task data. Public BSD observations include background flags, Nice, role and raw CPU/page-in counters. A read-only Mach port is attempted separately, with return and errno recorded before any DYLD/thread reads. The local Apple-sleep fixture returned EPERM; actual Recovery rights remain unknown. The probe has no control-port fallback, process suspension, remote writes, extra entitlements or SIP change. Unsuspended snapshots may be incomplete.
|
||||
|
||||
For local maintenance with an existing Apple SDK, run `dotnet run --file tools/ci/native-process-probe/ProbeDriver.cs -- tools/ci/native-process-probe`. It builds and signs into that folder's `artifacts/`, observes and cleans up its own temporary sleep child, and retains the build/self-test receipt there. Publishing a changed asset requires reviewing that receipt, refreshing the portable `build-manifest.json` and updating all four controller hash pins. CI verifies those pins before staging the binary and manifest into the owned Recovery state; it never runs the build driver.
|
||||
|
||||
```sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --source /path/to/clean/pinned/dockur-clone --output /path/to/fresh/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
|
||||
```
|
||||
|
||||
The native diagnostic workflow is manual only. Temporary diagnostic branches are excluded from ordinary push jobs to avoid repeating unchanged Wine/portable jobs. Remove this routing when integrating qualified CI into the actual PR.
|
||||
|
||||
## Before Apple downloads
|
||||
|
||||
The existing daemon must be Linux/x64 with two CPUs and 6 GiB memory; the runner must have 5 GiB available memory and the Docker filesystem 8 GiB free. These checks do not reconfigure resources. Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, both imported QEMU image digests and original source seams remain pinned.
|
||||
|
||||
Actual `Skylake-Client-v4` CPU flags under TCG use `enforce=on` to reject unsupported requests. The CPU preflight uses that same composed flag list and QEMU binary before Recovery download/boot. `tools/ci/macos-tcg-cpu-preflight.asm` enables long mode/YMM state, executes AVX and AVX2 integer arithmetic, and checks an Int32 from the upper 128-bit lane. Only the correct result reaches [QEMU debug-exit](https://github.com/qemu/qemu/blob/v11.1.1/hw/misc/debugexit.c) code 33. No disks/network attach; failure/timeout fails preflight. This tests that instruction chain, not the complete ISA or macOS.
|
||||
|
||||
The locally assembled NASM 2.16.03 ROM is 65,536 bytes, SHA256 `c32746122cc68f3ed642aa46c21b677f803c58f0d4ff665841723fcc5625f549`. Assembly/static review does not prove remote execution.
|
||||
|
||||
## Native gates, bounds and cleanup
|
||||
|
||||
The original Apple recoveryosd runs under its existing job/PID beside the read-only probe. Exact known macOS 13/14 plist layouts and same-length replacements retain their allowlist. The patcher validates UDIF boundaries, updates changed mish/koly CRCs and reads back the image. Four raw/zlib positive and twelve rejection fixtures use an independent C# CRC32 reader. Apple chunklist authentication applies to the input, not the deliberately modified image.
|
||||
|
||||
Native readiness requires x86_64, UID 0, macOS 14+, successful launchd service queries and exactly one writable whole 64-GiB disk. The guest's existing Bash runtime reads its own `/System/Library/CoreServices/SystemVersion.plist` with a 4-KiB bound and mandatory EOF. Apple documents this path as the [system-version source](https://developer.apple.com/documentation/installer_js/system/1812284-version). Bash extracts only a provisional numeric version from the same bytes it publishes; subsequent guest probes remain read-only. The existing C# controller parses the full captured XML with external resolution disabled and requires a flat string-valued dictionary with exactly one valid direct `ProductVersion` string and macOS 14+. Missing, binary, oversized, ambiguous or malformed content fails. Before either readiness success or an installation permit, the result's version must exactly match this current-file evidence. No configured `VERSION` or host OS value serves as proof. The actual native diskutil query remains mandatory.
|
||||
|
||||
The raw file, exact source path, length, SHA256 and parsing receipt are retained and bound to the current token. This version evidence travels only from guest to host and requires no reply. The guest uses its existing Bash before any SDK exists; authoritative XML logic stays in C#/.NET. A Bash candidate alone cannot authorize installation or qualify readiness. This method establishes the current guest version, not successful execution of `sw_vers`.
|
||||
|
||||
Ordinary commands retain 45 seconds; architecture and UID use 180 seconds. The single disk query retains its 600-second diagnostic window under the existing 90-minute Recovery deadline. An optional no-target `sample` CLI control precedes it. The owned observer takes an early native process snapshot, requests an ordinary one-second/100-ms `sample` without eager `-mayDie` symbol loading, and records live StorageKit state. After a cancelable 180-second builtin pause it takes a late snapshot of the same live disk child and expected parent. Each observation retains its own 60-second watchdog and two-second TERM/KILL grace. Missing tools, denied reads, failures and timed-out samples remain explicit missing evidence. Stack output is captured directly from the owned state with a 512-KiB bound, without another native copy command. Observation failure passes no gate. Owned children are stopped on query completion/cancellation; output remains 512 KiB per command and 4 MiB proof. No service is started or restarted by the observer.
|
||||
|
||||
The container retains 6 GiB memory/swap, two-CPU limit, 512 MiB shared memory and a 4-GiB/two-vCPU guest. One fresh anonymous /storage volume holds the sparse 64-GiB target. Inspection rejects devices, capabilities, binds, ports, host networking and privileged mode. KVM is disabled with no /dev/kvm mapping; guest networking stays slirp.
|
||||
|
||||
Evidence retains run/source/profile identity, CPU preflight, original/patched Recovery identity, container/QEMU state, native proof/result and cleanup. Sparse kernel-handoff lines are retained separately; two handoffs before readiness/installation permission fail early. After permission, normal installer reboots remain allowed. Optional bounded before/during/after pressure snapshots record host/cgroup counters. The /storage/14/setup.dmg hash is captured once after staging; successful evidence survives later capture failure. Screenshots/pressure observations pass no gate.
|
||||
|
||||
Both cleanup paths verify exact token/label/ID before removing only the owned container, anonymous volume and image. No pruning, host changes, original checkout changes or Meeting Assistant restart occurs. Artifacts remain seven days. Full CI remains unverified until an installed supported guest builds/signs fresh helpers and passes all 577 tests, including the five native macOS tests, with zero skips.
|
||||
|
||||
## Prepared full build/test flow
|
||||
|
||||
The separate manual `.gitea/workflows/macos-native-full.yaml` and the prepared required PR job invoke the same full mode:
|
||||
|
||||
```sh
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --validate --full --source /path/to/clean/pinned/dockur-clone --output /path/to/fresh/full-validation
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --run --full --output artifacts/native-macos-full
|
||||
dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifacts/native-macos-full
|
||||
```
|
||||
|
||||
Full mode repeats CPU preflight and Recovery readiness for its own fresh guest. Before erasing the disposable target, the host verifies the owned container/anonymous volume, raw 64-GiB image, actual QEMU attachment/unique disk serial and state share. A token/source/disk-bound permit authorizes the guest. The guest independently checks whole/writable/size/unique serial before `diskutil eraseDisk`. It never erases a host disk or reuses an unrelated guest volume.
|
||||
|
||||
The installer provisions the owned guest and returns into the prepared firstboot hook. Early firstboot logs and failures enter the state share even before account-package installation or test bootstrap. The installed root must be APFS backed by the exact owned physical store. Apple softwareupdate provisions CLT; a real Swift/SDK smoke build imports the required Apple frameworks. The source archive is bound to clean Git HEAD and SHA256; the pinned macOS x64 .NET SDK 10.0.401 is checked with SHA512. No prebuilt application/helper result counts as this run's evidence.
|
||||
|
||||
`tools/ci/MacOsNativeGuest.cs` restores/builds/tests the source inside the installed guest. Success requires four fresh x86_64 Mach-O helpers, a valid audio-app signature and a fresh source-bound TRX containing exactly 577 distinct passing tests, zero failures/skips and all five named native macOS tests. Archive, SDK, build, signature and TRX receipts are retained. The required PR job follows the existing Wine and portable jobs; all jobs still select `ubuntu-latest`.
|
||||
|
||||
The workflow has 180 minutes; the controller reserves cleanup time with a shared 172-minute total deadline. Recovery, installation, CLT and test caps are 90/80/30/25 minutes under that same total, not additive promises. Actual supported-guest installation/performance and remote test success remain unqualified. The full run's own mandatory fresh-readiness and owned-disk gates prevent installation until that guest passes its prerequisites. CI does not deploy or restart the workstation application.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
// Installed-guest CI slice. --validate never invokes macOS, dotnet build/test, or a VM.
|
||||
return await NativeGuest.Execute(args);
|
||||
|
||||
static class NativeGuest
|
||||
{
|
||||
const string SdkVersion = "10.0.401";
|
||||
const string SdkSha512 = "33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0";
|
||||
const int ExpectedTests = 577;
|
||||
const int MaximumLogBytes = 8 * 1024 * 1024;
|
||||
static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
|
||||
static readonly string[] RequiredNativeTests =
|
||||
[
|
||||
"MeetingAssistant.Tests.MacOsMeetingAudioSourceTests.NativeAudioCaptureIsPackagedAsSignedMacOsAppForPersistentPrivacyGrant",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.MacOsCapabilityEndpointReportsEnabledRealProviders",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.NativeHelperCropsPngUsingOcrPixelCoordinates",
|
||||
"MeetingAssistant.Tests.MacOsMeetingIntegrationTests.CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied"
|
||||
];
|
||||
static readonly string[] NativeNames =
|
||||
[
|
||||
"MeetingAssistantAudioCapture.app/Contents/MacOS/macos-meeting-audio-capture",
|
||||
"macos-desktop-controls", "macos-meeting-integrations", "macos-meeting-assistant-launcher"
|
||||
];
|
||||
|
||||
public static async Task<int> Execute(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.SequenceEqual(["--help"]))
|
||||
{
|
||||
Console.WriteLine("dotnet run --file tools/ci/MacOsNativeGuest.cs -- --validate [--archive <source.tar> --source-commit <SHA>] | --run --state /Volumes/installstate --work /private/var/tmp/meeting-assistant-native-<runToken>");
|
||||
return 0;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (args.SequenceEqual(["--validate"]) || args.Length == 5 && args[0] == "--validate" && args[1] == "--archive" && args[3] == "--source-commit")
|
||||
{
|
||||
ValidateContracts();
|
||||
if (args.Length == 5)
|
||||
{
|
||||
if (!Hex(args[4], 40)) throw new ArgumentException("Source commit must be the full lowercase Git SHA.");
|
||||
using var archive = File.OpenRead(Path.GetFullPath(args[2]));
|
||||
ValidateArchive(archive, args[4]);
|
||||
}
|
||||
Console.WriteLine("Guest payload, safe Git tar, fresh TRX and native receipt contracts passed; no native execution occurred.");
|
||||
return 0;
|
||||
}
|
||||
if (args.Length != 5 || args[0] != "--run" || args[1] != "--state" || args[3] != "--work")
|
||||
throw new ArgumentException("Choose --validate or --run --state <mounted9pdir> --work <ownedAPFSdir>.");
|
||||
return await Run(Path.GetFullPath(args[2]), Path.GetFullPath(args[4]));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(exception.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> Run(string state, string work)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS() || RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
throw new InvalidOperationException("--run is restricted to the installed macOS x86_64 guest.");
|
||||
RequireNoLinks(state);
|
||||
RequireNoLinks(work);
|
||||
var manifestPath = Path.Combine(state, "payload.json");
|
||||
RequireNoLinks(manifestPath);
|
||||
var payload = ReadPayload(File.ReadAllText(manifestPath));
|
||||
if (work != "/private/var/tmp/meeting-assistant-native-" + payload.RunToken || !Directory.Exists(work))
|
||||
throw new InvalidOperationException("Guest work directory does not match this run's owned APFS location.");
|
||||
var owner = Path.Combine(work, "run.owner");
|
||||
RequireNoLinks(owner);
|
||||
if (File.ReadAllText(owner).TrimEnd('\r', '\n') != payload.RunToken)
|
||||
throw new InvalidOperationException("Guest work directory has a different run owner.");
|
||||
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var summary = new TestSummary(0, 0, 0, 0, 0, []);
|
||||
var native = new List<NativeArtifact>();
|
||||
var osVersion = "";
|
||||
var architecture = "";
|
||||
var actualSdk = "";
|
||||
var trxSha256 = "";
|
||||
var audioCodeSignExit = -1;
|
||||
var success = false;
|
||||
var reason = "";
|
||||
var logs = Path.Combine(state, "guest-logs");
|
||||
var results = Path.Combine(state, "test-results");
|
||||
var source = Path.Combine(work, "source");
|
||||
var dotnet = Path.Combine(work, "dotnet", "dotnet");
|
||||
// Guest checks/restore/build/tests: 25 minutes within the host's 172-minute total.
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(25));
|
||||
using var signal = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; deadline.Cancel(); });
|
||||
ConsoleCancelEventHandler cancelHandler = (_, context) => { context.Cancel = true; deadline.Cancel(); };
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
void Phase(string phase, string stage) => Save(Path.Combine(state, "guest-phase.json"), new { token = payload.RunToken, phase, stage, updatedUtc = DateTimeOffset.UtcNow });
|
||||
async Task<CommandResult> Cmd(string executable, string[] arguments, string label, bool requireSuccess = true) =>
|
||||
await Command(executable, arguments, source, work, logs, label, deadline.Token, requireSuccess);
|
||||
try
|
||||
{
|
||||
RequireAbsent(Path.Combine(state, "full-result.json"));
|
||||
RequireAbsent(logs);
|
||||
RequireAbsent(results);
|
||||
RequireAbsent(source);
|
||||
Directory.CreateDirectory(logs);
|
||||
Directory.CreateDirectory(results);
|
||||
foreach (var directory in new[] { "home", "packages", "tmp" })
|
||||
{
|
||||
RequireNoLinks(Path.Combine(work, directory));
|
||||
Directory.CreateDirectory(Path.Combine(work, directory));
|
||||
}
|
||||
Phase("tests-running", "installed-guest-checks");
|
||||
osVersion = (await Cmd("/usr/bin/sw_vers", ["-productVersion"], "os-version")).Output.Trim();
|
||||
architecture = (await Cmd("/usr/bin/uname", ["-m"], "architecture")).Output.Trim();
|
||||
var uid = (await Cmd("/usr/bin/id", ["-u"], "uid")).Output.Trim();
|
||||
RequirePlatform(osVersion, architecture, uid);
|
||||
foreach (var volume in new[] { ("/", "system-volume"), (work, "work-volume") })
|
||||
RequireApfs((await Cmd("/usr/sbin/diskutil", ["info", "-plist", volume.Item1], volume.Item2)).Output);
|
||||
await Cmd("/usr/bin/xcode-select", ["-p"], "clt-location");
|
||||
await Cmd("/usr/sbin/pkgutil", ["--pkg-info", "com.apple.pkg.CLTools_Executables"], "clt-package");
|
||||
await Cmd("/usr/bin/xcrun", ["swiftc", "--version"], "swift-version");
|
||||
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-version"], "apple-sdk-version");
|
||||
await Cmd("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], "apple-sdk-path");
|
||||
RequireNoLinks(dotnet);
|
||||
actualSdk = (await Cmd(dotnet, ["--version"], "sdk-version")).Output.Trim();
|
||||
var sdkInfo = (await Cmd(dotnet, ["--info"], "sdk-info")).Output;
|
||||
if (actualSdk != SdkVersion || !Regex.IsMatch(sdkInfo, @"(?m)^\s*Architecture:\s*x64\s*$"))
|
||||
throw new InvalidOperationException("Guest .NET SDK is not the pinned 10.0.401/x64 toolchain.");
|
||||
|
||||
Phase("tests-running", "payload-verification");
|
||||
var archive = Path.Combine(state, "source.tar");
|
||||
var sdkArchive = Path.Combine(state, "sdk.tar.gz");
|
||||
RequireNoLinks(archive);
|
||||
RequireNoLinks(sdkArchive);
|
||||
if (await HashFile(archive, false, deadline.Token) != payload.ArchiveSha256 || await HashFile(sdkArchive, true, deadline.Token) != payload.SdkSha512)
|
||||
throw new InvalidOperationException("Guest payload hash does not match the pinned manifest.");
|
||||
using (var stream = File.OpenRead(archive)) ValidateArchive(stream, payload.SourceCommit);
|
||||
// All members were checked before native tar can write anything; the destination is new.
|
||||
Directory.CreateDirectory(source);
|
||||
await Cmd("/usr/bin/tar", ["-xf", archive, "-C", source], "source-extraction");
|
||||
var project = Path.Combine(source, "MeetingAssistant.Tests", "MeetingAssistant.Tests.csproj");
|
||||
if (!File.Exists(project)) throw new InvalidOperationException("Source archive lacks the test project.");
|
||||
var nativeRoot = Path.Combine(source, "MeetingAssistant", "bin", "Release", "net10.0", "Native");
|
||||
RequireAbsent(nativeRoot);
|
||||
Phase("tests-running", "restore");
|
||||
await Cmd(dotnet, ["restore", project, "-p:EnableWindowsTargeting=true", "-p:TargetFramework=net10.0"], "restore");
|
||||
Phase("tests-running", "build");
|
||||
var buildStarted = DateTimeOffset.UtcNow;
|
||||
await Cmd(dotnet, ["build", project, "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true"], "build");
|
||||
Phase("tests-running", "native-artifacts");
|
||||
foreach (var name in NativeNames)
|
||||
{
|
||||
var path = Path.Combine(nativeRoot, name);
|
||||
RequireNoLinks(path);
|
||||
RequireFreshFile(path, buildStarted);
|
||||
var fileOutput = (await Cmd("/usr/bin/file", ["-b", path], "native-file-" + native.Count)).Output;
|
||||
var arch = (await Cmd("/usr/bin/xcrun", ["lipo", "-archs", path], "native-architecture-" + native.Count)).Output.Trim();
|
||||
RequireNativeArtifact(fileOutput, arch);
|
||||
native.Add(new(name, await HashFile(path, false, deadline.Token), arch));
|
||||
}
|
||||
var signing = await Cmd("/usr/bin/codesign", ["--verify", "--deep", "--strict", Path.Combine(nativeRoot, "MeetingAssistantAudioCapture.app")], "audio-code-sign", false);
|
||||
audioCodeSignExit = signing.ExitCode;
|
||||
if (audioCodeSignExit != 0) throw new InvalidOperationException("Fresh audio app did not pass strict code-signature verification.");
|
||||
Phase("tests-running", "test");
|
||||
var testStarted = DateTimeOffset.UtcNow;
|
||||
await Cmd(dotnet, ["test", project, "--no-build", "--no-restore", "-f", "net10.0", "-c", "Release", "-p:EnableWindowsTargeting=true", "--logger", "trx;LogFileName=native.trx", "--results-directory", results], "test");
|
||||
var trxPath = Path.Combine(results, "native.trx");
|
||||
RequireNoLinks(trxPath);
|
||||
RequireFreshFile(trxPath, testStarted, 16 * 1024 * 1024);
|
||||
var trxBytes = File.ReadAllBytes(trxPath);
|
||||
summary = ValidateTrx(trxBytes, payload.ExpectedTests, testStarted);
|
||||
trxSha256 = Convert.ToHexStringLower(SHA256.HashData(trxBytes));
|
||||
success = true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
reason = exception is OperationCanceledException ? "The explicit 25-minute guest deadline or cancellation was reached." : exception.Message;
|
||||
if (reason.Length > 4096) reason = reason[..4096];
|
||||
Console.Error.WriteLine(reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
var result = new FullResult(payload.RunToken, success, payload.SourceCommit, payload.ArchiveSha256, osVersion, architecture, actualSdk, payload.ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, native.ToArray(), audioCodeSignExit, trxSha256, reason, started, DateTimeOffset.UtcNow);
|
||||
if (success)
|
||||
{
|
||||
try { ValidateResult(result, payload); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
success = false;
|
||||
result = result with { Success = false, Reason = exception.Message };
|
||||
}
|
||||
}
|
||||
Save(Path.Combine(state, "full-result.json"), result);
|
||||
Phase(success ? "tests-passed" : "tests-failed", "complete");
|
||||
}
|
||||
Console.WriteLine(success ? "Native macOS guest build, signed helpers and all 577 tests passed without skips." : "Native guest validation failed; full-result.json and bounded logs retain the evidence.");
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
|
||||
static Payload ReadPayload(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object || document.RootElement.EnumerateObject().Select(property => property.Name).Distinct(StringComparer.Ordinal).Count() != document.RootElement.EnumerateObject().Count())
|
||||
throw new InvalidOperationException("Payload manifest must contain one unambiguous JSON object.");
|
||||
var payload = JsonSerializer.Deserialize<Payload>(json, JsonOptions) ?? throw new InvalidOperationException("Missing guest payload manifest.");
|
||||
if (!Hex(payload.RunToken, 32) || !Hex(payload.SourceCommit, 40) || !Hex(payload.ArchiveSha256, 64) || payload.SdkVersion != SdkVersion || payload.SdkSha512 != SdkSha512 || payload.ExpectedTests != ExpectedTests)
|
||||
throw new InvalidOperationException("Guest payload identity, SDK pin or expected test count is invalid.");
|
||||
return payload;
|
||||
}
|
||||
|
||||
static void ValidateArchive(Stream stream, string commit)
|
||||
{
|
||||
using var reader = new TarReader(stream, leaveOpen: true);
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var commits = new List<string>();
|
||||
long totalBytes = 0;
|
||||
while (reader.GetNextEntry() is { } entry)
|
||||
{
|
||||
if (entry is PaxGlobalExtendedAttributesTarEntry global)
|
||||
{
|
||||
if (global.GlobalExtendedAttributes.TryGetValue("comment", out var value)) commits.Add(value);
|
||||
if (global.GlobalExtendedAttributes.Keys.Any(key => key is "path" or "linkpath"))
|
||||
throw new InvalidOperationException("Global tar path overrides are not supported.");
|
||||
continue;
|
||||
}
|
||||
if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory))
|
||||
throw new InvalidOperationException("Source tar contains a link or unsupported entry type: " + entry.Name);
|
||||
var name = entry.Name.TrimEnd('/');
|
||||
if (name.Length == 0 || name.StartsWith('/') || name.Contains('\\') || name.Contains('\0') || name.Split('/').Any(part => part.Length == 0 || part is "." or ".." or ".git" or "bin" or "obj") || !names.Add(name))
|
||||
throw new InvalidOperationException("Source tar path is unsafe, repeated or contains generated output: " + entry.Name);
|
||||
totalBytes = checked(totalBytes + entry.Length);
|
||||
if (names.Count > 100_000 || totalBytes > 2L * 1024 * 1024 * 1024)
|
||||
throw new InvalidOperationException("Source tar exceeds its explicit entry/size budget.");
|
||||
}
|
||||
if (names.Count == 0 || commits.Count != 1 || commits[0] != commit || !names.Contains("MeetingAssistant.Tests/MeetingAssistant.Tests.csproj"))
|
||||
throw new InvalidOperationException("Source tar does not prove its exact Git commit and test project.");
|
||||
}
|
||||
|
||||
static TestSummary ValidateTrx(string xml, int expected, DateTimeOffset testStarted) => ValidateTrx(Encoding.UTF8.GetBytes(xml), expected, testStarted);
|
||||
static TestSummary ValidateTrx(byte[] xml, int expected, DateTimeOffset testStarted)
|
||||
{
|
||||
var document = ParseXml(xml);
|
||||
var root = document.Root ?? throw new InvalidOperationException("Empty TRX.");
|
||||
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
if (root.Name != ns + "TestRun") throw new InvalidOperationException("Unexpected TRX namespace or root.");
|
||||
var times = root.Element(ns + "Times") ?? throw new InvalidOperationException("TRX lacks execution timestamps.");
|
||||
if (!DateTimeOffset.TryParse((string?)times.Attribute("start"), out var start) || !DateTimeOffset.TryParse((string?)times.Attribute("finish"), out var finish) || start < testStarted.AddSeconds(-2) || finish < start || finish > DateTimeOffset.UtcNow.AddSeconds(30))
|
||||
throw new InvalidOperationException("TRX belongs to a stale or invalid test execution.");
|
||||
var resultSummary = root.Element(ns + "ResultSummary") ?? throw new InvalidOperationException("TRX lacks a final result summary.");
|
||||
if ((string?)resultSummary.Attribute("outcome") != "Completed") throw new InvalidOperationException("TRX test run did not complete.");
|
||||
var counters = resultSummary.Element(ns + "Counters") ?? throw new InvalidOperationException("TRX lacks final counters.");
|
||||
int Count(string name) => int.TryParse((string?)counters.Attribute(name), out var value) && value >= 0 ? value : throw new InvalidOperationException("TRX lacks a valid counter: " + name);
|
||||
var total = Count("total");
|
||||
var executed = Count("executed");
|
||||
var passed = Count("passed");
|
||||
var failed = Count("failed");
|
||||
var notExecuted = Count("notExecuted");
|
||||
foreach (var name in new[] { "error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected", "inProgress", "pending" })
|
||||
if (counters.Attribute(name) is not null && Count(name) != 0) throw new InvalidOperationException("TRX contains an incomplete or unsuccessful execution: " + name);
|
||||
if (expected != ExpectedTests || total != expected || executed != expected || passed != expected || failed != 0 || notExecuted != 0)
|
||||
throw new InvalidOperationException($"Native TRX must prove {expected} total/executed/passed tests, zero failures and zero skips; got {total}/{executed}/{passed}/{failed}/{notExecuted}.");
|
||||
var definitions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var unit in root.Element(ns + "TestDefinitions")?.Elements(ns + "UnitTest") ?? [])
|
||||
{
|
||||
var method = unit.Element(ns + "TestMethod") ?? throw new InvalidOperationException("TRX test definition lacks its method identity.");
|
||||
var className = ((string?)method.Attribute("className") ?? "").Split(',')[0].Trim();
|
||||
var methodName = (string?)method.Attribute("name") ?? "";
|
||||
var id = (string?)unit.Attribute("id") ?? "";
|
||||
if (id.Length == 0 || className.Length == 0 || methodName.Length == 0 || !definitions.TryAdd(id, methodName.StartsWith(className + ".", StringComparison.Ordinal) ? methodName : className + "." + methodName))
|
||||
throw new InvalidOperationException("TRX contains an ambiguous test definition.");
|
||||
}
|
||||
var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? [];
|
||||
var executionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var testIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var native = new List<string>();
|
||||
foreach (var result in results)
|
||||
{
|
||||
var id = (string?)result.Attribute("testId") ?? "";
|
||||
var executionId = (string?)result.Attribute("executionId") ?? "";
|
||||
if ((string?)result.Attribute("outcome") != "Passed" || executionId.Length == 0 || !executionIds.Add(executionId) || !testIds.Add(id) || !definitions.TryGetValue(id, out var identity))
|
||||
throw new InvalidOperationException("TRX has a missing, duplicate or non-passed execution.");
|
||||
if (RequiredNativeTests.Contains(identity, StringComparer.Ordinal)) native.Add(identity);
|
||||
}
|
||||
if (definitions.Count != expected || results.Length != expected || native.Count != RequiredNativeTests.Length || !native.Order().SequenceEqual(RequiredNativeTests.Order()))
|
||||
throw new InvalidOperationException("TRX does not prove every expected test definition exactly once and the five explicit native macOS tests.");
|
||||
return new(total, executed, passed, failed, notExecuted, native.ToArray());
|
||||
}
|
||||
|
||||
static void ValidateResult(FullResult result, Payload payload)
|
||||
{
|
||||
if (result.Token != payload.RunToken || !result.Success || result.SourceCommit != payload.SourceCommit || result.ArchiveSha256 != payload.ArchiveSha256 || !Version.TryParse(result.OsVersion, out var version) || version.Major < 14 || result.Architecture != "x86_64" || result.SdkVersion != SdkVersion || result.ExpectedTests != ExpectedTests || result.Total != ExpectedTests || result.Executed != ExpectedTests || result.Passed != ExpectedTests || result.Failed != 0 || result.NotExecuted != 0 || !result.NativeTests.Order().SequenceEqual(RequiredNativeTests.Order()) || result.AudioCodeSignExit != 0 || !Hex(result.TrxSha256, 64) || result.NativeArtifacts.Length != NativeNames.Length || !result.NativeArtifacts.Select(artifact => artifact.Name).Order().SequenceEqual(NativeNames.Order()) || result.NativeArtifacts.Any(artifact => artifact.Architecture != "x86_64" || !Hex(artifact.Sha256, 64)) || result.CompletedUtc < result.StartedUtc || result.CompletedUtc - result.StartedUtc > TimeSpan.FromMinutes(25).Add(TimeSpan.FromSeconds(15)) || result.CompletedUtc > DateTimeOffset.UtcNow.AddSeconds(30) || result.CompletedUtc < DateTimeOffset.UtcNow.AddMinutes(-1) || result.Reason.Length != 0)
|
||||
throw new InvalidOperationException("Native guest receipt does not prove this source, toolchain, signed artifacts and all expected tests.");
|
||||
}
|
||||
|
||||
static void RequirePlatform(string version, string architecture, string uid)
|
||||
{
|
||||
if (!Version.TryParse(version, out var parsed) || parsed.Major < 14 || architecture != "x86_64" || uid != "0")
|
||||
throw new InvalidOperationException("Native build requires installed macOS 14+/x86_64 running as root.");
|
||||
}
|
||||
static void RequireApfs(string xml)
|
||||
{
|
||||
var elements = ParseXml(xml).Root?.Element("dict")?.Elements().ToArray() ?? [];
|
||||
var type = elements.Select((element, index) => (element, index)).FirstOrDefault(pair => pair.element.Name == "key" && pair.element.Value == "FilesystemType");
|
||||
if (type.element is null || type.index + 1 >= elements.Length || elements[type.index + 1].Name != "string" || elements[type.index + 1].Value != "apfs")
|
||||
throw new InvalidOperationException("Native build must run from the installed APFS system and owned APFS work volume.");
|
||||
}
|
||||
static void RequireNativeArtifact(string fileOutput, string architecture)
|
||||
{
|
||||
if (!fileOutput.Contains("Mach-O", StringComparison.Ordinal) || !fileOutput.Contains("x86_64", StringComparison.Ordinal) || architecture != "x86_64")
|
||||
throw new InvalidOperationException("Native helper is not a Mach-O executable containing exactly x86_64.");
|
||||
}
|
||||
static void RequireAbsent(string path)
|
||||
{
|
||||
if (Path.Exists(path) || GetAttributesSafe(path)?.HasFlag(FileAttributes.ReparsePoint) == true)
|
||||
throw new InvalidOperationException("Fresh guest execution refuses pre-existing output: " + path);
|
||||
}
|
||||
static void RequireFreshFile(string path, DateTimeOffset started, long maximumBytes = long.MaxValue)
|
||||
{
|
||||
RequireNoLinks(path);
|
||||
var file = new FileInfo(path);
|
||||
if (!file.Exists || file.Length == 0 || file.Length > maximumBytes || file.LastWriteTimeUtc < started.UtcDateTime.AddSeconds(-2))
|
||||
throw new InvalidOperationException("Expected a fresh, nonempty, bounded output from this execution: " + path);
|
||||
}
|
||||
static void RequireNoLinks(string path)
|
||||
{
|
||||
for (var current = Path.GetFullPath(path); current is not null; current = Path.GetDirectoryName(current))
|
||||
if (GetAttributesSafe(current)?.HasFlag(FileAttributes.ReparsePoint) == true)
|
||||
throw new InvalidOperationException("Guest ownership/extraction refuses a symbolic link: " + current);
|
||||
}
|
||||
static FileAttributes? GetAttributesSafe(string path)
|
||||
{
|
||||
try { return File.GetAttributes(path); }
|
||||
catch (FileNotFoundException) { return null; }
|
||||
catch (DirectoryNotFoundException) { return null; }
|
||||
}
|
||||
static XDocument ParseXml(string xml) => ParseXml(Encoding.UTF8.GetBytes(xml));
|
||||
static XDocument ParseXml(byte[] xml)
|
||||
{
|
||||
using var stream = new MemoryStream(xml, writable: false);
|
||||
using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null, MaxCharactersInDocument = 16 * 1024 * 1024 });
|
||||
return XDocument.Load(reader);
|
||||
}
|
||||
static bool Hex(string? value, int length) => value is not null && Regex.IsMatch(value, "^[0-9a-f]{" + length + "}$");
|
||||
static async Task<string> HashFile(string path, bool sha512, CancellationToken cancellation)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = sha512 ? await SHA512.HashDataAsync(stream, cancellation) : await SHA256.HashDataAsync(stream, cancellation);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
static void Save(string path, object value)
|
||||
{
|
||||
RequireNoLinks(path);
|
||||
var temporary = path + ".tmp";
|
||||
RequireNoLinks(temporary);
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
|
||||
static async Task<CommandResult> Command(string executable, string[] arguments, string source, string work, string logs, string label, CancellationToken cancellation, bool requireSuccess)
|
||||
{
|
||||
Console.WriteLine("[native-guest] " + label);
|
||||
using var commandCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
|
||||
var token = commandCancellation.Token;
|
||||
var start = new ProcessStartInfo(executable) { WorkingDirectory = Directory.Exists(source) ? source : work, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
start.Environment.Clear();
|
||||
foreach (var pair in new Dictionary<string, string>
|
||||
{
|
||||
["PATH"] = Path.Combine(work, "dotnet") + ":/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
["TMPDIR"] = Path.Combine(work, "tmp"),
|
||||
["DOTNET_ROOT"] = Path.Combine(work, "dotnet"), ["DOTNET_CLI_HOME"] = Path.Combine(work, "home"),
|
||||
["NUGET_PACKAGES"] = Path.Combine(work, "packages"), ["TZ"] = "Europe/Berlin",
|
||||
["LANG"] = "en_US.UTF-8", ["LC_ALL"] = "en_US.UTF-8", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
|
||||
["DOTNET_NOLOGO"] = "1", ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", ["MSBUILDDISABLENODEREUSE"] = "1"
|
||||
}) start.Environment[pair.Key] = pair.Value;
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start guest command: " + label);
|
||||
long capturedBytes = 0;
|
||||
async Task<string> Read(StreamReader reader, string stream)
|
||||
{
|
||||
var captured = new StringBuilder();
|
||||
var buffer = new char[8192];
|
||||
using var log = new StreamWriter(Path.Combine(logs, label + "." + stream + ".log"), false, new UTF8Encoding(false));
|
||||
while (true)
|
||||
{
|
||||
var count = await reader.ReadAsync(buffer.AsMemory(), token);
|
||||
if (count == 0) break;
|
||||
if (Interlocked.Add(ref capturedBytes, Encoding.UTF8.GetByteCount(buffer.AsSpan(0, count))) > MaximumLogBytes)
|
||||
{
|
||||
commandCancellation.Cancel();
|
||||
throw new InvalidOperationException(label + " exceeded its combined 8-MiB output budget.");
|
||||
}
|
||||
captured.Append(buffer, 0, count);
|
||||
await log.WriteAsync(buffer.AsMemory(0, count), token);
|
||||
await log.FlushAsync(token);
|
||||
}
|
||||
return captured.ToString();
|
||||
}
|
||||
var stdout = Read(process.StandardOutput, "stdout");
|
||||
var stderr = Read(process.StandardError, "stderr");
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token));
|
||||
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) { }
|
||||
using var killDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await process.WaitForExitAsync(killDeadline.Token); } catch (OperationCanceledException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static void ValidateContracts()
|
||||
{
|
||||
var payload = new Payload(new string('a', 32), new string('b', 40), new string('c', 64), SdkVersion, SdkSha512, ExpectedTests);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
ReadPayload(json);
|
||||
Reject(() => ReadPayload(json.Replace(SdkVersion, "10.0.100", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(SdkSha512, new string('d', 128), StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace("577", "572", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(payload.RunToken, "stale", StringComparison.Ordinal)));
|
||||
Reject(() => ReadPayload(json.Replace(payload.ArchiveSha256, "invalid", StringComparison.Ordinal)));
|
||||
RequirePlatform("14.6.1", "x86_64", "0");
|
||||
Reject(() => RequirePlatform("13.6.1", "x86_64", "0"));
|
||||
Reject(() => RequirePlatform("14.6.1", "arm64", "0"));
|
||||
Reject(() => RequirePlatform("14.6.1", "x86_64", "501"));
|
||||
RequireApfs("<plist><dict><key>FilesystemType</key><string>apfs</string></dict></plist>");
|
||||
Reject(() => RequireApfs("<plist><dict><key>FilesystemType</key><string>hfs</string></dict></plist>"));
|
||||
RequireNativeArtifact("Mach-O 64-bit executable x86_64", "x86_64");
|
||||
Reject(() => RequireNativeArtifact("Mach-O universal binary x86_64 arm64", "x86_64 arm64"));
|
||||
Reject(() => RequireNativeArtifact("ELF 64-bit executable x86_64", "x86_64"));
|
||||
using (var archive = FixtureArchive(payload.SourceCommit)) ValidateArchive(archive, payload.SourceCommit);
|
||||
foreach (var path in new[] { "../escape", "/absolute", "safe/../escape", "safe\\escape", "MeetingAssistant/bin/stale" })
|
||||
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, path); ValidateArchive(archive, payload.SourceCommit); });
|
||||
Reject(() => { using var archive = FixtureArchive(payload.SourceCommit, "link", true); ValidateArchive(archive, payload.SourceCommit); });
|
||||
Reject(() => { using var archive = FixtureArchive(new string('d', 40)); ValidateArchive(archive, payload.SourceCommit); });
|
||||
var started = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
var fixture = FixtureTrx(started);
|
||||
var summary = ValidateTrx(fixture.ToString(), ExpectedTests, started);
|
||||
var plainTrx = Encoding.UTF8.GetBytes(fixture.ToString());
|
||||
var bomTrx = new byte[] { 0xef, 0xbb, 0xbf }.Concat(plainTrx).ToArray();
|
||||
ValidateTrx(bomTrx, ExpectedTests, started);
|
||||
if (SHA256.HashData(plainTrx).SequenceEqual(SHA256.HashData(bomTrx))) throw new InvalidOperationException("TRX raw-byte hashing discarded its BOM.");
|
||||
Reject(() => ValidateTrx(fixture.ToString(), ExpectedTests, started.AddMinutes(2)));
|
||||
XNamespace ns = fixture.Root!.Name.Namespace;
|
||||
var skipped = new XDocument(fixture);
|
||||
skipped.Descendants(ns + "UnitTestResult").First().SetAttributeValue("outcome", "NotExecuted");
|
||||
Reject(() => ValidateTrx(skipped.ToString(), ExpectedTests, started));
|
||||
var missingNative = new XDocument(fixture);
|
||||
missingNative.Descendants(ns + "TestMethod").First().SetAttributeValue("name", "ManagedReplacement");
|
||||
Reject(() => ValidateTrx(missingNative.ToString(), ExpectedTests, started));
|
||||
var duplicate = new XDocument(fixture);
|
||||
duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0");
|
||||
Reject(() => ValidateTrx(duplicate.ToString(), ExpectedTests, started));
|
||||
var repeatedManaged = new XDocument(fixture);
|
||||
repeatedManaged.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("testId", "test-5");
|
||||
Reject(() => ValidateTrx(repeatedManaged.ToString(), ExpectedTests, started));
|
||||
var unexecutedDefinition = new XDocument(fixture);
|
||||
var extraDefinition = new XElement(unexecutedDefinition.Descendants(ns + "UnitTest").Last());
|
||||
extraDefinition.SetAttributeValue("id", "unexecuted-test");
|
||||
unexecutedDefinition.Root!.Element(ns + "TestDefinitions")!.Add(extraDefinition);
|
||||
Reject(() => ValidateTrx(unexecutedDefinition.ToString(), ExpectedTests, started));
|
||||
var missingDefinition = new XDocument(fixture);
|
||||
missingDefinition.Descendants(ns + "UnitTest").Last().Remove();
|
||||
Reject(() => ValidateTrx(missingDefinition.ToString(), ExpectedTests, started));
|
||||
var theoryRows = new XDocument(fixture);
|
||||
foreach (var method in theoryRows.Descendants(ns + "TestMethod").Skip(RequiredNativeTests.Length).Take(2))
|
||||
method.SetAttributeValue("name", "TheoryWithDistinctRowIds");
|
||||
ValidateTrx(theoryRows.ToString(), ExpectedTests, started);
|
||||
var counters = new XDocument(fixture);
|
||||
counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572");
|
||||
Reject(() => ValidateTrx(counters.ToString(), ExpectedTests, started));
|
||||
var aborted = new XDocument(fixture);
|
||||
aborted.Descendants(ns + "ResultSummary").Single().SetAttributeValue("outcome", "Aborted");
|
||||
Reject(() => ValidateTrx(aborted.ToString(), ExpectedTests, started));
|
||||
var artifacts = NativeNames.Select(name => new NativeArtifact(name, new string('d', 64), "x86_64")).ToArray();
|
||||
var result = new FullResult(payload.RunToken, true, payload.SourceCommit, payload.ArchiveSha256, "14.6.1", "x86_64", SdkVersion, ExpectedTests, summary.Total, summary.Executed, summary.Passed, summary.Failed, summary.NotExecuted, summary.NativeTests, artifacts, 0, new string('e', 64), "", started, DateTimeOffset.UtcNow);
|
||||
ValidateResult(result, payload);
|
||||
foreach (var invalid in new[] { result with { Token = new string('f', 32) }, result with { Success = false }, result with { SourceCommit = new string('f', 40) }, result with { ArchiveSha256 = new string('f', 64) }, result with { NotExecuted = 5 }, result with { AudioCodeSignExit = 1 }, result with { NativeTests = RequiredNativeTests[..4] }, result with { NativeArtifacts = artifacts[..3] }, result with { NativeArtifacts = [artifacts[0] with { Architecture = "arm64" }, .. artifacts[1..]] }, result with { TrxSha256 = "" }, result with { SdkVersion = "10.0.100" }, result with { OsVersion = "13.6.1" }, result with { StartedUtc = started.AddHours(-1) }, result with { StartedUtc = started.AddHours(-1), CompletedUtc = started.AddHours(-1).AddSeconds(1) } })
|
||||
Reject(() => ValidateResult(invalid, payload));
|
||||
var temporary = Path.Combine(OperatingSystem.IsMacOS() ? "/private/tmp" : Path.GetTempPath(), "meeting-assistant-guest-validation-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
RequireAbsent(temporary);
|
||||
Directory.CreateDirectory(temporary);
|
||||
Reject(() => RequireAbsent(temporary));
|
||||
var file = Path.Combine(temporary, "fresh.trx");
|
||||
File.WriteAllText(file, "fixture");
|
||||
RequireFreshFile(file, started);
|
||||
Reject(() => RequireFreshFile(file, started, 1));
|
||||
File.SetLastWriteTimeUtc(file, started.UtcDateTime.AddMinutes(-5));
|
||||
Reject(() => RequireFreshFile(file, started));
|
||||
File.Delete(file);
|
||||
}
|
||||
finally { if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true); }
|
||||
}
|
||||
|
||||
static MemoryStream FixtureArchive(string commit, string? extra = null, bool link = false)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new TarWriter(stream, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
writer.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary<string, string> { ["comment"] = commit }));
|
||||
writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "MeetingAssistant.Tests/MeetingAssistant.Tests.csproj") { DataStream = new MemoryStream(Encoding.UTF8.GetBytes("<Project />")) });
|
||||
if (extra is not null)
|
||||
{
|
||||
var entry = new PaxTarEntry(link ? TarEntryType.SymbolicLink : TarEntryType.RegularFile, extra);
|
||||
if (link) entry.LinkName = "../outside";
|
||||
else entry.DataStream = new MemoryStream([1]);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
static XDocument FixtureTrx(DateTimeOffset started)
|
||||
{
|
||||
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
|
||||
var definitions = new XElement(ns + "TestDefinitions");
|
||||
var results = new XElement(ns + "Results");
|
||||
for (var index = 0; index < ExpectedTests; index++)
|
||||
{
|
||||
var identity = index < RequiredNativeTests.Length ? RequiredNativeTests[index] : "MeetingAssistant.Tests.ManagedTests.Test" + index;
|
||||
var separator = identity.LastIndexOf('.');
|
||||
definitions.Add(new XElement(ns + "UnitTest", new XAttribute("id", "test-" + index), new XElement(ns + "TestMethod", new XAttribute("className", identity[..separator] + ", MeetingAssistant.Tests"), new XAttribute("name", identity[(separator + 1)..]))));
|
||||
results.Add(new XElement(ns + "UnitTestResult", new XAttribute("testId", "test-" + index), new XAttribute("executionId", "execution-" + index), new XAttribute("outcome", "Passed")));
|
||||
}
|
||||
return new XDocument(new XElement(ns + "TestRun", new XElement(ns + "Times", new XAttribute("start", started.ToString("O")), new XAttribute("finish", started.AddSeconds(1).ToString("O"))), definitions, results, new XElement(ns + "ResultSummary", new XAttribute("outcome", "Completed"), new XElement(ns + "Counters", new XAttribute("total", ExpectedTests), new XAttribute("executed", ExpectedTests), new XAttribute("passed", ExpectedTests), new XAttribute("failed", 0), new XAttribute("notExecuted", 0)))));
|
||||
}
|
||||
static void Reject(Action action)
|
||||
{
|
||||
try { action(); }
|
||||
catch (InvalidOperationException) { return; }
|
||||
throw new InvalidOperationException("Contract validation accepted an invalid or stale fixture.");
|
||||
}
|
||||
sealed record Payload(string RunToken, string SourceCommit, string ArchiveSha256, string SdkVersion, string SdkSha512, int ExpectedTests);
|
||||
sealed record NativeArtifact(string Name, string Sha256, string Architecture);
|
||||
sealed record TestSummary(int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests);
|
||||
sealed record FullResult(string Token, bool Success, string SourceCommit, string ArchiveSha256, string OsVersion, string Architecture, string SdkVersion, int ExpectedTests, int Total, int Executed, int Passed, int Failed, int NotExecuted, string[] NativeTests, NativeArtifact[] NativeArtifacts, int AudioCodeSignExit, string TrxSha256, string Reason, DateTimeOffset StartedUtc, DateTimeOffset CompletedUtc);
|
||||
sealed record CommandResult(int ExitCode, string Output, string Error);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Apple Recovery has Bash before any SDK is installed. Preserve the original
|
||||
# daemon under its launchd label/PID while the unchanged read-only probe runs.
|
||||
/bin/bash /Volumes/installstate/readiness.sh &
|
||||
exec /usr/libexec/recoveryosd
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Apple pre-.NET boot seam, sourced in Recovery and on installed first boot.
|
||||
# A match requires the exact run-owned emulated serial, one whole writable 64-GiB disk.
|
||||
verify_owned_disk() {
|
||||
local disk="$1" state="$2" token="$3" info serial matches bytes
|
||||
[[ "$disk" =~ ^/dev/disk[0-9]+$ && "$token" =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
[ "$(cat "$state/run.owner" 2>/dev/null)" = "$token" ] || return 1
|
||||
serial="${token:0:20}"
|
||||
/usr/sbin/diskutil list physical > "$state/disk-list-current.log" 2>&1 || return 1
|
||||
/usr/bin/grep -Eq "^$disk[[:space:]].*physical" "$state/disk-list-current.log" || return 1
|
||||
/usr/sbin/diskutil info "$disk" > "$state/disk-info-current.log" 2>&1 || return 1
|
||||
info=$(cat "$state/disk-info-current.log")
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*Whole:[[:space:]]*Yes' || return 1
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes' && return 1
|
||||
printf '%s\n' "$info" | /usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*No' || return 1
|
||||
bytes=$(printf '%s\n' "$info" | /usr/bin/sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p')
|
||||
[ "$bytes" = 68719476736 ] || return 1
|
||||
/usr/sbin/ioreg -r -c IOBlockStorageDevice -l -w 0 > "$state/disk-ownership-ioreg.log" 2>&1 || return 1
|
||||
matches=$(/usr/bin/awk -v expected="$serial" -v disk="${disk#/dev/}" '
|
||||
function finish_root() { if (serialCount == 1 && serial == expected && ownedDisk) found++ }
|
||||
/^\+-o/ { finish_root(); serial=""; serialCount=0; ownedDisk=0 }
|
||||
/"Serial Number"[[:space:]]*=[[:space:]]*"/ {
|
||||
serialCount++; serial=$0; sub(/^.*"Serial Number"[[:space:]]*=[[:space:]]*"/, "", serial); sub(/".*$/, "", serial); sub(/[[:space:]]+$/, "", serial)
|
||||
}
|
||||
/"BSD Name"[[:space:]]*=[[:space:]]*"/ {
|
||||
name=$0; sub(/^.*"BSD Name"[[:space:]]*=[[:space:]]*"/, "", name); sub(/".*$/, "", name)
|
||||
if (name == disk) ownedDisk=1
|
||||
}
|
||||
END { finish_root(); print found+0 }
|
||||
' "$state/disk-ownership-ioreg.log")
|
||||
[ "$matches" = 1 ] || return 1
|
||||
printf '[owned-disk] %s serial=%s bytes=%s\n' "$disk" "$serial" "$bytes"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
# Apple LaunchDaemon bootstrap before the guest .NET SDK exists.
|
||||
# Runs only inside the isolated owned VM; installs CLT and expands the pinned SDK.
|
||||
set -u
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
PROOF_TOKEN="${1:-}"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
WORK="/private/var/tmp/meeting-assistant-native-$PROOF_TOKEN"
|
||||
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || exit 1
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || exit 1
|
||||
exec >> "$STATE_DIR/firstboot.log" 2>&1
|
||||
|
||||
phase() {
|
||||
printf '{"token":"%s","phase":"%s","updatedUtc":"%s"}\n' "$PROOF_TOKEN" "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATE_DIR/guest-phase.json.tmp"
|
||||
mv -f "$STATE_DIR/guest-phase.json.tmp" "$STATE_DIR/guest-phase.json"
|
||||
}
|
||||
fail() { printf '[firstboot] ERROR: %s\n' "$1"; phase bootstrap-failed; exit 1; }
|
||||
require_installed_macos_version() {
|
||||
# Numeric sw_vers product release with a major version of at least 14.
|
||||
[[ "${1:-}" =~ ^(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,})\.[0-9]+(\.[0-9]+)?$ ]]
|
||||
}
|
||||
phase toolchain-installing
|
||||
echo '[firstboot] verifying installed OS, APFS and owned physical store'
|
||||
uname -a; id
|
||||
installed_version=$(/usr/bin/sw_vers -productVersion) || fail installed_os_version_failed
|
||||
require_installed_macos_version "$installed_version" || fail installed_macos_14_or_newer_required
|
||||
printf '[firstboot] installed macOS version=%s\n' "$installed_version"
|
||||
[ "$(id -u)" = 0 ] && [ "$(uname -m)" = x86_64 ] || fail wrong_guest_platform
|
||||
/usr/sbin/diskutil info -plist / > "$STATE_DIR/installed-root.plist" || fail root_diskutil_failed
|
||||
[ "$(/usr/libexec/PlistBuddy -c 'Print :FilesystemType' "$STATE_DIR/installed-root.plist")" = apfs ] || fail root_is_not_installed_apfs
|
||||
container=$(/usr/libexec/PlistBuddy -c 'Print :APFSContainerReference' "$STATE_DIR/installed-root.plist") || fail root_container_missing
|
||||
/usr/sbin/diskutil apfs list -plist > "$STATE_DIR/apfs-containers.plist" || fail apfs_list_failed
|
||||
index=0
|
||||
physical=""
|
||||
while reference=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:ContainerReference" "$STATE_DIR/apfs-containers.plist" 2>/dev/null); do
|
||||
if [ "$reference" = "$container" ]; then
|
||||
[ -z "$physical" ] || fail ambiguous_root_containers
|
||||
physical=$(/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:0:DeviceIdentifier" "$STATE_DIR/apfs-containers.plist") || fail physical_store_missing
|
||||
/usr/libexec/PlistBuddy -c "Print :Containers:$index:PhysicalStores:1" "$STATE_DIR/apfs-containers.plist" >/dev/null 2>&1 && fail multiple_physical_stores
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
[[ "$physical" =~ ^disk[0-9]+s[0-9]+$ ]] || fail invalid_physical_store
|
||||
/usr/sbin/diskutil info -plist "/dev/$physical" > "$STATE_DIR/physical-store.plist" || fail physical_store_info_failed
|
||||
whole=$(/usr/libexec/PlistBuddy -c 'Print :ParentWholeDisk' "$STATE_DIR/physical-store.plist") || fail physical_parent_missing
|
||||
. "$STATE_DIR/macos-native-disk-guard.sh"
|
||||
verify_owned_disk "/dev/$whole" "$STATE_DIR" "$PROOF_TOKEN" || fail installed_root_is_not_the_owned_64g_disk
|
||||
|
||||
# The phase has an independent 30-minute watchdog; host outer deadline is 180 minutes.
|
||||
parent=$$
|
||||
(
|
||||
trap 'kill "$sleeper" 2>/dev/null || :; exit 0' TERM INT
|
||||
# Toolchain watchdog: 30 minutes, also bounded by the host's 172-minute total.
|
||||
sleep 1800 & sleeper=$!; wait "$sleeper"; kill -TERM "$parent" 2>/dev/null || :
|
||||
) & watchdog=$!
|
||||
clt_marker="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress"
|
||||
trap 'rm -f "$clt_marker"; kill -TERM "$watchdog" 2>/dev/null || :; wait "$watchdog" 2>/dev/null || :' EXIT
|
||||
trap 'fail toolchain_deadline_or_cancellation' TERM INT
|
||||
[ ! -e "$WORK" ] || fail guest_work_directory_already_exists
|
||||
mkdir -p "$WORK" || fail guest_work_directory_failed
|
||||
printf '%s\n' "$PROOF_TOKEN" > "$WORK/run.owner" || fail guest_work_owner_failed
|
||||
free_kib=$(df -Pk "$WORK" | awk 'NR==2 {print $4}')
|
||||
[[ "$free_kib" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || fail insufficient_existing_guest_free_space
|
||||
|
||||
echo '[firstboot] installing a compatible Apple CLT catalog entry without a GUI'
|
||||
touch "$clt_marker" || fail clt_marker_failed
|
||||
/usr/sbin/softwareupdate -l > "$STATE_DIR/clt-catalog.log" 2>&1 || fail clt_catalog_failed
|
||||
label=$(grep -B 1 -E 'Command Line Tools' "$STATE_DIR/clt-catalog.log" | awk -F'*' '/^ *\*/ {print $2}' | sed -e 's/^ *Label: //' -e 's/^ *//' | sort -V | tail -n 1)
|
||||
[ -n "$label" ] || fail no_compatible_headless_clt_label
|
||||
printf '[firstboot] selected CLT: %s\n' "$label"
|
||||
/usr/sbin/softwareupdate -i "$label" > "$STATE_DIR/clt-install.log" 2>&1 || fail clt_install_failed
|
||||
/usr/bin/xcode-select --switch /Library/Developer/CommandLineTools || fail clt_switch_failed
|
||||
/usr/sbin/pkgutil --pkg-info=com.apple.pkg.CLTools_Executables || fail clt_receipt_failed
|
||||
/usr/bin/xcrun --find swiftc || fail swiftc_missing
|
||||
/usr/bin/xcrun swiftc --version || fail swiftc_version_failed
|
||||
{
|
||||
/usr/bin/xcrun --sdk macosx --show-sdk-version &&
|
||||
/usr/bin/xcrun --sdk macosx --show-sdk-path
|
||||
} > "$STATE_DIR/clt-sdk.log" 2>&1 || fail clt_sdk_identity_failed
|
||||
printf 'import AppKit\nimport AVFoundation\nimport ScreenCaptureKit\nimport EventKit\nimport WebKit\nprint("native SDK ready")\n' > "$WORK/toolchain-smoke.swift"
|
||||
/usr/bin/xcrun swiftc -target x86_64-apple-macos13.0 "$WORK/toolchain-smoke.swift" -o "$WORK/toolchain-smoke" || fail native_framework_compile_failed
|
||||
"$WORK/toolchain-smoke" || fail native_framework_execution_failed
|
||||
rm -f "$clt_marker"
|
||||
|
||||
echo '[firstboot] validating and expanding the pinned .NET SDK on owned APFS'
|
||||
expected_sdk=33401b4a2da8554e3306db6072ea8569d9fcc608509c271e0aa4b39e7cc432da3631f14e7e1e2445d67d72550d18ce44a8bbd2382a756867ad2edab6b1c963c0
|
||||
actual_sdk=$(shasum -a 512 "$STATE_DIR/sdk.tar.gz" | awk '{print $1}')
|
||||
[ "$actual_sdk" = "$expected_sdk" ] || fail sdk_hash_mismatch
|
||||
mkdir "$WORK/dotnet" || fail sdk_directory_failed
|
||||
tar -xzf "$STATE_DIR/sdk.tar.gz" -C "$WORK/dotnet" || fail sdk_extract_failed
|
||||
[ "$("$WORK/dotnet/dotnet" --version)" = 10.0.401 ] || fail sdk_version_mismatch
|
||||
# Guest C# owns build/test deadlines from this point; stop the CLT-only watchdog.
|
||||
kill -TERM "$watchdog" 2>/dev/null || :
|
||||
wait "$watchdog" 2>/dev/null || :
|
||||
trap - TERM INT
|
||||
"$WORK/dotnet/dotnet" run --file "$STATE_DIR/MacOsNativeGuest.cs" -- --run --state "$STATE_DIR" --work "$WORK"
|
||||
result=$?
|
||||
(( result == 0 )) || fail native_tests_failed
|
||||
exit 0
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Full-only pre-.NET seam. Claim one persistent owned probe before forking;
|
||||
# launchd then execs the original Apple daemon, including on a real failure.
|
||||
set -u
|
||||
PROOF_TOKEN="@@PROOF_TOKEN@@"
|
||||
STATE_DIR="/Volumes/installstate"
|
||||
MARKER="$STATE_DIR/probe.started"
|
||||
|
||||
bootstrap_failed() {
|
||||
printf '[full-bootstrap] ERROR: %s\n' "$1" >&2
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
|
||||
printf '{"token":"%s","phase":"bootstrap-failed","reason":"%s"}\n' "$PROOF_TOKEN" "$1" > "$STATE_DIR/guest-phase.bootstrap.$$.tmp" &&
|
||||
/bin/mv -f "$STATE_DIR/guest-phase.bootstrap.$$.tmp" "$STATE_DIR/guest-phase.json"
|
||||
}
|
||||
|
||||
wait_for_owned_state() {
|
||||
local count=0
|
||||
while :; do
|
||||
if [ -e "$STATE_DIR/run.owner" ] || [ -L "$STATE_DIR/run.owner" ]; then
|
||||
[ -f "$STATE_DIR/run.owner" ] && [ ! -L "$STATE_DIR/run.owner" ] &&
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || {
|
||||
bootstrap_failed foreign_state_owner
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
if (( count >= 120 )); then
|
||||
bootstrap_failed state_share_mount_timeout
|
||||
return 1
|
||||
fi
|
||||
/sbin/mount_9p installstate >/dev/null 2>&1 || :
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
owns_probe_marker() {
|
||||
local record extra
|
||||
[ -f "$MARKER" ] && [ ! -L "$MARKER" ] || return 1
|
||||
{
|
||||
IFS= read -r record || return 1
|
||||
if IFS= read -r extra || [ -n "$extra" ]; then return 1; fi
|
||||
} < "$MARKER"
|
||||
[ "$record" = "$PROOF_TOKEN:$source_commit" ]
|
||||
}
|
||||
|
||||
claim_probe() {
|
||||
[[ "$PROOF_TOKEN" =~ ^[0-9a-f]{32}$ ]] || { bootstrap_failed invalid_probe_token; return 1; }
|
||||
[ "$(cat "$STATE_DIR/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || { bootstrap_failed foreign_state_owner; return 1; }
|
||||
source_commit=$(cat "$STATE_DIR/source.commit" 2>/dev/null) || { bootstrap_failed source_commit_missing; return 1; }
|
||||
[[ "$source_commit" =~ ^[0-9a-f]{40}$ ]] || { bootstrap_failed source_commit_invalid; return 1; }
|
||||
if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then
|
||||
owns_probe_marker || { bootstrap_failed foreign_or_invalid_probe_marker; return 1; }
|
||||
return 2
|
||||
fi
|
||||
# Keep partial/failed markers: an incomplete first start is an error, no retry.
|
||||
if ! ( set -o noclobber; printf '%s:%s\n' "$PROOF_TOKEN" "$source_commit" > "$MARKER" ); then
|
||||
bootstrap_failed probe_marker_write_failed
|
||||
return 1
|
||||
fi
|
||||
owns_probe_marker || { bootstrap_failed probe_marker_incomplete; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
if wait_for_owned_state && claim_probe; then
|
||||
(
|
||||
/bin/bash "$STATE_DIR/readiness.sh"
|
||||
child_exit=$?
|
||||
(( child_exit == 0 )) || bootstrap_failed first_probe_child_failed
|
||||
) &
|
||||
fi
|
||||
exec /usr/libexec/recoveryosd
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/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
|
||||
MAX_OUTPUT_BYTES=524288
|
||||
TIMER_FIFO="/tmp/native-diagnostic-$PROOF_TOKEN-$$.fifo"
|
||||
PENDING_OUTPUTS=()
|
||||
ACTIVE_COMMAND=""
|
||||
ACTIVE_TIMER=""
|
||||
# BEGIN disk IPC diagnostic
|
||||
ACTIVE_OBSERVER=""
|
||||
# END disk IPC diagnostic
|
||||
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
|
||||
exec 3>> "$PROOF_LOG" || exit 1
|
||||
rm -f "$RESULT" "$RESULT.tmp"
|
||||
printf '[proof-token] %s\n' "$PROOF_TOKEN" >&3
|
||||
|
||||
finish() {
|
||||
local success="$1" reason="$2"
|
||||
flush_outputs || { success=false; reason=diagnostic_log_budget_exceeded; }
|
||||
printf '[proof-result] %s: %s\n' "$success" "$reason" >&3
|
||||
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
|
||||
exec 9>&-
|
||||
[ ! -p "$TIMER_FIFO" ] || /bin/rm -f "$TIMER_FIFO"
|
||||
# Keep the service alive for the bounded host diagnostic to capture evidence.
|
||||
while :; do sleep 60; done
|
||||
}
|
||||
|
||||
init_timer_fifo() {
|
||||
# Recovery has Bash 3.2 before any SDK is installed. Its read timeout uses
|
||||
# alarm(), avoiding a separate sleep process for every command and grace period.
|
||||
[ ! -e "$TIMER_FIFO" ] || exit 1
|
||||
/usr/bin/mkfifo -m 600 "$TIMER_FIFO" || exit 1
|
||||
exec 9<> "$TIMER_FIFO" || exit 1
|
||||
}
|
||||
|
||||
flush_outputs() {
|
||||
(( ${#PENDING_OUTPUTS[@]} > 0 )) || return 0
|
||||
local started=$SECONDS sizes="/tmp/native-diagnostic-$$.sizes" proof_size output_size raw_size
|
||||
local raw_count=0 raw_valid=1 pending_count=${#PENDING_OUTPUTS[@]}
|
||||
local bounded="/tmp/native-diagnostic-$$.flush"
|
||||
# One bounded native copy per group, rather than tail/stat startup per command.
|
||||
# Keep native byte-oriented copying: Bash 3.2 read -n would read large outputs
|
||||
# one byte per system call. Small scalar reads below have a separate tight bound.
|
||||
/usr/bin/tail -c "$MAX_OUTPUT_BYTES" "${PENDING_OUTPUTS[@]}" > "$bounded" || return 1
|
||||
/usr/bin/stat -f '%z' "$PROOF_LOG" "$bounded" "${PENDING_OUTPUTS[@]}" > "$sizes" || return 1
|
||||
{
|
||||
IFS= read -r proof_size; IFS= read -r output_size
|
||||
while IFS= read -r raw_size; do
|
||||
raw_count=$((raw_count + 1))
|
||||
[[ "$raw_size" =~ ^[0-9]+$ ]] && (( raw_size <= MAX_OUTPUT_BYTES )) || raw_valid=0
|
||||
done
|
||||
} < "$sizes"
|
||||
PENDING_OUTPUTS=()
|
||||
[[ "$proof_size" =~ ^[0-9]+$ && "$output_size" =~ ^[0-9]+$ ]] || return 1
|
||||
(( raw_valid == 1 && raw_count == pending_count )) || return 1
|
||||
(( proof_size + output_size + 1024 <= MAX_LOG_BYTES )) || return 1
|
||||
/bin/cat "$bounded" >&3 || return 1
|
||||
printf '\n[proof-flush] outputs-bytes=%s elapsed=%ss\n' "$output_size" "$((SECONDS - started))" >&3
|
||||
}
|
||||
|
||||
read_scalar() {
|
||||
local value status
|
||||
# All three values are short native machine/uid/version scalars. Reject excess
|
||||
# content instead of accepting a truncated first line as a successful gate.
|
||||
IFS= read -r -n 65 -d '' value < "$LAST_OUTPUT"; status=$?
|
||||
# EOF is mandatory: the byte bound or a NUL delimiter must never hide a suffix.
|
||||
(( status == 1 && ${#value} < 65 )) || return 1
|
||||
value=${value%$'\n'}
|
||||
[[ "$value" != *$'\n'* ]] || return 1
|
||||
SCALAR="$value"
|
||||
}
|
||||
|
||||
# BEGIN native SystemVersion plist request helpers
|
||||
read_native_system_version() {
|
||||
# Recovery has Bash 3.2 before .NET. The numerical candidate is provisional;
|
||||
# only the owned host's complete XML/evidence binding can qualify readiness.
|
||||
local LC_ALL=C source="/System/Library/CoreServices/SystemVersion.plist"
|
||||
local value status owner candidate remainder started=$SECONDS invalid_bytes
|
||||
local raw="$STATE_DIR/native-system-version.plist"
|
||||
local ready="$STATE_DIR/native-system-version.request"
|
||||
NATIVE_VERSION_ERROR=native_system_version_read_failed
|
||||
printf '[native-version-start] method=guest-file/host-xml source=%s seconds=%s\n' "$source" "$started" >&3
|
||||
IFS= read -r -n 81 -d '' owner < "$STATE_DIR/run.owner"; status=$?
|
||||
(( status == 1 && ${#owner} <= 80 )) &&
|
||||
[ "$owner" = "$PROOF_TOKEN"$'\n' ] || { NATIVE_VERSION_ERROR=native_system_version_foreign_owner; return 1; }
|
||||
[ ! -e "$ready" ] && [ ! -L "$ready" ] &&
|
||||
[ ! -e "$raw" ] && [ ! -L "$raw" ] || { NATIVE_VERSION_ERROR=native_system_version_stale_exchange; return 1; }
|
||||
[ -f "$source" ] || return 1
|
||||
IFS= read -r -n 4097 -d '' value < "$source"; status=$?
|
||||
# EOF is mandatory. NUL stops read with status 0; 4097 is the overbound sentinel.
|
||||
(( status == 1 && ${#value} > 0 && ${#value} <= 4096 )) || { NATIVE_VERSION_ERROR=native_system_version_invalid_bytes; return 1; }
|
||||
invalid_bytes=${value//$'\t'/}
|
||||
invalid_bytes=${invalid_bytes//$'\r'/}
|
||||
invalid_bytes=${invalid_bytes//$'\n'/}
|
||||
[[ "$invalid_bytes" =~ [[:cntrl:]] ]] && { NATIVE_VERSION_ERROR=native_system_version_binary; return 1; }
|
||||
printf '%s' "$value" > "$raw" || return 1
|
||||
# Publish this final marker only after the complete raw write has closed.
|
||||
printf '%s\n%s\n%s\nready:%s\n' "$PROOF_TOKEN" "$source" "${#value}" "$PROOF_TOKEN" > "$ready" || return 1
|
||||
printf '[native-version-request] method=guest-file/host-xml source=%s bytes=%s seconds=%s\n' "$source" "${#value}" "$SECONDS" >&3
|
||||
# This is a candidate from exactly these bytes, not an XML validity check.
|
||||
NATIVE_VERSION_ERROR=native_system_version_candidate_invalid
|
||||
[[ "$value" == *'<key>ProductVersion</key>'* ]] || return 1
|
||||
remainder=${value#*'<key>ProductVersion</key>'}
|
||||
remainder=${remainder#"${remainder%%[![:space:]]*}"}
|
||||
[[ "$remainder" == '<string>'* ]] || return 1
|
||||
remainder=${remainder#'<string>'}
|
||||
candidate=${remainder%%'</string>'*}
|
||||
[ "$candidate" != "$remainder" ] && [[ "$candidate" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || return 1
|
||||
SCALAR="$candidate"
|
||||
NATIVE_VERSION_ERROR=""
|
||||
printf '[native-version-candidate] method=guest-file/host-xml source=%s candidate=%s qualified=false elapsed=%ss\n' "$source" "$SCALAR" "$((SECONDS - started))" >&3
|
||||
return 0
|
||||
}
|
||||
# END native SystemVersion plist request helpers
|
||||
# BEGIN disk IPC diagnostic
|
||||
# Optional observations have their own child/timer ownership. Stack/thread
|
||||
# observations target only this probe's live diskutil; none qualifies readiness.
|
||||
observe_disk_query() {
|
||||
local disk_process="$1" output="$2" observation_child="" observation_timer=""
|
||||
local stack_output="$STATE_DIR/diskutil-stack.txt"
|
||||
cancel_observation() {
|
||||
trap '' TERM INT
|
||||
if [ -n "$observation_child" ]; then
|
||||
kill -TERM "$observation_child" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$observation_child" 2>/dev/null || :
|
||||
wait "$observation_child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$observation_timer" ] || { kill -TERM "$observation_timer" 2>/dev/null || :; wait "$observation_timer" 2>/dev/null || :; }
|
||||
printf '[disk-observation] stopped after the owned disk query\n' >> "$output"
|
||||
exit 143
|
||||
}
|
||||
observe_command() {
|
||||
local name="$1" status started=$SECONDS
|
||||
shift
|
||||
printf '\n[disk-observation-command] %s:' "$name" >> "$output"
|
||||
printf ' %s' "$@" >> "$output"
|
||||
printf '\n' >> "$output"
|
||||
"$@" >> "$output" 2>&1 &
|
||||
observation_child=$!
|
||||
(
|
||||
trap 'exit 0' TERM INT
|
||||
IFS= read -r -t 60 -u 9 unused || :
|
||||
printf '[disk-observation-timeout] %s child=%s limit=60s\n' "$name" "$observation_child" >> "$output"
|
||||
kill -TERM "$observation_child" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$observation_child" 2>/dev/null || :
|
||||
) &
|
||||
observation_timer=$!
|
||||
wait "$observation_child"; status=$?
|
||||
kill -TERM "$observation_timer" 2>/dev/null || :
|
||||
wait "$observation_timer" 2>/dev/null || :
|
||||
printf '[disk-observation-exit] %s status=%s elapsed=%ss\n' "$name" "$status" "$((SECONDS - started))" >> "$output"
|
||||
OBSERVATION_EXIT="$status"
|
||||
observation_child=""; observation_timer=""
|
||||
}
|
||||
observe_live_command() {
|
||||
local name="$1"
|
||||
shift
|
||||
if ! kill -0 "$disk_process" 2>/dev/null; then
|
||||
printf '[disk-observation-unavailable] %s: owned diskutil already exited\n' "$name" >> "$output"
|
||||
elif [ ! -x "$1" ]; then
|
||||
printf '[disk-observation-unavailable] %s: %s is unavailable\n' "$name" "$1" >> "$output"
|
||||
else
|
||||
observe_command "$name" "$@"
|
||||
fi
|
||||
}
|
||||
trap cancel_observation TERM INT
|
||||
printf '[disk-observation] owned-diskutil-child=%s parent-shell=%s\n' "$disk_process" "$$" >> "$output"
|
||||
observe_live_command diskutil-native-before "$STATE_DIR/native-process-probe-x86_64" "$disk_process" "$$"
|
||||
if [ ! -x /usr/bin/sample ]; then
|
||||
printf '[disk-observation-unavailable] diskutil-stack: /usr/bin/sample is unavailable\n' >> "$output"
|
||||
elif ! kill -0 "$disk_process" 2>/dev/null; then
|
||||
printf '[disk-observation-unavailable] diskutil-stack: owned diskutil already exited\n' >> "$output"
|
||||
elif [ -e "$stack_output" ] || [ -L "$stack_output" ]; then
|
||||
printf '[disk-observation-unavailable] diskutil-stack: output already exists\n' >> "$output"
|
||||
elif : > "$stack_output"; then
|
||||
printf '[disk-stack-attempt] owned-diskutil-child=%s duration=1s interval=100ms limit=60s output=%s observation-only=true\n' "$disk_process" "$stack_output" >> "$output"
|
||||
observe_command diskutil-stack /usr/bin/sample "$disk_process" 1 100 -file "$stack_output"
|
||||
printf '[disk-stack-result] status=%s observation-only=true; raw report is captured by the Linux host\n' "$OBSERVATION_EXIT" >> "$output"
|
||||
else
|
||||
printf '[disk-observation-unavailable] diskutil-stack: output cannot be created\n' >> "$output"
|
||||
fi
|
||||
observe_live_command storagekit-live /bin/launchctl print system/com.apple.storagekitd
|
||||
printf '[disk-observation-pause] limit=180s; canceled when owned query ends\n' >> "$output"
|
||||
IFS= read -r -t 180 -u 9 unused || :
|
||||
observe_live_command diskutil-native-after "$STATE_DIR/native-process-probe-x86_64" "$disk_process" "$$"
|
||||
}
|
||||
|
||||
stop_disk_observation() {
|
||||
[ -n "$ACTIVE_OBSERVER" ] || return 0
|
||||
kill -TERM "$ACTIVE_OBSERVER" 2>/dev/null || :
|
||||
wait "$ACTIVE_OBSERVER" 2>/dev/null || :
|
||||
ACTIVE_OBSERVER=""
|
||||
}
|
||||
|
||||
# END disk IPC diagnostic
|
||||
cancel_probe() {
|
||||
trap '' TERM INT
|
||||
# BEGIN disk IPC diagnostic
|
||||
stop_disk_observation
|
||||
# END disk IPC diagnostic
|
||||
if [ -n "$ACTIVE_COMMAND" ]; then
|
||||
kill -TERM "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
wait "$ACTIVE_COMMAND" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$ACTIVE_TIMER" ] || { kill -TERM "$ACTIVE_TIMER" 2>/dev/null || :; wait "$ACTIVE_TIMER" 2>/dev/null || :; }
|
||||
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||
finish false probe_cancelled
|
||||
}
|
||||
|
||||
run_command() {
|
||||
local name="$1"
|
||||
shift
|
||||
local process timer exit_code started waited command_limit=45
|
||||
# Run 4161: even native uname/ps startup took 34-42s under TCG.
|
||||
# Isolate only the failed UID gate; every other watchdog remains unchanged.
|
||||
[[ "$name" != uid ]] || command_limit=180
|
||||
# BEGIN disk IPC diagnostic
|
||||
# Run 4195: required uname -m exceeded 45s and returned 143 without output.
|
||||
# Extend this architecture gate to the existing UID limit; ordinary commands stay 45s.
|
||||
[[ "$name" != architecture ]] || command_limit=180
|
||||
if [[ "$name" == disks && "${attempt:-0}" == 1 ]]; then command_limit=600; fi
|
||||
# END disk IPC diagnostic
|
||||
LAST_OUTPUT="/tmp/native-diagnostic-$name.out"
|
||||
printf '\n[proof-command] %s:' "$name" >&3
|
||||
printf ' %s' "$@" >&3
|
||||
printf '\n' >&3
|
||||
started=$SECONDS
|
||||
"$@" > "$LAST_OUTPUT" 2>&1 &
|
||||
process=$!
|
||||
ACTIVE_COMMAND="$process"
|
||||
printf '[proof-start] %s child=%s shell=%s parent=%s seconds=%s\n' "$name" "$process" "$$" "$PPID" "$started" >&3
|
||||
printf '[proof-limit] %s %ss\n' "$name" "$command_limit" >&3
|
||||
(
|
||||
trap 'exit 0' TERM INT
|
||||
IFS= read -r -t "$command_limit" -u 9 unused || :
|
||||
printf '[proof-timeout] %s child=%s elapsed=%ss signal=TERM\n' "$name" "$process" "$((SECONDS - started))" >&3
|
||||
kill -TERM "$process" 2>/dev/null || :
|
||||
IFS= read -r -t 2 -u 9 unused || :
|
||||
kill -KILL "$process" 2>/dev/null || :
|
||||
) &
|
||||
timer=$!
|
||||
ACTIVE_TIMER="$timer"
|
||||
# BEGIN disk IPC diagnostic
|
||||
if [[ "$name" == disks && "$attempt" == 1 ]]; then
|
||||
local observation_output="/tmp/native-diagnostic-disk-observation.out"
|
||||
: > "$observation_output"
|
||||
observe_disk_query "$process" "$observation_output" &
|
||||
ACTIVE_OBSERVER=$!
|
||||
printf '[disk-observation-start] observer=%s owned-diskutil-child=%s\n' "$ACTIVE_OBSERVER" "$process" >&3
|
||||
PENDING_OUTPUTS+=("$observation_output")
|
||||
fi
|
||||
# END disk IPC diagnostic
|
||||
wait "$process"
|
||||
exit_code=$?
|
||||
waited=$SECONDS
|
||||
# Includes fork/exec/wait, but excludes timer cleanup and evidence copying.
|
||||
printf '[proof-native-wait] %s child=%s elapsed=%ss exit=%s\n' "$name" "$process" "$((waited - started))" "$exit_code" >&3
|
||||
kill -TERM "$timer" 2>/dev/null || :
|
||||
wait "$timer" 2>/dev/null || :
|
||||
# BEGIN disk IPC diagnostic
|
||||
stop_disk_observation
|
||||
# END disk IPC diagnostic
|
||||
ACTIVE_COMMAND=""; ACTIVE_TIMER=""
|
||||
printf '[proof-cleanup] %s child=%s elapsed=%ss total=%ss\n' "$name" "$process" "$((SECONDS - waited))" "$((SECONDS - started))" >&3
|
||||
printf '[proof-exit] %s\n' "$exit_code" >&3
|
||||
LAST_EXIT="$exit_code"
|
||||
PENDING_OUTPUTS+=("$LAST_OUTPUT")
|
||||
return 0
|
||||
}
|
||||
|
||||
diagnose_failure() {
|
||||
run_command kernel /usr/bin/uname -a
|
||||
run_command account /usr/bin/id
|
||||
run_command context /usr/sbin/sysctl kern.bootargs 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
|
||||
}
|
||||
|
||||
fail_probe() {
|
||||
local reason="$1"
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
diagnose_failure
|
||||
finish false "$reason"
|
||||
}
|
||||
|
||||
init_timer_fifo
|
||||
trap cancel_probe TERM INT
|
||||
|
||||
# Test the required native gates before optional process/CPU diagnostics.
|
||||
run_command architecture /usr/bin/uname -m
|
||||
(( LAST_EXIT == 0 )) || fail_probe architecture_probe_failed
|
||||
read_scalar || fail_probe architecture_output_invalid
|
||||
architecture="$SCALAR"
|
||||
[ "$architecture" = x86_64 ] || fail_probe unexpected_guest_architecture
|
||||
run_command uid /usr/bin/id -u
|
||||
(( LAST_EXIT == 0 )) || fail_probe uid_probe_failed
|
||||
read_scalar || fail_probe uid_output_invalid
|
||||
uid="$SCALAR"
|
||||
[ "$uid" = 0 ] || fail_probe recovery_account_not_root
|
||||
# BEGIN native SystemVersion plist getter
|
||||
read_native_system_version || fail_probe "$NATIVE_VERSION_ERROR"
|
||||
# END native SystemVersion plist getter
|
||||
os_version="$SCALAR"
|
||||
[[ "$os_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail_probe product_version_invalid
|
||||
(( ${os_version%%.*} >= 14 )) || fail_probe unsupported_macos_version
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
|
||||
# BEGIN disk IPC diagnostic
|
||||
printf '[disk-diagnostic-runtime] bash=%s stack-observation-only=true\n' "$BASH_VERSION" >&3
|
||||
run_command sample_usage /usr/bin/sample
|
||||
run_command arbitration_before /bin/launchctl print system/com.apple.diskarbitrationd
|
||||
run_command management_before /bin/launchctl print system/com.apple.storagekitd
|
||||
run_command media_before /usr/sbin/ioreg -r -c IOMedia -l -w 0
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
|
||||
# END disk IPC diagnostic
|
||||
# Bound readiness independently of the host's 40-minute overall deadline.
|
||||
readiness_start=$SECONDS
|
||||
attempt=0
|
||||
while (( attempt < 1 && SECONDS - readiness_start < 600 )); do
|
||||
attempt=$((attempt + 1))
|
||||
printf '\n[readiness-attempt] %s\n' "$attempt" >&3
|
||||
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" >&3
|
||||
done < <(printf '%s\n' "$disk_list" | sed -nE 's#^/dev/(disk[0-9]+).*#\1#p')
|
||||
(( candidates <= 1 )) || fail_probe 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 )) || fail_probe service_domain_not_ready
|
||||
finish true native_recovery_and_writable_64g_disk_ready
|
||||
fi
|
||||
fi
|
||||
flush_outputs || finish false diagnostic_log_budget_exceeded
|
||||
IFS= read -r -t 5 -u 9 unused || :
|
||||
done
|
||||
fail_probe disk_management_or_writable_target_not_ready
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Checksum binding for the pinned Linux Recovery UDIF patcher, not a new CLI.
|
||||
|
||||
Source semantics: planetbeing/libdmg-hfsplus dmg/dmglib.c and dmg/blkx.c.
|
||||
Only flattened, single-segment XML UDIF with CRC32 and raw/zlib data is accepted.
|
||||
The caller plans same-length chunk writes; XML formatting and all offsets remain.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import plistlib
|
||||
import re
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
|
||||
def u32(data, offset):
|
||||
return struct.unpack_from(">I", data, offset)[0]
|
||||
|
||||
|
||||
def u64(data, offset):
|
||||
return struct.unpack_from(">Q", data, offset)[0]
|
||||
|
||||
|
||||
def crc_contract(data, offset):
|
||||
if u32(data, offset) != 2 or u32(data, offset + 4) != 32 or any(data[offset + 12:offset + 136]):
|
||||
raise RuntimeError("Unsupported UDIF checksum type/size/padding")
|
||||
return u32(data, offset + 8)
|
||||
|
||||
|
||||
class ChecksumPlan:
|
||||
def __init__(self, image, koly, plist, xml_offset, xml_length, size):
|
||||
self.image, self.koly, self.plist = image, koly, plist
|
||||
self.xml_offset, self.xml_length, self.size = xml_offset, xml_length, size
|
||||
self.data_offset, self.data_length = u64(koly, 24), u64(koly, 32)
|
||||
if (u32(koly, 4) != 4 or u32(koly, 8) != 512 or u32(koly, 12) != 1
|
||||
or self.data_offset != 0 or u64(koly, 40) or u64(koly, 48)
|
||||
or u32(koly, 60) not in (0, 1) or self.data_length != xml_offset
|
||||
or xml_offset + xml_length > size - 512 or xml_length > 8 * 1024 * 1024):
|
||||
raise RuntimeError("Unsupported or out-of-bounds flattened UDIF layout")
|
||||
crc_contract(koly, 80)
|
||||
crc_contract(koly, 352)
|
||||
image.seek(xml_offset)
|
||||
self.xml = image.read(xml_length)
|
||||
if len(self.xml) != xml_length or not self.xml.lstrip().startswith(b"<?xml"):
|
||||
raise RuntimeError("Unsupported UDIF metadata framing")
|
||||
self.blocks = plist["resource-fork"]["blkx"]
|
||||
self.physical_runs = {}
|
||||
intervals = []
|
||||
for block in self.blocks:
|
||||
mish = block["Data"]
|
||||
if len(mish) < 244 or mish[:4] != b"mish" or (len(mish) - 204) % 40 or u32(mish, 200) != (len(mish) - 204) // 40:
|
||||
raise RuntimeError("Malformed UDIF block table")
|
||||
crc_contract(mish, 64)
|
||||
if u64(mish, 8) + u64(mish, 16) > u64(koly, 492):
|
||||
raise RuntimeError("UDIF partition exceeds logical disk boundary")
|
||||
count = u32(mish, 200)
|
||||
if u32(mish, 204 + (count - 1) * 40) != 0xffffffff:
|
||||
raise RuntimeError("UDIF block table has no final terminator")
|
||||
for kind, offset, length, sectors in self.runs(mish):
|
||||
if kind in (2, 0x7ffffffe, 0xffffffff):
|
||||
if length:
|
||||
raise RuntimeError("Non-data UDIF run has stored bytes")
|
||||
continue
|
||||
if kind not in (1, 0x80000005) or not sectors or length <= 0 or sectors * 512 > 32 * 1024 * 1024:
|
||||
raise RuntimeError("Unsupported UDIF compression/run boundary")
|
||||
if offset < self.data_offset or offset + length > self.data_offset + self.data_length:
|
||||
raise RuntimeError("UDIF data run exceeds data-fork boundary")
|
||||
intervals.append((offset, offset + length))
|
||||
self.physical_runs[offset] = length
|
||||
intervals.sort()
|
||||
if any(left[1] > right[0] for left, right in zip(intervals, intervals[1:])):
|
||||
raise RuntimeError("Overlapping UDIF physical data runs")
|
||||
|
||||
def runs(self, mish):
|
||||
for entry in range(204, len(mish), 40):
|
||||
kind = u32(mish, entry)
|
||||
sector, sectors = u64(mish, entry + 8), u64(mish, entry + 16)
|
||||
if sector + sectors > u64(mish, 16):
|
||||
raise RuntimeError("UDIF run exceeds its partition boundary")
|
||||
offset = self.data_offset + u64(mish, 24) + u64(mish, entry + 24)
|
||||
yield kind, offset, u64(mish, entry + 32), sectors
|
||||
|
||||
def read(self, offset, length):
|
||||
self.image.seek(offset)
|
||||
result = self.image.read(length)
|
||||
if len(result) != length:
|
||||
raise RuntimeError("Short UDIF checksum read")
|
||||
return result
|
||||
|
||||
def logical_crcs(self, mish, replacements):
|
||||
original_crc = patched_crc = 0
|
||||
for kind, offset, length, sectors in self.runs(mish):
|
||||
# IGNORE runs are excluded by the independently verified Apple 13
|
||||
# baseline. Unknown ZERO/compression types are rejected above.
|
||||
if kind not in (1, 0x80000005):
|
||||
continue
|
||||
old = self.read(offset, length)
|
||||
new = replacements.get(offset, old)
|
||||
old_decoded = old if kind == 1 else zlib.decompress(old)
|
||||
new_decoded = new if kind == 1 else zlib.decompress(new)
|
||||
if len(old_decoded) != sectors * 512 or len(new_decoded) != sectors * 512 or len(old) != len(new):
|
||||
raise RuntimeError("UDIF checksum run changed physical/logical extent")
|
||||
original_crc = zlib.crc32(old_decoded, original_crc)
|
||||
patched_crc = zlib.crc32(new_decoded, patched_crc)
|
||||
return original_crc, patched_crc
|
||||
|
||||
def data_crcs(self, replacements):
|
||||
old_crc = new_crc = 0
|
||||
cursor = self.data_offset
|
||||
def same_until(stop):
|
||||
nonlocal cursor, old_crc, new_crc
|
||||
while cursor < stop:
|
||||
data = self.read(cursor, min(1024 * 1024, stop - cursor))
|
||||
old_crc, new_crc = zlib.crc32(data, old_crc), zlib.crc32(data, new_crc)
|
||||
cursor += len(data)
|
||||
for offset, new in sorted(replacements.items()):
|
||||
same_until(offset)
|
||||
old = self.read(offset, len(new))
|
||||
old_crc, new_crc = zlib.crc32(old, old_crc), zlib.crc32(new, new_crc)
|
||||
cursor += len(new)
|
||||
same_until(self.data_offset + self.data_length)
|
||||
return old_crc, new_crc
|
||||
|
||||
def prepare(self, planned):
|
||||
replacements = dict(planned)
|
||||
if len(replacements) != len(planned):
|
||||
raise RuntimeError("Duplicate planned UDIF physical writes")
|
||||
if any(self.physical_runs.get(offset) != len(data) for offset, data in replacements.items()):
|
||||
raise RuntimeError("Planned UDIF write does not preserve an existing data-run boundary")
|
||||
old_master = bytearray()
|
||||
new_master = bytearray()
|
||||
changed = []
|
||||
for block in self.blocks:
|
||||
mish = block["Data"]
|
||||
old_crc, new_crc = self.logical_crcs(mish, replacements)
|
||||
if old_crc != crc_contract(mish, 64):
|
||||
raise RuntimeError("Original UDIF logical CRC32 mismatch")
|
||||
old_master.extend(struct.pack(">I", old_crc))
|
||||
new_master.extend(struct.pack(">I", new_crc))
|
||||
if new_crc != old_crc:
|
||||
new_mish = bytearray(mish)
|
||||
struct.pack_into(">I", new_mish, 72, new_crc)
|
||||
changed.append((mish, bytes(new_mish)))
|
||||
old_data_crc, new_data_crc = self.data_crcs(replacements)
|
||||
if old_data_crc != crc_contract(self.koly, 80) or zlib.crc32(old_master) != crc_contract(self.koly, 352):
|
||||
raise RuntimeError("Original UDIF data-fork/master CRC32 mismatch")
|
||||
xml = self.xml
|
||||
for old_mish, new_mish in changed:
|
||||
matches = [match for match in re.finditer(rb"<data>([\sA-Za-z0-9+/=]*)</data>", xml)
|
||||
if base64.b64decode(match.group(1)) == old_mish]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("UDIF block checksum XML identity is ambiguous")
|
||||
match = matches[0]
|
||||
encoded = iter(base64.b64encode(new_mish))
|
||||
text = bytes(value if chr(value).isspace() else next(encoded) for value in match.group(1))
|
||||
xml = xml[:match.start(1)] + text + xml[match.end(1):]
|
||||
if len(xml) != self.xml_length:
|
||||
raise RuntimeError("UDIF checksum update changed XML region length")
|
||||
new_plist = plistlib.loads(xml)
|
||||
expected = dict(self.plist)
|
||||
expected["resource-fork"] = dict(self.plist["resource-fork"])
|
||||
expected["resource-fork"]["blkx"] = [dict(block, Data=dict(changed).get(block["Data"], block["Data"])) for block in self.blocks]
|
||||
if new_plist != expected:
|
||||
raise RuntimeError("UDIF checksum update changed unrelated metadata")
|
||||
koly = bytearray(self.koly)
|
||||
struct.pack_into(">I", koly, 88, new_data_crc)
|
||||
struct.pack_into(">I", koly, 360, zlib.crc32(new_master))
|
||||
self.receipt = dict(originalDataCrc32=f"{old_data_crc:08x}", patchedDataCrc32=f"{new_data_crc:08x}",
|
||||
originalMasterCrc32=f"{zlib.crc32(old_master):08x}", patchedMasterCrc32=f"{zlib.crc32(new_master):08x}",
|
||||
changedBlockChecksums=len(changed), imageBytes=self.size, xmlOffset=self.xml_offset,
|
||||
xmlBytes=self.xml_length, physicalAndLogicalExtentsPreserved=True)
|
||||
return xml, bytes(koly)
|
||||
|
||||
def verify(self):
|
||||
koly = self.read(self.size - 512, 512)
|
||||
xml = self.read(self.xml_offset, self.xml_length)
|
||||
verifier = ChecksumPlan(self.image, koly, plistlib.loads(xml), self.xml_offset, self.xml_length, self.size)
|
||||
verifier.prepare([])
|
||||
self.image.seek(0, 2)
|
||||
if self.image.tell() != self.size:
|
||||
raise RuntimeError("Patched UDIF image length changed")
|
||||
print("[recovery-udif] " + json.dumps(dict(self.receipt, readBackChecksumsVerified=True), sort_keys=True))
|
||||
@@ -0,0 +1,79 @@
|
||||
; Bare 64-KiB BIOS for the existing Linux QEMU binary, not macOS firmware.
|
||||
; Assemble: nasm -f bin -o ci-cpu-preflight.bin macos-tcg-cpu-preflight.asm
|
||||
; No disks/network. isa-debug-exit returns 33 only after AVX + AVX2 execute
|
||||
; and the upper 128-bit lane contains the expected arithmetic result.
|
||||
; Unsupported instructions/triple faults cannot produce the success code.
|
||||
BITS 16
|
||||
ORG 0
|
||||
start:
|
||||
cli
|
||||
cld
|
||||
xor ax, ax
|
||||
mov ds, ax
|
||||
mov es, ax
|
||||
mov ss, ax
|
||||
mov sp, 0x8000
|
||||
|
||||
; QEMU zeroes fresh RAM. Identity-map the first 2 MiB through three tables.
|
||||
mov dword [0x1000], 0x2003
|
||||
mov dword [0x2000], 0x3003
|
||||
mov dword [0x3000], 0x0083
|
||||
lgdt [cs:gdt_descriptor]
|
||||
mov eax, 0x40620 ; PAE, OSFXSR, OSXMMEXCPT, OSXSAVE
|
||||
mov cr4, eax
|
||||
mov eax, 0x1000
|
||||
mov cr3, eax
|
||||
mov ecx, 0xc0000080 ; EFER.LME
|
||||
rdmsr
|
||||
or eax, 0x100
|
||||
wrmsr
|
||||
mov eax, cr0
|
||||
and eax, ~0x0c ; clear EM and TS before vector instructions
|
||||
or eax, 0x80000003 ; paging, protected mode, monitor coprocessor
|
||||
mov cr0, eax
|
||||
jmp dword 0x08:(0xf0000 + long_mode)
|
||||
|
||||
ALIGN 8
|
||||
gdt:
|
||||
dq 0
|
||||
dq 0x00af9a000000ffff ; ring-0 long-mode code, base 0
|
||||
dq 0x00cf92000000ffff ; ring-0 data, base 0
|
||||
gdt_descriptor:
|
||||
dw gdt_descriptor - gdt - 1
|
||||
dd 0xf0000 + gdt
|
||||
|
||||
BITS 64
|
||||
long_mode:
|
||||
mov ax, 0x10
|
||||
mov ds, ax
|
||||
mov es, ax
|
||||
mov ss, ax
|
||||
mov rsp, 0x8000
|
||||
xor ecx, ecx
|
||||
mov eax, 7 ; XCR0 enables x87, SSE and AVX state
|
||||
xor edx, edx
|
||||
xsetbv
|
||||
vxorps ymm0, ymm0, ymm0 ; AVX, including the upper YMM lane
|
||||
vpcmpeqd ymm1, ymm1, ymm1 ; AVX2: all eight int32 lanes become -1
|
||||
vpsrld ymm1, ymm1, 31 ; AVX2: all lanes become 1
|
||||
vpaddd ymm2, ymm1, ymm1 ; AVX2: all lanes become 2
|
||||
vextracti128 xmm3, ymm2, 1 ; AVX2: inspect the upper half, not only SSE
|
||||
vmovd eax, xmm3
|
||||
cmp eax, 2
|
||||
jne fail
|
||||
vzeroupper
|
||||
mov eax, 0x10 ; QEMU debugexit computes (value << 1) | 1
|
||||
jmp exit_qemu
|
||||
fail:
|
||||
mov eax, 0x11
|
||||
exit_qemu:
|
||||
mov dx, 0xf4
|
||||
out dx, eax
|
||||
hlt
|
||||
jmp $
|
||||
|
||||
; CPU reset starts at the last 16 bytes; reload the real-mode CS base.
|
||||
BITS 16
|
||||
TIMES 0xfff0 - ($ - $$) db 0xff
|
||||
jmp 0xf000:start
|
||||
TIMES 0x10000 - ($ - $$) db 0xff
|
||||
@@ -0,0 +1,106 @@
|
||||
// Disposable pre-SDK observation boundary. No control task port or process writes.
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <libproc.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_vm.h>
|
||||
#include <mach/i386/thread_status.h>
|
||||
#include <mach-o/dyld_images.h>
|
||||
#include <stddef.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/resource.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Private exported BSD API/selector, verified against XNU10063.141.1. Its
|
||||
// return is BSD int + errno, not a Mach kern_return_t. See manifest sources.
|
||||
extern int task_read_for_pid(mach_port_name_t, int, mach_port_name_t *) __attribute__((weak_import));
|
||||
#define READ_ONLY_DARWIN_ROLE 6
|
||||
static unsigned output_bytes;
|
||||
static void emit(const char *format, ...) {
|
||||
char line[256]; va_list args; va_start(args, format);
|
||||
int length = vsnprintf(line, sizeof line, format, args); va_end(args);
|
||||
if (length < 0 || length >= (int)sizeof line || output_bytes + (unsigned)length > 32768) exit(70);
|
||||
output_bytes += (unsigned)length; fwrite(line, 1, (size_t)length, stderr);
|
||||
}
|
||||
static void phase(const char *name, const char *point) { emit("[phase] %s %s\n", name, point); }
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
setvbuf(stderr, NULL, _IONBF, 0);
|
||||
emit("[probe-entry] self=%d architecture=%s snapshot-only=true\n", getpid(),
|
||||
#if defined(__x86_64__)
|
||||
"x86_64"
|
||||
#else
|
||||
"other"
|
||||
#endif
|
||||
);
|
||||
if (argc != 3) { emit("[invalid-pid] require target and expected-parent decimal PIDs greater than1\n"); return 64; }
|
||||
long parsed[2];
|
||||
for (int argument = 1; argument <= 2; ++argument) {
|
||||
if (!argv[argument][0]) { emit("[invalid-pid] empty PID\n"); return 64; }
|
||||
for (const char *p = argv[argument]; *p; ++p) if (*p < '0' || *p > '9') { emit("[invalid-pid] decimal digits required\n"); return 64; }
|
||||
errno = 0; char *end; parsed[argument - 1] = strtol(argv[argument], &end, 10);
|
||||
if (errno || *end || parsed[argument - 1] <= 1 || parsed[argument - 1] > INT_MAX) { emit("[invalid-pid] PID outside permitted numeric range\n"); return 64; }
|
||||
}
|
||||
int pid = (int)parsed[0], expected_parent = (int)parsed[1]; struct proc_bsdinfo bsd = {0};
|
||||
phase("proc_pidinfo", "before"); errno = 0;
|
||||
int bytes = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsd, sizeof bsd); int bsd_errno = errno;
|
||||
emit("[phase] proc_pidinfo after return=%d errno=%d\n", bytes, bsd_errno);
|
||||
if (bytes != sizeof bsd) { emit("[bsd-unavailable] identity was not established\n"); return 66; }
|
||||
emit("[bsd] pid=%u ppid=%u flags=0x%x nice=%d status=%u\n", bsd.pbi_pid, bsd.pbi_ppid, bsd.pbi_flags, bsd.pbi_nice, bsd.pbi_status);
|
||||
if (bsd.pbi_pid != (unsigned)pid || bsd.pbi_ppid != (unsigned)expected_parent) { emit("[ownership-rejected] target=%d expected-parent=%d actual-pid=%u actual-parent=%u\n", pid, expected_parent, bsd.pbi_pid, bsd.pbi_ppid); return 65; }
|
||||
phase("getpriority-role", "before"); errno = 0;
|
||||
int role = getpriority(READ_ONLY_DARWIN_ROLE, (id_t)pid); int role_errno = errno;
|
||||
emit("[role] selector=6 value=%d errno=%d\n", role, role_errno); phase("getpriority-role", "after");
|
||||
struct proc_taskinfo taskinfo = {0}; phase("proc_pidinfo-task", "before"); errno = 0;
|
||||
bytes = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskinfo, sizeof taskinfo); int taskinfo_errno = errno;
|
||||
emit("[phase] proc_pidinfo-task after return=%d errno=%d\n", bytes, taskinfo_errno);
|
||||
if (bytes == sizeof taskinfo) {
|
||||
emit("[bsd-task] total-user-raw=%llu total-system-raw=%llu threads-user-raw=%llu threads-system-raw=%llu\n", taskinfo.pti_total_user, taskinfo.pti_total_system, taskinfo.pti_threads_user, taskinfo.pti_threads_system);
|
||||
emit("[bsd-task] threads=%d running=%d policy=%d priority=%d faults=%d pageins=%d virtual-bytes=%llu resident-bytes=%llu\n", taskinfo.pti_threadnum, taskinfo.pti_numrunning, taskinfo.pti_policy, taskinfo.pti_priority, taskinfo.pti_faults, taskinfo.pti_pageins, taskinfo.pti_virtual_size, taskinfo.pti_resident_size);
|
||||
}
|
||||
if (!task_read_for_pid) {
|
||||
emit("[task-read-unavailable] optional private symbol absent; public BSD snapshot remains observational\n");
|
||||
emit("[probe-end] snapshot-only=true qualified-readiness=false\n"); return 0;
|
||||
}
|
||||
mach_port_t task = MACH_PORT_NULL; phase("task_read_for_pid", "before"); errno = 0;
|
||||
int read_result = task_read_for_pid(mach_task_self(), pid, &task); int read_errno = errno;
|
||||
emit("[task-read] return=%d errno=%d port=0x%x\n", read_result, read_errno, task); phase("task_read_for_pid", "after");
|
||||
if (read_result != 0 || task == MACH_PORT_NULL) {
|
||||
emit("[mach-unavailable] read-only capability denied; BSD snapshot remains observational\n");
|
||||
if (task != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), task);
|
||||
emit("[probe-end] snapshot-only=true qualified-readiness=false\n");
|
||||
return 0;
|
||||
}
|
||||
emit("[mach-capability] read-only=true; unsuspended snapshots may be incomplete\n");
|
||||
task_dyld_info_data_t dyld = {0}; mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT;
|
||||
phase("task_info-dyld", "before"); kern_return_t kr = task_info(task, TASK_DYLD_INFO, (task_info_t)&dyld, &count);
|
||||
emit("[phase] task_info-dyld after kern=%d count=%u\n", kr, count);
|
||||
size_t prefix = offsetof(struct dyld_all_image_infos, jitInfo);
|
||||
if (kr == KERN_SUCCESS && count == TASK_DYLD_INFO_COUNT && dyld.all_image_info_format == TASK_DYLD_ALL_IMAGE_INFO_64 && dyld.all_image_info_size >= prefix) {
|
||||
struct dyld_all_image_infos info = {0}; mach_vm_size_t received = 0; phase("dyld-prefix-read", "before");
|
||||
kr = mach_vm_read_overwrite(task, dyld.all_image_info_addr, prefix, (mach_vm_address_t)(uintptr_t)&info, &received);
|
||||
emit("[phase] dyld-prefix-read after kern=%d bytes=%llu\n", kr, (unsigned long long)received);
|
||||
if (kr == KERN_SUCCESS && received == prefix) emit("[dyld] version=%u images=%u array=0x%llx libSystemInitialized=%d dyld=0x%llx array-null-is-pending=true\n", info.version, info.infoArrayCount, (unsigned long long)(uintptr_t)info.infoArray, info.version >= 2 ? info.libSystemInitialized : -1, info.version >= 2 ? (unsigned long long)(uintptr_t)info.dyldImageLoadAddress : 0);
|
||||
}
|
||||
thread_act_array_t threads = NULL; mach_msg_type_number_t thread_count = 0;
|
||||
phase("task_threads", "before"); kr = task_threads(task, &threads, &thread_count);
|
||||
emit("[phase] task_threads after kern=%d count=%u observed-limit=32\n", kr, thread_count);
|
||||
if (kr == KERN_SUCCESS) {
|
||||
for (unsigned i = 0; i < thread_count && i < 32; ++i) {
|
||||
thread_basic_info_data_t basic = {0}; count = THREAD_BASIC_INFO_COUNT; phase("thread_info", "before");
|
||||
kr = thread_info(threads[i], THREAD_BASIC_INFO, (thread_info_t)&basic, &count);
|
||||
emit("[phase] thread_info after index=%u kern=%d count=%u\n", i, kr, count);
|
||||
if (kr == KERN_SUCCESS && count == THREAD_BASIC_INFO_COUNT) emit("[thread-basic] index=%u run-state=%d policy=%d cpu=%d user=%d.%06d system=%d.%06d\n", i, basic.run_state, basic.policy, basic.cpu_usage, basic.user_time.seconds, basic.user_time.microseconds, basic.system_time.seconds, basic.system_time.microseconds);
|
||||
x86_thread_state64_t registers = {0}; count = x86_THREAD_STATE64_COUNT; phase("thread_get_state-x86_64", "before");
|
||||
kr = thread_get_state(threads[i], x86_THREAD_STATE64, (thread_state_t)®isters, &count);
|
||||
emit("[phase] thread_get_state-x86_64 after index=%u kern=%d count=%u\n", i, kr, count);
|
||||
if (kr == KERN_SUCCESS && count == x86_THREAD_STATE64_COUNT) emit("[thread-registers] index=%u rip=0x%llx rbp=0x%llx rsp=0x%llx\n", i, registers.__rip, registers.__rbp, registers.__rsp);
|
||||
}
|
||||
for (unsigned i = 0; i < thread_count; ++i) mach_port_deallocate(mach_task_self(), threads[i]);
|
||||
vm_deallocate(mach_task_self(), (vm_address_t)threads, thread_count * sizeof *threads);
|
||||
}
|
||||
mach_port_deallocate(mach_task_self(), task);
|
||||
emit("[probe-end] snapshot-only=true qualified-readiness=false\n"); return 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
// Offline build/owned-child validation only. No guest, Docker or services.
|
||||
if (!OperatingSystem.IsMacOS()) throw new InvalidOperationException("The disposable build driver uses the existing Apple SDK on this local host.");
|
||||
var folder = Path.GetFullPath(args.Single());
|
||||
var source = Path.Combine(folder, "NativeProcessProbe.c");
|
||||
var output = Path.Combine(folder, "artifacts"); Directory.CreateDirectory(output);
|
||||
var sourceHash = Hash(File.ReadAllBytes(source));
|
||||
var sdk = (await Run("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], "sdk-path")).Stdout.Trim();
|
||||
var sdkVersion = (await Run("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-version"], "sdk-version")).Stdout.Trim();
|
||||
var compiler = await Run("/usr/bin/xcrun", ["--sdk", "macosx", "clang", "--version"], "compiler-version");
|
||||
var binary = Path.Combine(output, "native-process-probe-x86_64");
|
||||
string[] build = ["--sdk", "macosx", "clang", "-arch", "x86_64", "-mmacosx-version-min=14.0", "-std=c11", "-Os", "-Wall", "-Wextra", "-Werror", source, "-lproc", "-o", binary];
|
||||
await Run("/usr/bin/xcrun", build, "build-x86_64");
|
||||
await Run("/usr/bin/codesign", ["--force", "--sign", "-", binary], "adhoc-sign");
|
||||
await Run("/usr/bin/codesign", ["--verify", "--strict", binary], "signature-verify");
|
||||
var signature = await Run("/usr/bin/codesign", ["-d", "--verbose=4", "--entitlements", ":-", binary], "signature-details");
|
||||
var architectures = await Run("/usr/bin/lipo", ["-archs", binary], "architectures");
|
||||
var imports = await Run("/usr/bin/otool", ["-L", binary], "imports");
|
||||
var loadCommands = await Run("/usr/bin/otool", ["-l", binary], "load-commands");
|
||||
var symbols = await Run("/usr/bin/nm", ["-m", "-u", binary], "undefined-symbols");
|
||||
if (architectures.Stdout.Trim() != "x86_64" || !Regex.IsMatch(loadCommands.Stdout, @"cmd LC_BUILD_VERSION\s+cmdsize [0-9]+\s+platform [0-9]+\s+minos 14\.0\b")) throw new Exception("Actual architecture/minimum OS differs.");
|
||||
var importedLibraries = imports.Stdout.Split('\n').Skip(1).Where(line => line.Trim().Length != 0).Select(line => line.Trim().Split(' ')[0]).ToArray();
|
||||
if (importedLibraries.Length != 1 || importedLibraries[0] != "/usr/lib/libSystem.B.dylib") throw new Exception("Probe imports another runtime/framework: " + string.Join(",", importedLibraries));
|
||||
if (!Regex.IsMatch(symbols.Stdout, @"weak external _task_read_for_pid\b") || symbols.Stdout.Contains("_task_for_pid", StringComparison.Ordinal)
|
||||
|| new[] { "_task_suspend", "_task_resume", "_thread_suspend", "_thread_resume", "_mach_vm_write" }.Any(symbols.Stdout.Contains)) throw new Exception("Read-only import contract failed.");
|
||||
if (signature.Stdout.Contains("<dict>", StringComparison.Ordinal) || signature.Stderr.Contains("<dict>", StringComparison.Ordinal)) throw new Exception("Probe must not acquire entitlements.");
|
||||
|
||||
var cases = new List<object>(); bool x86Executed = false; string? executionUnavailable = null;
|
||||
using var target = Process.Start(new ProcessStartInfo("/bin/sleep") { ArgumentList = { "30" }, UseShellExecute = false })!;
|
||||
try
|
||||
{
|
||||
Result positive;
|
||||
try { positive = await Run(binary, [target.Id.ToString(), Environment.ProcessId.ToString()], "owned-sleep", requireSuccess: false); x86Executed = true; }
|
||||
catch (System.ComponentModel.Win32Exception exception) { executionUnavailable = exception.Message; positive = new(-1, "", ""); }
|
||||
if (x86Executed)
|
||||
{
|
||||
var evidence = positive.Stdout + positive.Stderr;
|
||||
if (positive.ExitCode != 0 || !evidence.Contains("[probe-entry]", StringComparison.Ordinal)
|
||||
|| !Regex.IsMatch(evidence, @"\[bsd\] pid=" + target.Id + " ppid=" + Environment.ProcessId + @" flags=0x[0-9a-f]+ nice=-?[0-9]+ status=[0-9]+")
|
||||
|| !evidence.Contains("[role] selector=6", StringComparison.Ordinal) || !evidence.Contains("[probe-end] snapshot-only=true qualified-readiness=false", StringComparison.Ordinal)
|
||||
|| !(Regex.IsMatch(evidence, @"\[task-read\] return=-?[0-9]+ errno=[0-9]+ port=0x[0-9a-f]+") || evidence.Contains("[task-read-unavailable] optional private symbol absent", StringComparison.Ordinal))) throw new Exception("Owned BSD snapshot/read-capability receipt failed: " + evidence);
|
||||
var read = Regex.Match(evidence, @"\[task-read\] return=(-?[0-9]+) errno=([0-9]+) port=0x([0-9a-f]+)");
|
||||
cases.Add(new { name = "owned-sleep", success = true, targetPid = target.Id, expectedParentPid = Environment.ProcessId, positive.ExitCode, outputBytes = Encoding.UTF8.GetByteCount(evidence), privateReadSymbolAvailable = read.Success, readReturn = read.Success ? int.Parse(read.Groups[1].Value) : (int?)null, readErrno = read.Success ? int.Parse(read.Groups[2].Value) : (int?)null, readPort = read.Success ? read.Groups[3].Value : null, targetIsApplePlatformBinary = true, targetArchitectureNotAsserted = true, recoveryPermissionProven = false });
|
||||
var wrongParent = await Run(binary, [target.Id.ToString(), target.Id.ToString()], "owned-sleep-wrong-parent", requireSuccess: false);
|
||||
var rejected = wrongParent.Stdout + wrongParent.Stderr;
|
||||
if (wrongParent.ExitCode != 65 || !rejected.Contains("[ownership-rejected]", StringComparison.Ordinal)
|
||||
|| !Regex.IsMatch(rejected, @"\[bsd\] pid=" + target.Id + " ppid=" + Environment.ProcessId + @"\b")
|
||||
|| rejected.Contains("getpriority-role", StringComparison.Ordinal) || rejected.Contains("proc_pidinfo-task", StringComparison.Ordinal) || rejected.Contains("task_read_for_pid", StringComparison.Ordinal)) throw new Exception("Wrong parent reached role/task/Mach observation.");
|
||||
cases.Add(new { name = "owned-sleep-wrong-parent", success = true, targetPid = target.Id, suppliedExpectedParentPid = target.Id, actualParentPid = Environment.ProcessId, wrongParent.ExitCode, furtherReadsReached = false });
|
||||
var ownedPid = target.Id.ToString(); var parentPid = Environment.ProcessId.ToString();
|
||||
string[][] invalid = [[], [ownedPid], ["", parentPid], ["0", parentPid], ["1", parentPid], ["-1", parentPid], ["+2", parentPid], ["2x", parentPid], [" 2", parentPid], ["999999999999999999999999", parentPid],
|
||||
[ownedPid, ""], [ownedPid, "0"], [ownedPid, "1"], [ownedPid, "-1"], [ownedPid, "+2"], [ownedPid, "2x"], [ownedPid, " 2"], [ownedPid, "999999999999999999999999"], [ownedPid, parentPid, "extra"]];
|
||||
for (var i = 0; i < invalid.Length; i++)
|
||||
{
|
||||
var result = await Run(binary, invalid[i], "invalid-pid-" + i, requireSuccess: false);
|
||||
var text = result.Stdout + result.Stderr;
|
||||
if (result.ExitCode != 64 || !text.Contains("[probe-entry]", StringComparison.Ordinal) || !text.Contains("[invalid-pid]", StringComparison.Ordinal) || text.Contains("proc_pidinfo", StringComparison.Ordinal)) throw new Exception("Invalid PID reached observation.");
|
||||
cases.Add(new { name = "invalid-pid-" + i, success = true, result.ExitCode, nativeProcessObserved = false });
|
||||
}
|
||||
if (target.HasExited) throw new Exception("Own sleep exited unexpectedly during the snapshot.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!target.HasExited) target.Kill();
|
||||
await target.WaitForExitAsync();
|
||||
}
|
||||
if (!target.HasExited) throw new Exception("Owned local child was not cleaned up.");
|
||||
var manifest = new
|
||||
{
|
||||
success = x86Executed, buildVerified = true, selftestPassed = x86Executed, purpose = "Disposable offline native read-only process diagnostic; no CI runner requirement", sourcePath = source, sourceSha256 = sourceHash,
|
||||
driverSha256 = Hash(File.ReadAllBytes(Path.Combine(folder, "ProbeDriver.cs"))), binaryPath = binary, binarySha256 = Hash(File.ReadAllBytes(binary)),
|
||||
hostArchitecture = RuntimeInformation.OSArchitecture.ToString(), compiler = compiler.Stdout.Trim(), sdk, sdkVersion, minimumMacOS = "14.0", architecture = "x86_64", compilerArguments = build,
|
||||
importedLibraries, signature = "ad-hoc; no entitlements", outputMaximumBytes = 32768, threadObservationMaximum = 32, frameWalkUsed = false, imageArrayReadUsed = false,
|
||||
readOnlyTaskPortOnly = true, taskReadWeakImportVerified = true, taskReadReturnContract = "BSD int/errno", darwinRolePrioritySelector = 6, targetAndExpectedParentMandatory = true, bsdIdentityRequiredBeforeFurtherReads = true, snapshotIsReadiness = false, qualifiedReadiness = false,
|
||||
x86Executed, executionUnavailable, localOwnedChildCleanedUp = target.HasExited, selftests = cases, guestExecuted = false, dockerExecuted = false, recoveryPermissionsProven = false,
|
||||
primarySources = new[] { "https://github.com/apple-oss-distributions/xnu/blob/xnu-10063.141.1/bsd/kern/kern_resource.c#L691-L721", "https://github.com/apple-oss-distributions/xnu/blob/xnu-10063.141.1/bsd/sys/resource.h", "https://raw.githubusercontent.com/apple-oss-distributions/xnu/xnu-10063.141.1/bsd/vm/vm_unix.c", "https://raw.githubusercontent.com/apple-oss-distributions/xnu/main/bsd/kern/syscalls.master" },
|
||||
abiSourceIsExactGuestBinary = false, actualSdkExport = Path.Combine(sdk, "usr/lib/system/libsystem_kernel.tbd"), completedUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
File.WriteAllText(Path.Combine(output, "manifest.json"), JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
Console.WriteLine(JsonSerializer.Serialize(manifest));
|
||||
|
||||
async Task<Result> Run(string executable, string[] arguments, string label, bool requireSuccess = true)
|
||||
{
|
||||
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } };
|
||||
foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument);
|
||||
process.Start();
|
||||
async Task<string> Read(StreamReader reader)
|
||||
{
|
||||
var text = new StringBuilder(); var buffer = new char[2048];
|
||||
while (await reader.ReadAsync(buffer) is var count && count != 0)
|
||||
{
|
||||
text.Append(buffer, 0, count);
|
||||
if (Encoding.UTF8.GetByteCount(text.ToString()) > 32768) { if (!process.HasExited) process.Kill(); throw new Exception("Disposable probe/tool output exceeded32KiB."); }
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
var stdout = Read(process.StandardOutput); var stderr = Read(process.StandardError);
|
||||
using var bound = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
try { await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(bound.Token)); }
|
||||
catch { if (!process.HasExited) process.Kill(); await process.WaitForExitAsync(); throw; }
|
||||
var result = new Result(process.ExitCode, await stdout, await stderr);
|
||||
File.WriteAllText(Path.Combine(output, label + ".stdout.log"), result.Stdout);
|
||||
File.WriteAllText(Path.Combine(output, label + ".stderr.log"), result.Stderr);
|
||||
if (requireSuccess && result.ExitCode != 0) throw new Exception(label + " failed: " + result.Stderr);
|
||||
return result;
|
||||
}
|
||||
static string Hash(byte[] value) => Convert.ToHexStringLower(SHA256.HashData(value));
|
||||
sealed record Result(int ExitCode, string Stdout, string Stderr);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"purpose": "Disposable prebuilt diagnostic; no macOS compiler or runner is required by CI",
|
||||
"sourceSha256": "38acf83b05694f9931c41ff1749e9b6b836f04c740b7f1a1c60e32332678cb1f",
|
||||
"driverSha256": "d9d87415c12398f29b35697109f18d9b16f121dae969a4a05d0ec6a8cb874d25",
|
||||
"binarySha256": "b8d54e2945eeefb3e0c22468feb7aef7efa50813909058b1e4b40144dd7e3d3e",
|
||||
"compiler": "Apple clang 21.0.0 (clang-2100.3.34.2)",
|
||||
"sdkVersion": "27.0",
|
||||
"minimumMacOS": "14.0",
|
||||
"architecture": "x86_64",
|
||||
"importedLibraries": ["/usr/lib/libSystem.B.dylib"],
|
||||
"signature": "ad-hoc",
|
||||
"entitlements": false,
|
||||
"offlineVerified": true,
|
||||
"targetAndExpectedParentMandatory": true,
|
||||
"readOnlyTaskPortOnly": true,
|
||||
"taskReadWeakImportVerified": true,
|
||||
"taskReadReturnContract": "BSD int/errno",
|
||||
"darwinRolePrioritySelector": 6,
|
||||
"outputMaximumBytes": 32768,
|
||||
"threadObservationMaximum": 32,
|
||||
"frameWalkUsed": false,
|
||||
"imageArrayReadUsed": false,
|
||||
"localPlatformReadPortDenied": true,
|
||||
"recoveryPermissionsProven": false,
|
||||
"qualifiedReadiness": false
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user