Author SHA1 Message Date
codex 3e7cb3ac39 feat: emit target command in controlled automation config 2026-08-10 15:11:28 +03:00
codex 5a14658994 feat: add guarded controlled entity selection transport 2026-08-10 15:03:38 +03:00
codex e12b8c549c fix: observe automation evidence without timeout delay 2026-08-10 11:32:39 +03:00
codex 6f7888b18b fix: keep pilot automation host alive 2026-08-10 11:16:39 +03:00
codex 1f5853de86 fix: keep controlled roster discovery path-portable 2026-08-10 10:16:23 +03:00
codex 94d35a12d9 feat: enforce startup roster on pilot launch 2026-08-10 10:15:41 +03:00
codex cda89c359e fix: discover controlled local parity startup roots 2026-08-10 10:11:32 +03:00
codex 8652325980 test: cover requested and legacy startup candidates 2026-08-10 10:10:35 +03:00
codex 7025ce0a46 fix: include requested package in startup roster 2026-08-10 10:10:03 +03:00
codex 514302b966 feat: fail closed on conflicting P_Rec_Annot startup roster 2026-08-10 10:08:48 +03:00
codex a5a615f7f0 fix: persist p-rec-update package load diagnostics 2026-08-07 07:35:05 +03:00
codex 06d7cb0015 fix: keep controlled launch host alive for evidence 2026-08-06 16:48:57 +03:00
codex fb632b48d8 feat: prepare multiple exact plant packages 2026-08-06 16:41:53 +03:00
codex 2792e244e9 docs: record owner roster compatibility result 2026-08-06 15:14:40 +03:00
codex 2c578576d7 fix: parse project rosters with legacy-compatible semantics 2026-08-06 15:14:08 +03:00
codex f460e87253 fix: bind release evidence to embedded commit 2026-08-06 14:03:22 +03:00
codex 433dc9a50d docs: record owner network configuration runtime 2026-08-06 14:01:45 +03:00
codex 45b1379853 feat: record network settings snapshot in launch evidence 2026-08-06 13:58:08 +03:00
codex 97fbc1d746 feat: acquire legacy settings from approved network source 2026-08-06 13:56:54 +03:00
codex 0213b229a9 feat: build controlled owner review distribution 2026-08-06 13:37:15 +03:00
codex 876e4f5d50 fix: report launcher failures by operation stage 2026-08-06 13:36:59 +03:00
codex 1c18f4893d docs: record verified legacy selection contracts 2026-08-06 13:02:59 +03:00
codex 72f127a427 feat: restore product-specific legacy project selection 2026-08-06 13:02:49 +03:00
codex 5c812f87fd fix: bootstrap plant plugins with startup script 2026-08-06 12:27:12 +03:00
codex f052dd0633 feat: restore two-step legacy launcher workflow 2026-08-06 11:20:25 +03:00
codex 75a1097653 feat: add controlled local PXP-OA parity workflow 2026-08-06 11:17:33 +03:00
codex 0879d716dd feat: expose legacy launcher configuration workflow 2026-08-06 10:18:22 +03:00
codex 8d32f8e7ae docs: record phase one lifecycle verification 2026-08-06 10:01:34 +03:00
codex 9fce6ee830 feat: expand controlled package lifecycle pilot evidence 2026-08-06 09:59:43 +03:00
codex 64fa1f5664 feat: harden package lifecycle content boundaries 2026-08-06 09:59:17 +03:00
codex 0e38d8fad2 test: inject deterministic package lifecycle faults 2026-08-06 09:51:25 +03:00
codex 66e46db849 feat: add typed local package pilot root 2026-08-06 09:46:50 +03:00
codex 95beb0f632 feat: bind activation receipts to immutable content 2026-08-06 09:29:00 +03:00
codex 1aa68ba8c5 fix: compile package lifecycle pilot route 2026-08-06 09:23:59 +03:00
codex c72f3cae0a feat: add controlled package lifecycle pilot route 2026-08-06 09:23:32 +03:00
codex 9a5c578af4 feat: serialize package pointer transactions 2026-08-06 09:21:17 +03:00
codex 961d8a662c fix: remove staging marker before immutable commit 2026-08-06 09:08:22 +03:00
codex d94fd4bdaf feat: add owned package staging cleanup 2026-08-06 09:08:00 +03:00
codex 517105bd3f test: prove package lifecycle isolation and recovery 2026-08-06 08:24:00 +03:00
codex c4c611b35b feat: add explicit package activation receipts 2026-08-06 08:20:50 +03:00
codex 20f4b6a7b8 refactor: separate package materialization from activation 2026-08-06 08:13:49 +03:00
codex 0b890432d5 feat: route legacy configuration probe through CLI 2026-08-06 07:40:51 +03:00
codex 84ddac90a8 feat: resolve typed legacy probe overlays 2026-08-06 07:33:26 +03:00
codex 997762069f feat: parse exact legacy compatibility overlays 2026-08-05 16:41:46 +03:00
codex 8764348a75 feat: add strict legacy probe override schema 2026-08-05 16:33:13 +03:00
codex 9c49ebf1e2 feat: enforce typed legacy probe input roles 2026-08-05 16:12:55 +03:00
codex 02e34186fc feat: add isolated legacy configuration probe 2026-08-05 16:00:56 +03:00
codex 4c29c9084a fix: join legacy settings with download selections 2026-08-05 15:54:40 +03:00
codex d6ed0ac592 feat: enforce resolved catalog path roles 2026-08-05 14:57:16 +03:00
codex 6fd670d08a fix: restore strict runtime manifest compatibility 2026-08-05 14:24:17 +03:00
codex d4a4d9acf8 fix: bound catalog acquisition before pilot staging 2026-08-05 13:21:51 +03:00
codex b83742b3a5 feat: adapt roster sources into package resolution 2026-08-05 13:09:04 +03:00
codex cea9d70dc3 feat: resolve roster-bound packages through verified cache 2026-08-05 11:13:40 +03:00
codex 2298fac8cd feat: verify last-known-good local packages 2026-08-05 09:17:21 +03:00
codex c66f0485dc docs: link shared project map from agent instructions 2026-08-05 07:14:46 +03:00
codex 1fecaad47c fix: bound automation cleanup and bind apply visibility 2026-08-04 16:35:42 +03:00
codex e2c360e9f2 feat: add launcher-only preflight gate 2026-08-04 16:12:01 +03:00
codex 4ae5a93a4e fix: allocate pilot session before fixture hashing 2026-08-04 15:52:27 +03:00
codex 73b97d9c2c fix: bind legacy annotation template to Apply permit 2026-08-04 14:59:51 +03:00
codex 6690fcaeb1 fix: use global undo options in Apply script 2026-08-04 14:51:40 +03:00
codex a531a48289 fix: drive limited Apply script through undo boundary 2026-08-04 14:50:00 +03:00
codex 9f6abcc542 fix: keep script preview on active Ortho drawing 2026-08-04 14:44:07 +03:00
codex b3b30b7993 fix: disable dialogs before opening automation drawings 2026-08-04 14:40:23 +03:00
codex ea961f2455 fix: open source model for script preview placement 2026-08-04 14:38:29 +03:00
codex 0829e5dace fix: allow optional Preview automation paths 2026-08-04 12:29:37 +03:00
codex 0052790a2b fix: report automation preparation gate failures 2026-08-04 12:28:13 +03:00
codex f780650e9e fix: accept configured pilot root 2026-08-04 12:27:18 +03:00
codex 49704681d3 fix: resolve automation entry assembly path 2026-08-04 12:25:41 +03:00
codex 2deca5d009 fix: capture PXP-OA script execution diagnostics 2026-08-04 12:23:45 +03:00
codex c1587b7f66 fix: canonicalize legacy drive aliases in resolved paths 2026-08-04 11:50:14 +03:00
codex e13f44ac3c refactor: resolve launcher paths and isolate batch automation 2026-08-04 11:48:30 +03:00
codex ea6f3cdcdf Merge remote-tracking branch 'origin/test/launcher-next-first-apply-automation-20260803' into integration/script-automation-20260804 2026-08-04 11:24:44 +03:00
codex 115f44fcb9 feat: drive Preview automation through AutoCAD script 2026-08-04 10:07:03 +03:00
codex 8a2ca2daa6 fix: wait for completed evidence before forced cleanup 2026-08-04 07:36:05 +03:00
codex e2725c7f4c fix: clear stale automation evidence before pilot session 2026-08-04 07:23:38 +03:00
codex b88e09afd4 feat: allow forced cleanup after completed automation evidence 2026-08-04 07:22:35 +03:00
codex 9209937b85 fix: isolate rebuilt pilot package versions 2026-08-03 16:38:31 +03:00
codex 399cdf503e feat: pass limited Apply permit to pilot process 2026-08-03 10:52:12 +03:00
codex 72cd0cc337 feat: require local permit for first Apply pilot 2026-08-03 10:48:03 +03:00
codex dc9aeff1b5 feat: gate first limited Apply pilot 2026-08-03 10:44:51 +03:00
codex e97b10cf2e docs: record local Plant 3D Ortho DryRun 2026-08-03 08:09:36 +03:00
codex 02e5dbc61f test: allow controlled P_Rec_Annot DryRun pilot 2026-07-31 15:57:14 +03:00
codex afd3924214 docs: record pilot runner exit-code limitation 2026-07-31 15:51:52 +03:00
codex 0314dfc997 docs: document Launcher Next mechanics and real P_Rec_Annot pilot 2026-07-31 15:51:16 +03:00
codex 3b6e43fae1 fix: allow new pilot run after completed rollback 2026-07-31 15:44:44 +03:00
codex 5f3a75f160 fix: release persisted pilot session before state transition 2026-07-31 15:44:11 +03:00
codex fef3db996a fix: parse string pilot configuration enums 2026-07-31 15:42:31 +03:00
codex 39a6ed1f4a feat: persist real pilot manifest catalog 2026-07-31 15:41:51 +03:00
codex f7202a4a3d feat: complete pilot CLI session and rollback lifecycle 2026-07-31 15:41:34 +03:00
codex e50dd7843f feat: complete NextPilot execution service 2026-07-31 15:27:01 +03:00
codex 90108d1c06 feat: add controlled NextPilot execution pipeline 2026-07-31 15:19:11 +03:00
70 changed files with 3704 additions and 24 deletions
+1
View File
@@ -34,3 +34,4 @@ PRecAnnot*.dll
appsettings.Local.json appsettings.Local.json
*.secrets.json *.secrets.json
.env .env
launcher.legacy-configuration-probe.json
+48
View File
@@ -0,0 +1,48 @@
# AGENTS.md — Launcher Next
## Scope
Эти правила относятся только к Launcher Next. Работать с текущей веткой и
локальными test fixtures; не делать production deployment, не менять default
branches и не создавать новую ветку или worktree без прямого задания.
## Общая карта проекта
Каноническая общая карта проекта:
`https://drive.google.com/drive/folders/1avnR0V8q7y5pfWRYwB6LPBEEyfpN7PpV`.
Необязательный локальный доступ к синхронизированной копии возможен только
через `PHIM_PLANT3D_PROJECT_MAP_ROOT`; путь синхронизации не хардкодить.
Перед архитектурной, package-lifecycle или runtime работой читать доступные
индекс/обзор карты, запись Launcher architecture и migration status, package
activation/rollback contract, индекс runtime evidence и записи затронутой
интеграции. Сверять их с локальным AGENTS.md, tracked documentation, code,
tests, configuration, фактическим `git rev-parse HEAD` и runtime evidence.
Приоритет источников: фактическое runtime evidence; code/tests/configuration
на проверенном Git HEAD; tracked documentation; общая карта проекта. Внешняя
документация не является build/runtime dependency и не переопределяет текущий
код или runtime evidence. Недоступность карты фиксировать как
`PROJECT_MAP_UNAVAILABLE`; несовпадение её commit/runtime status с фактическими
данными — как `PROJECT_MAP_STALE`. В обоих случаях продолжать только по
доступным источникам и явно сообщать расхождение.
## Launcher invariants
Launcher использует versioned local package, проверяемый manifest и один
managed entry assembly; Autodesk DLL не включаются в package и не загружаются
с UNC. Headless automation требует явного NextPilot mode, не создаёт Foundation
UI, не ожидает ввода и не возвращается в Legacy/default route при ошибке.
Production route не создаёт pilot session или automation manifest и не
использует `/b`.
До hashing сохранять immutable manifest и initial evidence; session/progress,
terminal evidence, cancellation, rollback и package activation должны быть
проверяемыми. Runtime evidence определяет реальный статус запуска AutoCAD,
активации и cleanup — build и unit tests его не заменяют.
После изменения Launcher architecture, package activation/rollback либо
runtime-status в отчёте перечислять документы карты, которые требуют
обновления: Launcher architecture, package lifecycle, runtime evidence index,
migration status и запись затронутой интеграции. Не заявлять обновление карты
без фактического внешнего изменения.
+1 -1
View File
@@ -1 +1 @@
<Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml" /> <Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" />
+43 -1
View File
@@ -1 +1,43 @@
namespace Launcher.Next.App; public partial class App : System.Windows.Application { } namespace Launcher.Next.App;
public partial class App : System.Windows.Application
{
protected override async void OnStartup(System.Windows.StartupEventArgs e)
{
base.OnStartup(e);
if (e.Args.FirstOrDefault()?.Equals("legacy-configuration-probe", StringComparison.OrdinalIgnoreCase) == true)
{
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await LegacyConfigurationProbeCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
if (e.Args.FirstOrDefault()?.Equals("package-lifecycle-pilot", StringComparison.OrdinalIgnoreCase) == true)
{
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
if (e.Args.FirstOrDefault()?.Equals("local-parity-launch", StringComparison.OrdinalIgnoreCase) == true)
{
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await LocalParityLaunchCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
if (e.Args.Length == 4 && e.Args[0].Equals("--mode", StringComparison.OrdinalIgnoreCase) && e.Args[1].Equals("LocalParityLaunch", StringComparison.OrdinalIgnoreCase) && e.Args[2].Equals("--config", StringComparison.OrdinalIgnoreCase))
{
try { new MainWindow(LocalParityLaunchService.LoadProfile(e.Args[3])).Show(); }
catch (Exception exception) { System.Windows.MessageBox.Show("Controlled local configuration blocked: " + exception.Message, "Launcher Next"); Shutdown(2); }
return;
}
if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true)
{
// A pilot command is asynchronous and owns no WPF window. Without an
// explicit shutdown mode the dispatcher may terminate at its first
// await, after the Preview child has been started but before Apply
// and its evidence/rollback boundary have run.
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
new MainWindow().Show();
}
}
+1 -1
View File
@@ -1 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /></ItemGroup></Project> <Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /><ProjectReference Include="..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" /><ProjectReference Include="..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" /></ItemGroup></Project>
@@ -0,0 +1,47 @@
using Launcher.Next.Application;
using Launcher.Next.Core;
using System.IO;
namespace Launcher.Next.App;
/// <summary>Dedicated, non-Autodesk command route. It deliberately constructs
/// only the read-only legacy configuration probe dependency graph.</summary>
internal static class LegacyConfigurationProbeCli
{
internal static async Task<int> RunAsync(string[] args)
{
if (args.Length != 4 || !string.Equals(args[0], "--mode", StringComparison.Ordinal) ||
!string.Equals(args[1], "LegacyConfigurationProbe", StringComparison.Ordinal) ||
!string.Equals(args[2], "--config", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(args[3]))
return Fail("LEGACY_PROBE_MODE_CONFLICT");
try
{
if (!Path.IsPathFullyQualified(args[3]) || !File.Exists(args[3]) || Directory.Exists(args[3]))
return Fail("LEGACY_PROBE_OVERRIDE_MISSING");
var overrideDocument = LegacyProbeOverrideParser.Parse(await File.ReadAllTextAsync(args[3]));
var variables = new LauncherPathVariables(new Dictionary<string, string?>
{
["LegacySettingsPath"] = overrideDocument.SettingsXml,
["LegacyDownloadListPath"] = overrideDocument.DownloadListXml,
["LegacyCompatibilityOverlayPath"] = overrideDocument.CompatibilityOverlay,
["LegacyProbeEvidenceRoot"] = overrideDocument.EvidenceOutputRoot
});
var resolver = new PathResolver();
var request = new LegacyConfigurationProbeRequest(
resolver.ResolveLegacySettingsPath(variables),
resolver.ResolveLegacyDownloadListPath(variables),
resolver.ResolveLegacyCompatibilityOverlayPath(variables),
resolver.ResolveLegacyProbeEvidenceRoot(variables),
overrideDocument.SelectedPattern,
Guid.NewGuid().ToString("N"));
var result = await new LegacyConfigurationProbe(new LegacyConfigurationAdapter()).RunAsync(request);
Console.WriteLine(result.TerminalState);
return result.TerminalState == "Succeeded" ? 0 : 2;
}
catch (InvalidOperationException ex) { return Fail(ex.Message); }
catch { return Fail("LEGACY_PROBE_INTERNAL_FAILURE"); }
}
private static int Fail(string code) { Console.Error.WriteLine(code); return 2; }
}
+29
View File
@@ -0,0 +1,29 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Launcher.Next.App;
/// <summary>Explicit automation of the same LocalParityLaunchService used by the main-window actions.</summary>
internal static class LocalParityLaunchCli
{
public static async Task<int> RunAsync(string[] args)
{
if (args.Length != 4 || args[0] != "--mode" || args[1] != "LocalParityLaunch" || args[2] != "--config") return 2;
try
{
var service = new LocalParityLaunchService();
var prepared = await service.PrepareAsync(LocalParityLaunchService.LoadProfile(args[3]), CancellationToken.None);
var launch = await service.LaunchAsync(prepared, CancellationToken.None);
return launch.TerminalState == "Succeeded" ? 0 : 1;
}
catch (Exception exception)
{
// Explicit local mode: retain a bounded diagnostic next to its supplied profile.
// Normal production and automation routes never take this path.
try { await File.WriteAllTextAsync(Path.Combine(Path.GetDirectoryName(args[3])!, "local-parity-last-error.txt"), exception.ToString()); } catch { }
return 1;
}
}
}
@@ -0,0 +1,199 @@
using System;
using System.IO;
using System.Linq;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Launcher.Next.Application;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
namespace Launcher.Next.App;
/// <summary>
/// Explicit non-production workflow for a legacy-shaped configuration whose source,
/// cache, activation state, starter and evidence all live under one supplied local root.
/// It never accepts UNC roots or uses the legacy compatibility synchronizer.
/// </summary>
internal sealed record LocalParityLaunchProfile(
int SchemaVersion, string Name, string Root, string SettingsPath, string DownloadListPath,
string OverlayPath, string CatalogRoot, string PatternName, string EvidenceRoot, string? NetworkSettingsEndpoint = null,
LegacySettingsSnapshot? SettingsSnapshot = null);
internal sealed record LocalParityPackage(PackageRequirement Requirement, PackageManifest Manifest,
ImmutableLocalPackage Package, PackageActivationResult Activation);
internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, IReadOnlyList<LocalParityPackage> Packages,
string StarterPath, string StarterSha256);
internal sealed record LocalParityLaunchResult(int ProcessId, bool StartupVerified, bool PackageLoadConfirmed,
string EvidencePath, string TerminalState);
internal sealed class LocalParityLaunchService
{
private readonly LegacyConfigurationAdapter adapter = new();
public static LocalParityLaunchProfile LoadProfile(string path)
{
var profile = JsonSerializer.Deserialize<LocalParityLaunchProfile>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Name) || string.IsNullOrWhiteSpace(profile.PatternName))
throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
ValidateProfile(profile);
profile = profile with { Root = Path.GetFullPath(profile.Root) };
if (!string.IsNullOrWhiteSpace(profile.NetworkSettingsEndpoint))
{
var snapshot = LegacyNetworkConfiguration.AcquireApprovedSnapshot(profile.NetworkSettingsEndpoint, profile.Root);
profile = profile with { SettingsPath = snapshot.LocalSnapshotPath, SettingsSnapshot = snapshot };
}
return profile;
}
public async Task<LocalParityPrepared> PrepareAsync(LocalParityLaunchProfile profile, CancellationToken ct)
{
ValidateProfile(profile);
var settings = adapter.ParseSettings(await File.ReadAllTextAsync(profile.SettingsPath, ct));
var pattern = settings.Patterns.Where(x => string.Equals(x.Name, profile.PatternName, StringComparison.Ordinal)).ToArray();
if (pattern.Length != 1) throw new InvalidOperationException(pattern.Length == 0 ? "LOCAL_PARITY_PATTERN_NOT_FOUND" : "LOCAL_PARITY_PATTERN_AMBIGUOUS");
var selection = adapter.ParseDownloadList(await File.ReadAllTextAsync(profile.DownloadListPath, ct));
var overlay = LegacyCompatibilityOverlayParser.Parse(await File.ReadAllTextAsync(profile.OverlayPath, ct));
var requirements = adapter.Join(pattern[0], selection, overlay);
var store = new LocalPackageStore(profile.Root, new Sha256FileHasher());
var prepared = new List<LocalParityPackage>();
foreach (var requirement in requirements)
{
var source = Under(profile.CatalogRoot, requirement.PackageId, requirement.RequiredVersion);
var manifestPath = Under(source, "package.manifest.json");
var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct));
if (!ManifestMatches(manifest, requirement)) throw new InvalidOperationException("LOCAL_PARITY_MANIFEST_REQUIREMENT_MISMATCH");
var package = await store.MaterializeAsync(manifest, source, ct);
await store.BindAsync(requirement, package.Version, ct);
var activation = await store.ActivateImmutableAsync(package, requirement, ct);
if (activation.Outcome is not ("Activated" or "AlreadyActiveExact")) throw new InvalidOperationException("LOCAL_PARITY_ACTIVATION_" + activation.Outcome);
if (await store.ResolveAsync(requirement, ct) is null) throw new InvalidOperationException("LOCAL_PARITY_ACTIVE_REVALIDATION_FAILED");
prepared.Add(new(requirement, manifest, package, activation));
}
var starter = await GenerateStarterAsync(profile, prepared, ct);
return new(profile, prepared, starter.Path, starter.Sha256);
}
public async Task<LocalParityLaunchResult> LaunchAsync(LocalParityPrepared prepared, CancellationToken ct)
{
var product = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026)
?? throw new InvalidOperationException("PLANT3D_2026_NOT_FOUND");
var operationId = Guid.NewGuid().ToString("N");
var evidenceDirectory = Under(prepared.Profile.EvidenceRoot, operationId);
Directory.CreateDirectory(evidenceDirectory);
var bootstrapRoot = Path.GetDirectoryName(prepared.StarterPath)!;
var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal));
var updateAssemblyHash = updatePackage is null ? null : await HashAsync(Under(updatePackage.Package.LocalPackageRoot, updatePackage.Manifest.EntryAssembly), ct);
var logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Promhimproekt", "PRecAnnot2026", "Logs", "runtime.jsonl");
var beforeLogLength = File.Exists(logPath) ? new FileInfo(logPath).Length : 0L;
var psi = new ProcessStartInfo { FileName = product.ExecutablePath, WorkingDirectory = Path.GetDirectoryName(product.ExecutablePath)!, UseShellExecute = false };
psi.ArgumentList.Add("/product"); psi.ArgumentList.Add("PLNT3D"); psi.ArgumentList.Add("/language"); psi.ArgumentList.Add("ru-RU");
// Controlled-local only: accepted Plant runtime bootstrap. Keep /b and the owned SCR last.
psi.ArgumentList.Add("/b"); psi.ArgumentList.Add(prepared.StarterPath);
psi.Environment["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke";
psi.Environment["P_REC_UPDATE_MARKER_ROOT"] = Path.Combine(bootstrapRoot, "markers");
psi.Environment["P_REC_UPDATE_OPERATION_ID"] = operationId;
using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED");
await Task.Delay(TimeSpan.FromSeconds(30), ct);
var alive = !process.HasExited;
var marker = Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "pxp-oa-netload.marker");
var markerConfirmed = File.Exists(marker);
var runtimeConfirmed = false;
if (File.Exists(logPath))
{
await using var stream = new FileStream(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
stream.Seek(beforeLogLength, SeekOrigin.Begin);
using var reader = new StreamReader(stream, Encoding.UTF8);
var newLog = await reader.ReadToEndAsync(ct);
runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal);
}
var markerRoot = Path.Combine(bootstrapRoot, "markers");
var beforeUpdateNetload = File.Exists(Path.Combine(markerRoot, "BeforePRecUpdateNetload.marker"));
var afterUpdateNetload = File.Exists(Path.Combine(markerRoot, "AfterPRecUpdateNetload.marker"));
var scriptCompleted = File.Exists(Path.Combine(markerRoot, "ScriptCompleted.marker"));
var updateEntered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "InitializeEntered.marker.json"));
var updateInitialized = updatePackage is null || File.Exists(Path.Combine(markerRoot, "AssemblyInitializeCompleted.marker.json"));
var updateCommandRegistered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "CommandRegistrationVerified.marker.json"));
var loaded = runtimeConfirmed && updateInitialized && updateCommandRegistered;
var terminal = alive && loaded ? "Succeeded" : "Blocked";
var settingsSnapshot = prepared.Profile.SettingsSnapshot is null ? null : new { sourceEndpointFingerprint = prepared.Profile.SettingsSnapshot.SourceFingerprint, prepared.Profile.SettingsSnapshot.Sha256, prepared.Profile.SettingsSnapshot.Length, prepared.Profile.SettingsSnapshot.LastWriteUtc, prepared.Profile.SettingsSnapshot.AcquiredUtc, prepared.Profile.SettingsSnapshot.OfflineFallback };
var evidence = new { schemaVersion = 4, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, orderedPluginIds = prepared.Packages.Select(x => x.Requirement.PackageId), exactPackages = prepared.Packages.Select((x, rosterPosition) => new { legacyId = x.Requirement.PackageId == "P_Rec_Update" ? "6ffef84b01097975f51fb3bbb11279e1" : "b455ee6eaed79d76f084846a60ce3284", rosterPosition, x.Package.PackageId, x.Package.Version, entryAssembly = x.Manifest.EntryAssembly, entrySha256 = x.Package.PackageId == "P_Rec_Update" ? updateAssemblyHash : HashAsync(Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly), ct).GetAwaiter().GetResult(), manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration, netloadAttempted = x.Package.PackageId != "P_Rec_Update" || beforeUpdateNetload, netloadReturned = x.Package.PackageId != "P_Rec_Update" || afterUpdateNetload, initializeEntered = x.Package.PackageId != "P_Rec_Update" || updateEntered, initializeCompleted = x.Package.PackageId != "P_Rec_Update" || updateInitialized, commandProbeResult = x.Package.PackageId == "P_Rec_Update" ? (updateCommandRegistered ? "COMMAND_REGISTRATION_VERIFIED" : "Unavailable") : "AssemblyInitializeCompleted" }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, scriptMarkers = new { beforeUpdateNetload, afterUpdateNetload, scriptCompleted }, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, updateEntered, updateInitialized, updateCommandRegistered, processInteractiveAfterScript=alive }, terminalState = terminal, terminalResult = loaded ? "PACKAGE_LOAD_VERIFIED" : updateEntered ? "PACKAGE_INITIALIZE_TIMEOUT" : afterUpdateNetload ? "PACKAGE_LOAD_FAILED" : beforeUpdateNetload ? "SCRIPT_ABORTED" : "SCRIPT_ABORTED", sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } };
var evidencePath = await WriteEvidenceAsync(evidenceDirectory, evidence, ct);
return new(process.Id, alive, loaded, evidencePath, terminal);
}
private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, IReadOnlyList<LocalParityPackage> packages, CancellationToken ct)
{
var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot);
var entries = packages.Select(x => (x.Package.PackageId, Path: Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly))).ToArray();
if (entries.Any(x => !File.Exists(x.Path))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
var markerRoot = Under(starterRoot, "markers"); Directory.CreateDirectory(markerRoot);
var helperPath = Under(starterRoot, "bootstrap-markers.lsp");
var helper = "(defun PXP-MARK (p s / f) (setq f (open p \"w\")) (write-line s f) (close f) (princ))";
await File.WriteAllTextAsync(helperPath, helper, new UTF8Encoding(false), ct);
string LispPath(string path) => path.Replace('\\', '/');
string Mark(string name) => "(PXP-MARK \"" + LispPath(Under(markerRoot, name + ".marker")) + "\" \"" + name + "\")\r\n";
var content = "(load \"" + LispPath(helperPath) + "\")\r\n_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n";
foreach (var entry in entries)
{
if (entry.PackageId == "P_Rec_Update") content += Mark("BeforePRecUpdateNetload");
content += "_.NETLOAD\r\n\"" + entry.Path.Replace("\"", "\\\"") + "\"\r\n";
if (entry.PackageId == "P_Rec_Update") content += Mark("AfterPRecUpdateNetload") + "P-R-UPD-STATUS\r\n";
}
content += Mark("ScriptCompleted");
var path = Under(starterRoot, "startup.scr");
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
return (path, await HashAsync(path, ct));
}
private static bool ManifestMatches(PackageManifest manifest, PackageRequirement requirement) =>
manifest.PackageId == requirement.PackageId && manifest.Version == requirement.RequiredVersion && manifest.Product == requirement.Product && manifest.ProductYear == requirement.ProductYear && string.Equals(manifest.Architecture, requirement.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(manifest.RolloutChannel, requirement.Channel, StringComparison.OrdinalIgnoreCase);
private static void ValidateProfile(LocalParityLaunchProfile profile)
{
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Root)) throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
var root = Path.GetFullPath(profile.Root);
if (!Path.IsPathFullyQualified(root) || root.StartsWith("\\\\", StringComparison.Ordinal) || root.Contains("?", StringComparison.Ordinal) || new DirectoryInfo(root).Exists && new DirectoryInfo(root).Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LOCAL_PARITY_ROOT_NOT_LOCAL");
foreach (var path in new[] { profile.SettingsPath, profile.DownloadListPath, profile.OverlayPath, profile.CatalogRoot, profile.EvidenceRoot }) if (!UnderRoot(root, path)) throw new InvalidOperationException("LOCAL_PARITY_PATH_OUTSIDE_ROOT");
}
private static bool UnderRoot(string root, string path) { var r = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var p = Path.GetFullPath(path); return p.StartsWith(r, StringComparison.OrdinalIgnoreCase); }
private static string Under(string root, params string[] parts) { var result = Path.GetFullPath(Path.Combine(new[] { root }.Concat(parts).ToArray())); if (!UnderRoot(root, result) && !string.Equals(Path.GetFullPath(root), result, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LOCAL_PARITY_PATH_ESCAPE"); return result; }
private static async Task<string> HashAsync(string path, CancellationToken ct) { await using var stream = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false)); }
private static string Fingerprint(string path, string salt) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + "|" + path)));
internal static async Task<string> WriteEvidenceAsync(string directory, object value, CancellationToken ct)
{
var final = Path.Combine(directory, "controlled-local-parity-launch.json");
var temp = final + ".tmp";
var json = JsonSerializer.Serialize(value);
await File.WriteAllTextAsync(temp, json, Encoding.UTF8, ct).ConfigureAwait(false);
File.Move(temp, final, overwrite: true);
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct).ConfigureAwait(false));
if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 4) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID");
return final;
}
private static string ThisCommit()
{
var embedded = Environment.GetEnvironmentVariable("LAUNCHER_COMMIT");
if (!string.IsNullOrWhiteSpace(embedded)) return embedded;
try
{
var releaseManifest = Path.Combine(AppContext.BaseDirectory, "owner-review-release.json");
if (File.Exists(releaseManifest))
{
using var document = JsonDocument.Parse(File.ReadAllText(releaseManifest));
if (document.RootElement.TryGetProperty("launcherCommit", out var commit) && !string.IsNullOrWhiteSpace(commit.GetString())) return commit.GetString()!;
}
var start = new ProcessStartInfo("git", "rev-parse HEAD") { WorkingDirectory = AppContext.BaseDirectory, UseShellExecute = false, RedirectStandardOutput = true };
using var process = Process.Start(start);
var value = process is null ? "" : process.StandardOutput.ReadToEnd().Trim();
return string.IsNullOrWhiteSpace(value) ? "unknown" : value;
}
catch { return "unknown"; }
}
}
+1 -1
View File
@@ -1 +1 @@
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next (Foundation)" Height="300" Width="540"><StackPanel Margin="20"><TextBlock FontSize="20" Text="Launcher Next — foundation"/><TextBlock Margin="0,12,0,0" Text="Plant 3D: discovery available in diagnostics"/><TextBlock Text="Mode: Legacy (default); NextPilot requires explicit configuration"/><TextBlock Text="Package: no production catalog connected"/><Button Margin="0,16,0,0" IsEnabled="False" Content="Launch (disabled: dry-run foundation)"/></StackPanel></Window> <Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next" Height="620" Width="840"><Grid Margin="20"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><TextBlock FontSize="22" FontWeight="SemiBold" Text="Launcher Next"/><GroupBox Grid.Row="1" Header="Выберите ПО" Margin="0,14,0,0"><StackPanel Margin="8"><ListBox x:Name="Products" DisplayMemberPath="Name" SelectionChanged="Products_SelectionChanged" Height="78"/><TextBlock x:Name="ProductStatus" Margin="0,7,0,0" TextWrapping="Wrap"/></StackPanel></GroupBox><GroupBox Grid.Row="2" Header="Проект и режим загрузки" Margin="0,10,0,0"><Grid Margin="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><StackPanel><TextBlock Text="Путь к проекту:"/><TextBlock x:Name="ProjectPath" Text="Выберите программное обеспечение" TextWrapping="Wrap"/><StackPanel Orientation="Horizontal" Margin="0,10,0,0"><RadioButton x:Name="AutomaticMode" Content="Автоматически" IsChecked="True" Checked="LoadMode_Changed"/><RadioButton x:Name="ListMode" Content="По списку" Margin="22,0,0,0" Checked="LoadMode_Changed"/></StackPanel></StackPanel><Button Grid.Column="1" x:Name="SelectProjectButton" Content="Выбрать проект" IsEnabled="False" VerticalAlignment="Center" Margin="16,0,0,0" Click="SelectProject_Click"/></Grid></GroupBox><GroupBox Grid.Row="3" Header="Список плагинов" Margin="0,10,0,0"><StackPanel Margin="8"><TextBlock x:Name="PluginStatus" Text="Список будет определён после выбора проекта." TextWrapping="Wrap"/><ListBox x:Name="Plugins" SelectionMode="Multiple" DisplayMemberPath="Id" Margin="0,8,0,0" Height="110" IsEnabled="False"/></StackPanel></GroupBox><StackPanel Grid.Row="4" Margin="0,14,0,0"><WrapPanel><Button x:Name="StartButton" Content="Выбрать плагины и приступить к работе" IsEnabled="False" Click="Start_Click"/><Button Content="Справка" Margin="8,0,0,0" Click="Help_Click"/><Button Content="Диагностика" Margin="8,0,0,0" Click="Diagnostics_Click"/></WrapPanel><TextBlock x:Name="Status" Margin="0,10,0,0" TextWrapping="Wrap"/><TextBlock x:Name="Version" Margin="0,5,0,0"/></StackPanel></Grid></Window>
+221 -1
View File
@@ -1 +1,221 @@
namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); } using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using Launcher.Next.Application;
using Launcher.Next.Autodesk;
namespace Launcher.Next.App;
public partial class MainWindow : System.Windows.Window
{
private sealed record LauncherProduct(string Id, string Name, string? Executable, string Diagnostic);
private readonly LocalParityLaunchProfile? localProfile;
private readonly LocalParityLaunchService localService = new();
private readonly LegacyConfigurationAdapter adapter = new();
private LauncherProduct? selected;
private string? projectPath;
private string? manualRosterPath;
private string? selectedRosterSnapshotPath;
private string stage = "ProductSelection";
private string code = "PRODUCT_NOT_SELECTED";
private string detail = "";
private string pattern = "";
private string expectedRoster = "";
private string orderedIds = "";
private string mappingState = "NotEvaluated";
private string preparationState = "NotStarted";
private string activationState = "NotStarted";
private string bootstrapState = "NotStarted";
private string processState = "NotStarted";
private int? processId;
private string packageLoadMarker = "NotStarted";
public MainWindow() { InitializeComponent(); Initialize(); }
internal MainWindow(LocalParityLaunchProfile profile) { localProfile = profile; InitializeComponent(); Initialize(); }
private void Initialize()
{
var plant = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
var navisworks = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Autodesk", "Navisworks Manage 2026", "Roamer.exe");
Products.ItemsSource = new[]
{
new LauncherProduct("navisworks", "Navisworks Manage 2026", File.Exists(navisworks) ? navisworks : null, File.Exists(navisworks) ? "Найден" : "Navisworks Manage 2026 не найден"),
new LauncherProduct("pnid", "Plant P&ID 2026", plant?.ExecutablePath, plant is null ? "Plant P&ID 2026 не найден" : "Используется установленный Plant runtime"),
new LauncherProduct("plant3d", "Plant 3D 2026", plant?.ExecutablePath, plant is null ? "Plant 3D 2026 не найден" : "Найден")
};
Version.Text = "Версия: " + typeof(MainWindow).Assembly.GetName().Version;
Transition("ProductSelection", "PRODUCT_NOT_SELECTED", "Выберите программное обеспечение");
if (localProfile is not null) Products.SelectedIndex = 2;
}
private void Products_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
selected = Products.SelectedItem as LauncherProduct;
projectPath = null;
manualRosterPath = null;
ClearOperationState();
ProjectPath.Text = selected is null ? "Выберите программное обеспечение" : "Проект не выбран";
SelectProjectButton.IsEnabled = selected?.Executable is not null;
ProductStatus.Text = selected?.Diagnostic ?? "";
Transition(selected?.Executable is null ? "ProductSelectionFailed" : "ProjectSelection", selected?.Executable is null ? "PRODUCT_NOT_FOUND" : "PROJECT_NOT_SELECTED", selected?.Executable is null ? selected?.Diagnostic ?? "Продукт не найден" : "Выберите проект");
}
private void SelectProject_Click(object sender, System.Windows.RoutedEventArgs e) => SelectProjectFile_Click(sender, e);
private void SelectProjectFile_Click(object sender, System.Windows.RoutedEventArgs e)
{
if (selected is null) return;
var product = LegacyProductSelections.For(selected.Id);
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите проект", Filter = product.Filter, CheckFileExists = true, Multiselect = false };
if (dialog.ShowDialog() != true) return;
ClearOperationState();
try
{
Transition("ProjectSelected", "PROJECT_SELECTED", "Проверка проекта");
projectPath = LegacyProductSelections.ValidateSelectedFile(product, dialog.FileName);
manualRosterPath = null;
ProjectPath.Text = Path.GetFileName(projectPath);
Transition("ProjectValidated", "PROJECT_VALID", "Поиск списка плагинов");
ResolvePlugins();
}
catch (Exception ex) { ReportFailure(ex, "ProjectValidationFailed"); }
}
private void LoadMode_Changed(object sender, System.Windows.RoutedEventArgs e)
{
if (projectPath is null || selected is null) return;
if (ListMode.IsChecked == true)
{
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите download_list.xml", Filter = "download_list.xml (*.xml)|*.xml|All files (*.*)|*.*", CheckFileExists = true, Multiselect = false };
if (dialog.ShowDialog() != true) { AutomaticMode.IsChecked = true; return; }
manualRosterPath = dialog.FileName;
}
else manualRosterPath = null;
ClearOperationState();
ResolvePlugins();
}
private void ResolvePlugins()
{
if (selected is null || projectPath is null) return;
try
{
var product = LegacyProductSelections.For(selected.Id);
pattern = localProfile is not null && selected.Id == "plant3d" ? localProfile.PatternName : product.Pattern;
expectedRoster = manualRosterPath ?? LegacyProductSelections.AutomaticRoster(product, projectPath);
Transition("AutomaticRosterPathResolved", "AUTOMATIC_ROSTER_PATH_RESOLVED", "Поиск списка плагинов", expectedRoster);
var plan = LegacyProductSelections.ReadRoster(product, projectPath, manualRosterPath, adapter, localProfile?.Root);
orderedIds = string.Join(", ", plan.OrderedIds);
selectedRosterSnapshotPath = plan.SnapshotPath ?? plan.RosterPath;
if (plan.SnapshotSha256 is not null) detail = $"Roster snapshot SHA-256: {plan.SnapshotSha256}";
Plugins.ItemsSource = plan.OrderedIds;
Plugins.IsEnabled = false;
PluginStatus.Text = "Источник списка: " + (manualRosterPath is null ? "Автоматически" : "Выбран вручную");
Transition("OrderedPluginIdsResolved", "PLUGIN_PLAN_RESOLVED", "Плагины определены");
mappingState = plan.UnmappedIds.Count == 0 ? "Mapped" : "Unmapped: " + string.Join(", ", plan.UnmappedIds);
if (plan.UnmappedIds.Count > 0)
{
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", $"Для {product.DisplayName} отсутствуют пакеты 2026: {string.Join(", ", plan.UnmappedIds)}");
return;
}
if (localProfile is null || selected.Id != "plant3d")
{
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026");
return;
}
var unmapped = EvaluateLocalProfileDefinitions(plan);
if (unmapped.Count > 0)
{
mappingState = "Known legacy plugin, package 2026 absent: " + string.Join(", ", unmapped);
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для части плагинов проекта пока отсутствуют пакеты версии 2026");
return;
}
mappingState = "Mapped";
StartButton.IsEnabled = true;
Transition("Ready", "READY", "Готово к запуску");
}
catch (Exception ex) { ReportFailure(ex, "RosterResolutionFailed"); }
}
private IReadOnlyList<string> EvaluateLocalProfileDefinitions(LegacyPluginPlan plan)
{
var settings = adapter.ParseSettings(File.ReadAllText(localProfile!.SettingsPath));
var selectedPattern = settings.Patterns.SingleOrDefault(x => string.Equals(x.Name, localProfile.PatternName, StringComparison.Ordinal));
if (selectedPattern is null) throw new InvalidOperationException("LOCAL_PARITY_PATTERN_NOT_FOUND");
var overlay = LegacyCompatibilityOverlayParser.Parse(File.ReadAllText(localProfile.OverlayPath));
var missing = new List<string>();
foreach (var id in plan.OrderedIds.Distinct(StringComparer.OrdinalIgnoreCase))
{
var definitions = selectedPattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray();
if (definitions.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED");
if (definitions.Length > 1) throw new InvalidOperationException("AMBIGUOUS_LEGACY_PLUGIN_DEFINITION");
if (!definitions[0].IsAllowedOn(Environment.MachineName)) { missing.Add(id + " (machine restricted)"); continue; }
if (!overlay.Plugins.Any(x => string.Equals(x.LegacyId, id, StringComparison.OrdinalIgnoreCase))) missing.Add(id);
}
return missing;
}
private async void Start_Click(object sender, System.Windows.RoutedEventArgs e)
{
if (localProfile is null || selected?.Id != "plant3d" || projectPath is null || !StartButton.IsEnabled) return;
StartButton.IsEnabled = false;
try
{
preparationState = "InProgress";
Transition("PackagePreparation", "PACKAGE_PREPARING", "Подготовка плагинов");
var profile = localProfile with { DownloadListPath = selectedRosterSnapshotPath ?? localProfile.DownloadListPath };
var prepared = await localService.PrepareAsync(profile, CancellationToken.None);
preparationState = "Succeeded";
activationState = string.Join(", ", prepared.Packages.Select(x => x.Activation.Outcome));
bootstrapState = "Created";
Transition("ProcessStart", "PROCESS_STARTING", "Запуск Plant 3D");
var result = await localService.LaunchAsync(prepared, CancellationToken.None);
processId = result.ProcessId;
processState = result.StartupVerified ? "Started" : "ExitedEarly";
packageLoadMarker = result.PackageLoadConfirmed ? "AssemblyInitializeCompleted" : "Missing";
if (result.TerminalState == "Succeeded") Transition("Ready", "PACKAGE_LOAD_CONFIRMED", $"Plant 3D готов к работе (PID {result.ProcessId})");
else Transition("PackageLoadFailed", "PACKAGE_LOAD_NOT_CONFIRMED", "Plant 3D запущен, но автоматическая загрузка плагинов не подтверждена");
}
catch (Exception ex) { ReportFailure(ex, "PackagePreparationFailed"); }
finally { StartButton.IsEnabled = stage == "Ready"; }
}
private void ClearOperationState()
{
Plugins.ItemsSource = null;
Plugins.IsEnabled = false;
StartButton.IsEnabled = false;
pattern = expectedRoster = orderedIds = detail = "";
selectedRosterSnapshotPath = null;
mappingState = preparationState = activationState = bootstrapState = processState = "NotStarted";
processId = null;
packageLoadMarker = "NotStarted";
}
private void Transition(string nextStage, string nextCode, string message, string? technicalDetail = null)
{
stage = nextStage;
code = nextCode;
detail = technicalDetail ?? "";
Status.Text = message;
}
private void ReportFailure(Exception exception, string fallbackStage)
{
var failure = LauncherOperationFailures.Classify(exception.Message, fallbackStage, fallbackStage == "ProcessStartFailed");
Transition(failure.Stage, failure.Code, failure.UserMessage, exception.ToString());
}
private void Help_Click(object sender, System.Windows.RoutedEventArgs e) => System.Windows.MessageBox.Show("Выберите ПО и project file. В автоматическом режиме Launcher использует список плагинов, определённый проектом. Режим \"По списку\" позволяет явно выбрать download_list.xml.", "Справка");
private void Diagnostics_Click(object sender, System.Windows.RoutedEventArgs e)
{
var report = $"Launcher version: {typeof(MainWindow).Assembly.GetName().Version}\nSelected product: {selected?.Name}\nDiscovery: {selected?.Diagnostic}\nSelected project: {projectPath}\nProject validation: {(projectPath is null ? "NotStarted" : "Passed")}\nSelected mode: {(AutomaticMode.IsChecked == true ? "Automatic" : "Manual")}\nPattern: {pattern}\nExpected download list: {expectedRoster}\nOrdered legacy IDs: {orderedIds}\nDefinition join: {mappingState}\nPreparation: {preparationState}\nActivation: {activationState}\nBootstrap: {bootstrapState}\nProcess start: {processState}\nPID: {processId}\nPackage-load marker: {packageLoadMarker}\nLast error stage: {stage}\nLast error code: {code}\nTechnical detail: {detail}";
System.Windows.MessageBox.Show(report, "Диагностика Launcher Next");
}
}
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
namespace Launcher.Next.App;
internal static class PackageLifecyclePilotCli
{
private sealed class Fault(PackageLifecycleFaultPoint point) : IPackageLifecycleFaultInjector
{ public void ThrowIfRequested(PackageLifecycleFaultPoint requested) { if(requested==point) throw new IOException($"INJECTED:{point}"); } }
public static async Task<int> RunAsync(string[] args)
{
if (args.Length != 4 || args[0] != "--mode" || args[1] != "PackageLifecyclePilot" || args[2] != "--root") return 2;
ResolvedPackageLifecycleRoots roots; try { roots=new PathResolver().ResolvePackageLifecycleRoots(args[3]); } catch { return 2; }
var id=Guid.NewGuid().ToString("N"); var started=DateTimeOffset.UtcNow; var evidenceDir=Path.Combine(roots.Evidence.Value,id);
Directory.CreateDirectory(evidenceDir); Directory.CreateDirectory(roots.Catalog.Value); Directory.CreateDirectory(Path.Combine(roots.Pilot.Value,"foreign-staging-fixture"));
try
{
var store=new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher());
async Task<(ImmutableLocalPackage Package,PackageManifest Manifest)> Materialize(string version,byte seed,LocalPackageStore target)
{ var a=new[]{seed,(byte)(seed+1)};var b=new[]{(byte)(seed+2)};await File.WriteAllBytesAsync(Path.Combine(roots.Catalog.Value,"entry.dll"),a);await File.WriteAllBytesAsync(Path.Combine(roots.Catalog.Value,"payload.dat"),b);var manifest=new PackageManifest(1,"Pilot.Package",version,"Plant3D",2026,"x64","entry.dll",[new("entry.dll",a.Length,Convert.ToHexString(SHA256.HashData(a))),new("payload.dat",b.Length,Convert.ToHexString(SHA256.HashData(b)))],new("none",""),RolloutChannel:"pilot");return(await target.MaterializeAsync(manifest,roots.Catalog.Value,CancellationToken.None),manifest); }
PackageRequirement Req(string v)=>new("Pilot.Package","Plant3D",2026,"x64","pilot",v,"pilot-roster",true);
var initial=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None);
var (a,ma)=await Materialize("1.0.0",1,store); var afterMaterializeA=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);
var aa=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var repeat=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None);
var (b,mb)=await Materialize("2.0.0",4,store); var beforeB=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); await store.BindAsync(Req("2.0.0"),"2.0.0",CancellationToken.None);
var ab=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var rollback=await store.RollbackAsync(ab.Receipt!,CancellationToken.None); var current=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); var lkg=await store.ResolveAsync(Req("1.0.0"),CancellationToken.None);
async Task<string> ExpectedFault(PackageLifecycleFaultPoint point,string version)
{ try { await Materialize(version,9,new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher(),new Fault(point))); return "UnexpectedSuccess"; } catch { return "Blocked"; } }
var materializationFault=await ExpectedFault(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation,"9.0.0");
string pointerFault; try { await new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.BeforePointerReplace)).ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None);pointerFault="UnexpectedSuccess";}catch{pointerFault="Blocked";}
var foreign=Path.Combine(roots.Pilot.Value,"foreign-staging-fixture");var fake=new PackageStagingOwnership("foreign","Pilot.Package","0",foreign,DateTimeOffset.UtcNow,"foreign");var cleanup=(await store.CleanupOwnedStagingAsync(fake,CancellationToken.None)).Outcome;
var success=initial is null&&afterMaterializeA is null&&aa.Outcome=="Activated"&&repeat.Outcome=="AlreadyActiveExact"&&beforeB=="1.0.0"&&ab.Outcome=="Activated"&&rollback.Outcome=="RolledBack"&&current=="1.0.0"&&lkg?.Version=="1.0.0"&&materializationFault=="Blocked"&&pointerFault=="Blocked"&&cleanup=="OwnershipUnknown"&&Directory.Exists(foreign);
object Package(PackageManifest m)=>new{exactIdentity=new{m.PackageId,m.Product,m.ProductYear,m.Architecture,m.RolloutChannel,m.Version},manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(m))),payloadSetSha256=PayloadSet(m),payloadFiles=m.Files.Select(x=>new{x.Path,x.Length,x.Sha256})};
var evidence=new { schemaVersion=2,launcherCommit=ThisCommit(),operationId=id,mode="PackageLifecyclePilot",startedUtc=started,endedUtc=DateTimeOffset.UtcNow,terminalState=success?"Succeeded":"Blocked",exitClassification=success?"CONTROLLED_FILESYSTEM_PILOT_VERIFIED":"BLOCKED",sourceClassification="SyntheticLocal",typedRoots=new[]{Role("Catalog",roots.Catalog.Value,id),Role("Staging",roots.Staging.Value,id),Role("VersionStore",roots.VersionStore.Value,id),Role("Active",roots.Active.Value,id),Role("Evidence",roots.Evidence.Value,id)},packages=new[]{Package(ma),Package(mb)},pointers=new{initial,afterMaterializeA,afterActivateA=aa.Outcome,generationA=aa.Receipt?.PointerGeneration,repeatedA=repeat.Outcome,beforeActivateB=beforeB,afterActivateB=ab.Outcome,generationB=ab.Receipt?.PointerGeneration,afterRollback=rollback.Outcome,current,contentBoundSchema=2},exactLkg=new{selected=lkg?.Version,directoryScanAttempted=false,exactBinding=lkg is not null},failureScenarios=new[]{new{faultPoint="AfterPayloadCopyBeforeValidation",actual=materializationFault,invariant=current=="1.0.0"},new{faultPoint="BeforePointerReplace",actual=pointerFault,invariant=(await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None))=="1.0.0"},new{faultPoint="OwnershipMismatch",actual=cleanup,invariant=Directory.Exists(foreign)}},locking=new{scope="ProcessLocal",crossProcessSafety="NotVerified",networkShareSafety="NotSupported"},sideEffects=new{corporateDfsUsed=false,corporateDfsWriteAttempted=false,autoCadLaunchAttempted=false,processLaunchAttempted=false,productionPointerMutationAttempted=false,productionProjectAccessed=false,foundationUiCreated=false},claims=new{materializationIsolation="Verified",ownedStagingCleanup="Verified",immutableContentBinding="Verified",activation="Verified",activationIdempotency="Verified",receiptBoundRollback="Verified",deterministicFailureRecovery="Verified",exactLkg="Verified",processLocalConcurrency="Verified",corporateDfs="NotVerified",autoCadRuntime="NotVerified",productionAcceptance="NotVerified"}};
var final=Path.Combine(evidenceDir,"package-lifecycle-pilot.json");var temp=final+"."+id+".tmp";await using(var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)){await JsonSerializer.SerializeAsync(stream,evidence);await stream.FlushAsync();stream.Flush(true);}File.Move(temp,final);using var reread=JsonDocument.Parse(await File.ReadAllTextAsync(final));if(reread.RootElement.GetProperty("schemaVersion").GetInt32()!=2)return 10;return success?0:10;
}
catch { return 10; }
}
private static object Role(string role,string path,string salt)=>new{role,localOnly=true,topologyValidated=true,reparseRejected=true,fingerprint=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt+Path.GetFullPath(path))))};
private static string PayloadSet(PackageManifest m)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|",m.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>$"{x.Path}:{x.Length}:{x.Sha256}")))));
private static string ThisCommit()=>Environment.GetEnvironmentVariable("LAUNCHER_COMMIT")??"WORKTREE_BUILD";
}
+436
View File
@@ -0,0 +1,436 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
namespace Launcher.Next.App;
internal static class PilotCli
{
private const int Success = 0, Blocked = 2, DiscoveryFailed = 3, AcadRunning = 4, SessionConflict = 5, ProcessFailed = 6, RollbackAfterExitFailed = 7, ExplicitRollbackFailed = 8, Unexpected = 10;
public static async Task<int> RunAsync(string[] args)
{
try
{
if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "preflight" or "automate-first-apply")) return Blocked;
if (args[0] == "preflight") return await PreflightAsync(args.Skip(1).ToArray());
if (args[0] == "automate-first-apply") return await AutomateFirstApplyAsync(args.Skip(1).ToArray());
var command = args[0];
if (!TryParse(command, args.Skip(1).ToArray(), out var options, out var error)) return Fail(Blocked, error!);
var root = options.PilotRoot!;
if (command == "status") return await StatusAsync(root);
if (command == "rollback") return await RollbackAsync(root);
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!);
if (!TryResolvePaths(config, out var paths, out error)) return Fail(Blocked, error!);
if (!string.Equals(paths.LocalLauncherRoot, root, StringComparison.OrdinalIgnoreCase) || !string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH");
if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
var startupRoster = StartupRosterPreflight.Inspect(options.PackageSource!, manifest!);
Directory.CreateDirectory(Path.Combine(root, "evidence"));
StartupRosterPreflight.Write(Path.Combine(root, "evidence", "startup-roster-preflight.json"), startupRoster);
if (!startupRoster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + startupRoster.Status.ToString().ToUpperInvariant());
Directory.CreateDirectory(Path.Combine(root, "catalog"));
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
if (!File.Exists(paths.AcadExecutable)) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
var service = CreateService(paths);
var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []);
var request = new PilotPreparationRequest(config, product, manifest!, manifestHash!, options.PackageSource!, root, Path.Combine(root, "startup", "prepare-only.scr"));
var prepared = await service.PrepareAsync(request, CancellationToken.None);
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
PrintPlan(prepared.Plan);
if (command == "prepare") return Success;
var sessionScript = Path.Combine(root, "startup", Guid.NewGuid().ToString("N") + ".scr");
request = request with { StartupScriptPath = sessionScript };
prepared = await service.PrepareAsync(request, CancellationToken.None);
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
var permits = PermitService;
var permit = permits.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds);
var execution = await service.ExecuteAsync(new(request, prepared.Plan, permit), CancellationToken.None);
if (!execution.Started) return Fail(execution.BlockCode == "ACAD_ALREADY_RUNNING" ? AcadRunning : ProcessFailed, execution.BlockCode ?? "PROCESS_START_FAILED");
Console.WriteLine($"Plant 3D запущен. PID: {execution.ProcessId}. P_Rec_Annot загружается через startup script.");
if (!options.WaitForExit) return Success;
try
{
await WaitForExitAsync(execution.ProcessId!.Value);
Console.WriteLine("Plant 3D завершён штатно.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"PROCESS_WAIT_FAILED: {ex.Message}");
return ProcessFailed;
}
finally
{
if (options.RollbackAfterExit)
{
var rollback = await service.RollbackAsync(new(root, manifest!.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None);
if (!rollback.Completed) Console.Error.WriteLine(rollback.ErrorCode ?? "ROLLBACK_FAILED");
}
}
return await IsRolledBack(root) ? Success : RollbackAfterExitFailed;
}
catch (Exception ex) { return Fail(Unexpected, ex.Message); }
}
private static readonly IExecutionPermitService PermitService = new ExecutionPermitService();
private sealed record PreflightFile(string Role, string CanonicalPath, string AllowedRoot, string SourcePathVariable, bool Required, long Size, DateTimeOffset LastWriteUtc, string ReparseStatus, bool HashRequired, string? Sha256 = null);
private sealed record PreflightManifest(string SessionId, DateTimeOffset CreatedUtc, IReadOnlyList<PreflightFile> Files);
private static async Task<int> PreflightAsync(string[] args)
{
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
var root = options.PilotRoot;
var journal = await AutomationJournal.AllocateAsync(root, "NextPilotPreview", CancellationToken.None);
try
{
if (!TryReadConfiguration(root, out var config, out error) || config.LauncherMode != LauncherMode.NextPilot || !config.PilotEnabled)
return await journal.TerminalAsync("PRELAUNCH_CONFIGURATION_INVALID", "Failed", null, CancellationToken.None);
if (Process.GetProcessesByName("acad").Any()) return await journal.TerminalAsync("ACAD_ALREADY_RUNNING", "Failed", null, CancellationToken.None);
var fixtureRoot = Path.GetFullPath(Path.GetDirectoryName(options.FixtureMarker)!);
var files = new[]
{
("FixtureOrthoDwg", options.OrthoDrawing, "FixtureOrthoDwg"),
("FixtureModelDwg", options.ModelDrawing, "FixtureModelDwg"),
("PipingDcf", Path.Combine(fixtureRoot, "Piping.dcf"), "PipingDcf"),
("ProjectXml", Path.Combine(fixtureRoot, "Project.xml"), "ProjectXml")
};
var manifestFiles = new List<PreflightFile>();
foreach (var (role, path, variable) in files)
{
var canonical = Path.GetFullPath(path);
if (!canonical.StartsWith(fixtureRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
return await journal.TerminalAsync("MANIFEST_OUTSIDE_ALLOWED_ROOT", "Failed", canonical, CancellationToken.None);
var info = new FileInfo(canonical);
if (info.Attributes.HasFlag(FileAttributes.Directory)) return await journal.TerminalAsync("MANIFEST_DIRECTORY_REJECTED", "Failed", canonical, CancellationToken.None);
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return await journal.TerminalAsync("MANIFEST_REPARSE_REJECTED", "Failed", canonical, CancellationToken.None);
if (!info.Exists && role is "FixtureOrthoDwg" or "FixtureModelDwg" or "PipingDcf" or "ProjectXml") return await journal.TerminalAsync("MANIFEST_REQUIRED_FILE_MISSING", "Failed", canonical, CancellationToken.None);
manifestFiles.Add(new(role, canonical, fixtureRoot, variable, true, info.Length, info.LastWriteTimeUtc, "None", true));
}
if (manifestFiles.Count == 0 || manifestFiles.Select(x => x.CanonicalPath).Distinct(StringComparer.OrdinalIgnoreCase).Count() != manifestFiles.Count)
return await journal.TerminalAsync("MANIFEST_INVALID", "Failed", null, CancellationToken.None);
var manifest = new PreflightManifest(journal.SessionId, DateTimeOffset.UtcNow, manifestFiles);
var sessionDir = Path.Combine(root, "state", "automation-sessions", journal.SessionId);
await WriteAtomicAsync(Path.Combine(sessionDir, "manifest.json"), manifest, CancellationToken.None);
await journal.RecordAsync("ManifestPersisted", "Completed", null, CancellationToken.None);
using var cancellation = new CancellationTokenSource();
var totalBytes = manifestFiles.Sum(x => x.Size); var completedBytes = 0L; var completed = 0;
for (var fileIndex = 0; fileIndex < manifestFiles.Count; fileIndex++)
{
var file = manifestFiles[fileIndex];
await journal.RecordProgressAsync("HashFileStarted", "Started", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, false, CancellationToken.None);
try
{
await using var stream = File.OpenRead(file.CanonicalPath);
var hash = Convert.ToHexString(await HashWithWatchdogAsync(stream, cancellation.Token));
manifestFiles[fileIndex] = file with { Sha256 = hash };
completed++; completedBytes += file.Size;
await journal.RecordProgressAsync("HashFileCompleted", "Completed", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, false, CancellationToken.None);
}
catch (TimeoutException ex) { cancellation.Cancel(); await journal.RecordFailureAsync("HashFileTimedOut", file.Role, file.CanonicalPath, ex, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_PROGRESS_TIMEOUT", "Failed", file.CanonicalPath, CancellationToken.None); }
catch (OperationCanceledException) { await journal.RecordProgressAsync("HashFileCancelled", "Cancelled", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, true, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_CANCELLED", "Cancelled", file.CanonicalPath, CancellationToken.None); }
catch (Exception ex) { await journal.RecordFailureAsync("HashFileFailed", file.Role, file.CanonicalPath, ex, CancellationToken.None); return await journal.TerminalAsync("HASH_FILE_FAILED", "Failed", file.CanonicalPath, CancellationToken.None); }
}
await WriteAtomicAsync(Path.Combine(sessionDir, "manifest.json"), manifest with { Files = manifestFiles }, CancellationToken.None);
await journal.RecordAsync("GateBeforeAutoCADLaunch", "Completed", null, CancellationToken.None);
return await journal.TerminalAsync("PRELAUNCH_GATE_PASSED_AUTOCAD_NOT_STARTED", "Succeeded", null, CancellationToken.None);
}
catch (Exception ex) { await journal.RecordFailureAsync("PreflightFailed", null, null, ex, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_PREFLIGHT_FAILED", "Failed", null, CancellationToken.None); }
}
private static async Task<byte[]> HashWithWatchdogAsync(Stream stream, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var hashing = SHA256.HashDataAsync(stream, linked.Token).AsTask();
var watchdog = Task.Delay(TimeSpan.FromSeconds(30));
if (await Task.WhenAny(hashing, watchdog) == watchdog) { linked.Cancel(); throw new TimeoutException("PRELAUNCH_PROGRESS_TIMEOUT"); }
return await hashing;
}
private sealed record AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string AnnotationTemplateDwg, string EvidenceRoot, bool Diagnostic);
private sealed record PreviewEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, string? PlanIdRun1, string? PlanIdRun2, bool? PlanIdsEqual, int? EligibleCandidateCount, string? SelectedCandidateHash);
private sealed record ApplyEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, bool? PermitAccepted, string? ApplyOutcomeRun1, string? ApplyOutcomeRun2, object? Baseline, object? AfterCommand1, object? AfterCommand2, object? AfterUndo);
private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState, string AnnotationTemplateDwgPath);
private sealed record AutomationSessionResult(bool Started, bool GracefulExitVerified, bool ForcedCleanupUsed, bool RollbackVerified, bool EvidenceCompleted);
private static async Task<int> AutomateFirstApplyAsync(string[] args)
{
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
var journal = await AutomationJournal.AllocateAsync(options.PilotRoot, "NextPilotPreview", CancellationToken.None);
await journal.RecordAsync("ArgumentsParsed", "Completed", null, CancellationToken.None);
if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!);
await journal.RecordAsync("ConfigurationLoaded", "Completed", null, CancellationToken.None);
if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!);
if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED");
if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
Directory.CreateDirectory(options.EvidenceRoot);
var roster = StartupRosterPreflight.Inspect(options.PackageSource, manifest!);
StartupRosterPreflight.Write(Path.Combine(options.EvidenceRoot, "startup-roster-preflight.json"), roster);
await journal.RecordAsync("StartupRosterPreflight", roster.IsSafe ? "Completed" : "Failed", roster.Status.ToString(), CancellationToken.None);
if (!roster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + roster.Status.ToString().ToUpperInvariant());
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None);
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256;
var targetCommand = baseConfig.AutomationSelection?.CommandName ?? "PXP-OA";
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", targetCommand, orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "preview-transcript.log"), diagnostic = options.Diagnostic }));
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
var previewConfig = WithAutomationPaths(baseConfig, options, previewConfigPath, previewEvidence, null, "preview") with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotPreview, AutomationDiagnostic = options.Diagnostic };
var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence);
if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED");
var preview = JsonSerializer.Deserialize<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID");
if (!HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
// A diagnostic preview establishes the script/registration boundary only.
// It must never mint an Apply permit or start an Apply process.
if (options.Diagnostic) return Success;
var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json");
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "М25 DN15-32", options.AnnotationTemplateDwg)));
var applyConfigPath = Path.Combine(options.EvidenceRoot, "automation-apply.config.json");
var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.json");
await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", targetCommand, orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "apply-transcript.log"), diagnostic = false }));
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply, AutomationDiagnostic = false };
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
var hashesEqual = HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None));
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
if (File.Exists(permitPath)) File.Delete(permitPath);
var applyVerified = apply is { Completed: true, CommandsCompleted: true, InspectorsCompleted: true, EvidenceFlushed: true, PermitAccepted: true, ApplyOutcomeRun1: "Created", ApplyOutcomeRun2: "AlreadyExists" } && apply.Baseline is not null && apply.AfterCommand1 is not null && apply.AfterCommand2 is not null && apply.AfterUndo is not null;
return applySession.Started && applySession.RollbackVerified && applySession.EvidenceCompleted && applyVerified && hashesEqual ? Success : Fail(ProcessFailed, !hashesEqual ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_EVIDENCE_INVALID");
}
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath)
{
if (!TryResolvePaths(config, out var paths, out _)) return new(false, false, false, false, false);
var service = CreateService(paths); var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []);
var request = new PilotPreparationRequest(config, product, manifest, manifestHash, options.PackageSource, options.PilotRoot, Path.Combine(options.PilotRoot, "startup", phase + ".scr"));
var prepared = await service.PrepareAsync(request, CancellationToken.None);
if (prepared.Plan is null)
{
Console.Error.WriteLine("AUTOMATION_PREPARE_BLOCKED:" + string.Join(',', prepared.SafetyDecision.Violations));
return new(false, false, false, false, false);
}
var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None);
if (!execution.Started || execution.ProcessId is null)
{
Console.Error.WriteLine("AUTOMATION_EXECUTION_BLOCKED:" + (execution.BlockCode ?? "PROCESS_START_FAILED"));
return new(false, false, false, false, false);
}
var forcedCleanup = false;
var gracefulExit = false;
try
{
var exit = WaitForExitAsync(execution.ProcessId.Value);
if (!config.ForceAfterEvidence) { await exit; gracefulExit = true; }
else
{
// The graceful-exit timeout is deliberately measured only after
// terminal evidence is durable. Killing an active AutoCAD
// command before that boundary would turn ForceAfterEvidence
// into an unsafe general process killer.
// Evidence is written by the Plant process, not by a task owned
// by this host. Poll it with a bounded interval: waiting only on
// the timeout task delays an already durable result by a full
// timeout and makes the preview/apply hand-off unnecessarily slow.
await WaitForEvidenceOrExitAsync(exit, evidencePath, TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
if (exit.IsCompleted) gracefulExit = true;
else
{
var timeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
if (HasCompletedEvidence(evidencePath) && await Task.WhenAny(exit, timeout) == exit) gracefulExit = true;
else
{
using var process = Process.GetProcessById(execution.ProcessId.Value);
if (!process.HasExited) { process.Kill(entireProcessTree: true); forcedCleanup = true; }
await exit;
}
}
}
}
finally { await service.RollbackAsync(new(options.PilotRoot, manifest.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); }
var evidenceCompleted = HasCompletedEvidence(evidencePath);
return new(true, gracefulExit, forcedCleanup, await IsRolledBack(options.PilotRoot), evidenceCompleted);
}
private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error)
{
options = null!; error = null; var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var diagnostic = false;
for (var i = 0; i < args.Length; i++) { if (args[i] == "--diagnostic") { if (diagnostic) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } diagnostic = true; continue; } if (i + 1 >= args.Length || !args[i].StartsWith("--") || !d.TryAdd(args[i], args[++i])) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } }
string? V(string x) => d.GetValueOrDefault(x);
if (d.Keys.Except(new[] { "--package-source", "--entry-assembly", "--pilot-root", "--fixture-marker", "--ortho-dwg", "--model-dwg", "--annotation-template-dwg", "--evidence-root" }).Any() || new[] { V("--package-source"), V("--entry-assembly"), V("--pilot-root"), V("--fixture-marker"), V("--ortho-dwg"), V("--model-dwg"), V("--annotation-template-dwg"), V("--evidence-root") }.Any(string.IsNullOrWhiteSpace)) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; }
var paths = new[] { V("--package-source")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; }
if (!File.Exists(V("--annotation-template-dwg"))) { error = "ANNOTATION_TEMPLATE_ASSET_MISSING"; return false; }
options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")!, diagnostic); return true;
}
private static async Task<Dictionary<string,string>> ContentHashesAsync(string marker, AutomationJournal journal, CancellationToken cancellationToken)
{
var root = Path.GetDirectoryName(marker)!;
var files = new[] { Path.Combine(root, "Piping.dcf"), Path.Combine(root, "Project.xml") }
.Concat(Directory.EnumerateFiles(Path.Combine(root, "Orthos", "DWGs"), "*.dwg", SearchOption.TopDirectoryOnly))
.Concat(Directory.EnumerateFiles(Path.Combine(root, "Plant 3D Models"), "*.dwg", SearchOption.TopDirectoryOnly))
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
var result = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in files) { if (!File.Exists(path)) throw new InvalidOperationException("HASH_FILE_NOT_FOUND"); await journal.RecordAsync("HashFileStarted", "Started", path, cancellationToken); await using var stream = File.OpenRead(path); result[Path.GetRelativePath(root,path)] = Convert.ToHexString(await SHA256.HashDataAsync(stream,cancellationToken)); await journal.RecordAsync("HashFileCompleted", "Completed", path, cancellationToken); }
return result;
}
private sealed class AutomationJournal
{
private readonly string path; private readonly string root; private readonly DateTimeOffset started=DateTimeOffset.UtcNow; private int sequence;
public string SessionId { get; }
private AutomationJournal(string root, string sessionId) { this.root=root; SessionId=sessionId; path=Path.Combine(root,"state","automation-sessions",sessionId,"session-events.jsonl"); }
public static async Task<AutomationJournal> AllocateAsync(string root,string mode,CancellationToken ct) { var id=Guid.NewGuid().ToString("N"); var dir=Path.Combine(root,"state","automation-sessions",id); Directory.CreateDirectory(dir); var j=new AutomationJournal(root,id); await j.RecordAsync("SessionAllocated","Completed",null,ct); var pointer=Path.Combine(root,"state","automation-current.json"); await WriteAtomicAsync(pointer,new { sessionId=id,launchMode=mode,state="SessionAllocated",terminal=false,currentStage="InitialEvidenceFlushed",createdUtc=DateTimeOffset.UtcNow },ct); await WriteAtomicAsync(Path.Combine(root,"state","pilot-session.json"),new { sessionId=id,state="InitialEvidenceFlushed",launchMode=mode,foundationUiCreated=false },ct); await j.RecordAsync("InitialEvidenceFlushed","Completed",null,ct); return j; }
public Task RecordAsync(string stage,string status,string? currentPath,CancellationToken ct) => RecordProgressAsync(stage,status,null,currentPath,0,0,0,0,false,ct);
public Task RecordProgressAsync(string stage,string status,string? role,string? currentPath,int processed,int total,long processedBytes,long totalBytes,bool cancellationRequested,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,elapsedMs=(long)(DateTimeOffset.UtcNow-started).TotalMilliseconds,sessionId=SessionId,launcherPid=Environment.ProcessId,currentStage=stage,currentOperation=status,currentRole=role,currentPath,processedFiles=processed,totalFiles=total,processedBytes,totalBytes,lastProgressUtc=DateTimeOffset.UtcNow,cancellationRequested,foundationUiCreated=false,managedThreadId=Environment.CurrentManagedThreadId })+Environment.NewLine,ct);
public Task RecordFailureAsync(string stage,string? role,string? currentPath,Exception ex,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,sessionId=SessionId,stage,status="Failed",role,path=currentPath,exceptionType=ex.GetType().FullName,exception=ex.ToString(),cancellationRequested=true })+Environment.NewLine,ct);
public async Task<int> TerminalAsync(string code,string state,string? currentPath,CancellationToken ct) { await RecordProgressAsync("TerminalEvidence",state,null,currentPath,0,0,0,0,state!="Succeeded",ct); await WriteAtomicAsync(Path.Combine(root,"state","pilot-session.json"),new { sessionId=SessionId,state,terminal=true,terminalCode=code,foundationUiCreated=false,autocadStarted=false,launcherPid=Environment.ProcessId,completedUtc=DateTimeOffset.UtcNow },ct); Console.WriteLine($"PreflightSession={SessionId}; Terminal={state}; Code={code}; AutoCADStarted=false; FoundationUICreated=false"); return state == "Succeeded" ? Success : ProcessFailed; }
}
private static async Task WriteAtomicAsync<T>(string path, T value, CancellationToken ct)
{ Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp=path+"."+Guid.NewGuid().ToString("N")+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),ct); File.Move(temp,path,true); }
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value);
private static PilotExecutionService CreateService(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver());
private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
private static async Task WaitForEvidenceOrExitAsync(Task exit, string evidencePath, TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath) && DateTimeOffset.UtcNow < deadline)
await Task.WhenAny(exit, Task.Delay(TimeSpan.FromMilliseconds(250)));
}
private static bool HasCompletedEvidence(string path)
{
try { using var json = JsonDocument.Parse(File.ReadAllText(path)); return json.RootElement.TryGetProperty("Completed", out var completed) && completed.ValueKind == JsonValueKind.True; }
catch { return false; }
}
private static async Task<int> RollbackAsync(string root)
{
if (!TryReadConfiguration(root, out var config, out _) || !TryResolvePaths(config, out var paths, out _)) return Fail(ExplicitRollbackFailed, "PATH_CONFIGURATION_INVALID");
var result = await CreateService(paths).RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED");
}
private static async Task<int> StatusAsync(string root)
{
var session = SessionPath(root);
if (!File.Exists(session)) { Console.WriteLine("State: Clean"); return Success; }
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(session));
var state = document.RootElement.GetProperty("State").GetString() ?? "Unknown";
Console.WriteLine($"State: {state}");
return state is "RolledBack" ? Success : SessionConflict;
}
private static async Task<int> HandleExistingSession(string root)
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
var state = document.RootElement.GetProperty("State").GetString();
return state == "RolledBack" ? Success : Fail(SessionConflict, "PILOT_SESSION_ALREADY_ACTIVE");
}
private static async Task<bool> HasActiveSession(string root)
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
return document.RootElement.GetProperty("State").GetString() != "RolledBack";
}
private static async Task<bool> IsRolledBack(string root)
{
if (!File.Exists(SessionPath(root))) return false;
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
return document.RootElement.GetProperty("State").GetString() == "RolledBack";
}
private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json");
private static bool TryReadConfiguration(string root, out PilotLocalConfiguration config, out string? error)
{
config = PilotLocalConfiguration.SafeDefault; error = null; var directory = Path.Combine(root, "config"); var defaults = Path.Combine(directory, "launcher.defaults.json"); var machine = Path.Combine(directory, "launcher.machine.json"); var session = Path.Combine(directory, "launcher.session.json");
if (!File.Exists(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration;
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
return true;
}
private static bool TryResolvePaths(PilotLocalConfiguration configuration, out ResolvedLauncherPaths paths, out string? error)
{
try
{
var resolver = new PathResolver(); var variables = new LauncherPathVariables(configuration.PathVariables); var raw = resolver.Resolve(variables, [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]);
var resolved = new Dictionary<string, string?>(raw.Values, StringComparer.OrdinalIgnoreCase);
foreach (var name in new[] { LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.LocalPackageCache, LauncherPathVariableNames.LocalActiveRoot, LauncherPathVariableNames.LocalSessionRoot, LauncherPathVariableNames.LocalEvidenceRoot, LauncherPathVariableNames.LocalLogsRoot, LauncherPathVariableNames.LocalRollbackRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.PRecEntryAssembly, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.FixtureModelDwg, LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.AutomationConfigPath, LauncherPathVariableNames.AutomationEvidencePath, LauncherPathVariableNames.AutomationPermitPath, LauncherPathVariableNames.AutomationTranscriptPath })
if (raw.Get(name) is { Length: > 0 } value) resolved[name] = resolver.ResolvePath(value, variables);
paths = new(resolved); error = null; return true;
}
catch (Exception ex) { paths = null!; error = ex.Message; return false; }
}
private static PilotLocalConfiguration WithAutomationPaths(PilotLocalConfiguration config, AutomationOptions options, string configPath, string evidencePath, string? permitPath, string phase)
{
var variables = new Dictionary<string, string?>(config.PathVariables, StringComparer.OrdinalIgnoreCase)
{
[LauncherPathVariableNames.LocalSessionRoot] = options.EvidenceRoot,
[LauncherPathVariableNames.AutomationScriptPath] = Path.Combine(options.EvidenceRoot, phase + ".scr"),
[LauncherPathVariableNames.AutomationConfigPath] = configPath,
[LauncherPathVariableNames.AutomationEvidencePath] = evidencePath,
[LauncherPathVariableNames.AutomationPermitPath] = permitPath,
[LauncherPathVariableNames.AutomationTranscriptPath] = Path.Combine(options.EvidenceRoot, phase + "-transcript.log"),
[LauncherPathVariableNames.FixtureOrthoDwg] = options.OrthoDrawing,
[LauncherPathVariableNames.FixtureModelDwg] = options.ModelDrawing,
[LauncherPathVariableNames.PRecEntryAssembly] = Path.Combine(options.PackageSource, options.EntryAssembly)
};
return config with { PathVariables = variables };
}
private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error)
{
manifest = null; hash = null; error = null;
if (!Directory.Exists(source) || !File.Exists(Path.Combine(source, entry))) { error = "PACKAGE_SOURCE_INVALID"; return false; }
var list = new List<PackageFile>();
foreach (var path in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, path);
var name = Path.GetFileName(relative);
if (relative.StartsWith("..") || IsForbidden(name)) { error = "PACKAGE_FILE_FORBIDDEN"; return false; }
using var stream = File.OpenRead(path); var bytes = SHA256.HashData(stream);
list.Add(new(relative, new FileInfo(path).Length, Convert.ToHexString(bytes)));
}
var assemblyVersion = "0.1.0-pilot";
try { assemblyVersion = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? assemblyVersion; } catch { }
// A pilot version directory is immutable for the duration of its activation.
// Bind its name to the validated package content so a rebuilt local bundle
// cannot silently reuse a stale directory with the same assembly version.
var contentIdentity = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).Select(x => $"{x.Path}:{x.Sha256}")))))[..12];
var version = assemblyVersion + "-" + contentIdentity;
manifest = new PackageManifest(1, "P_Rec_Annot", version, "Plant3D", 2026, "x64", entry, list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).ToArray(), new("netload", "PHP_REC_ANNOT"), "0.1.0", ["R25.1"], "net8.0-windows", null, null, "pilot", false, true);
var json = JsonSerializer.Serialize(manifest); hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
return true;
}
private static bool IsForbidden(string name) => name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || name.Contains("test", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Ac", StringComparison.OrdinalIgnoreCase) || name.StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Autodesk.ProcessPower", StringComparison.OrdinalIgnoreCase);
private static void PrintPlan(PilotLaunchPlan plan) => Console.WriteLine($"Plan={plan.PlanId}; Package={plan.PackageId}/{plan.PackageVersion}; NETLOAD=1; Args={string.Join(' ', plan.Process.Arguments)}; /p=false");
private static int Fail(int code, string error) { Console.Error.WriteLine(error); return code; }
private sealed record Options(string? PackageSource, string? EntryAssembly, string? PilotRoot, bool WaitForExit, bool RollbackAfterExit);
private static bool TryParse(string command, string[] args, out Options options, out string? error)
{
options = new(null, null, null, false, false); error = null; var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var wait = false; var rollback = false;
for (var i = 0; i < args.Length; i++)
{
var token = args[i];
if (token is "--wait-for-exit" or "--rollback-after-exit")
{
if (command != "run" || (token == "--wait-for-exit" ? wait : rollback)) { error = "PILOT_ARGUMENT_INVALID"; return false; }
if (token == "--wait-for-exit") wait = true; else rollback = true; continue;
}
if (token is not ("--package-source" or "--entry-assembly" or "--pilot-root") || i + 1 >= args.Length || args[i + 1].StartsWith("--") || !values.TryAdd(token, args[++i])) { error = "PILOT_ARGUMENT_INVALID"; return false; }
}
if (rollback && !wait) { error = "ROLLBACK_AFTER_EXIT_REQUIRES_WAIT"; return false; }
if (!values.TryGetValue("--pilot-root", out var root) || !Path.IsPathFullyQualified(root) || root.StartsWith("\\\\")) { error = "PILOT_ROOT_INVALID"; return false; }
if (command is "prepare" or "run")
{
if (!values.TryGetValue("--package-source", out var source) || source is null || !Path.IsPathFullyQualified(source) || source.StartsWith("\\\\") || Path.GetFullPath(source).StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { error = "PACKAGE_SOURCE_INVALID"; return false; }
if (!values.TryGetValue("--entry-assembly", out var entry) || entry is null || !string.Equals(Path.GetFileName(entry), entry, StringComparison.Ordinal) || entry.Contains("..", StringComparison.Ordinal)) { error = "ENTRY_ASSEMBLY_INVALID"; return false; }
}
else if (values.Keys.Any(x => x != "--pilot-root")) { error = "PILOT_ARGUMENT_INVALID"; return false; }
options = new(values.GetValueOrDefault("--package-source"), values.GetValueOrDefault("--entry-assembly"), root, wait, rollback); return true;
}
private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning() => Process.GetProcessesByName("acad").Any(); }
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Launcher.Next.Tests")]
@@ -0,0 +1,22 @@
namespace Launcher.Next.Application;
public sealed record LauncherOperationFailure(string Stage, string Code, string UserMessage, bool ProcessStartAttempted);
public static class LauncherOperationFailures
{
public static LauncherOperationFailure Classify(string code, string fallbackStage, bool processStartAttempted = false) => code switch
{
"LEGACY_DOWNLOAD_LIST_NOT_FOUND" => new("RosterResolutionFailed", "AUTOMATIC_DOWNLOAD_LIST_NOT_FOUND", "Для выбранного проекта не найден список плагинов", false),
"DOWNLOAD_LIST_XML_MALFORMED" => new("PluginPlanFailed", "DOWNLOAD_LIST_XML_MALFORMED", "Некорректный download_list.xml", false),
"DOWNLOAD_LIST_ROOT_INVALID" => new("PluginPlanFailed", "DOWNLOAD_LIST_ROOT_INVALID", "download_list.xml имеет недопустимый корневой элемент", false),
"LEGACY_PLUGIN_ID_NOT_DEFINED" => new("PluginPlanFailed", "DOWNLOAD_LIST_ID_UNKNOWN", "В Settings.xml отсутствует описание плагина", false),
"AMBIGUOUS_LEGACY_PLUGIN_DEFINITION" => new("PluginPlanFailed", "DOWNLOAD_LIST_PLUGIN_DEFINITION_AMBIGUOUS", "Определение плагина в Settings.xml неоднозначно", false),
"LEGACY_PLUGIN_PRODUCT_MISMATCH" => new("PluginPlanFailed", "PLUGIN_PRODUCT_MISMATCH", "Список плагинов не совместим с выбранным продуктом", false),
"LEGACY_MAPPING_NOT_FOUND" => new("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026", false),
"LOCAL_PARITY_ROSTER_PROFILE_MISMATCH" => new("RosterResolutionFailed", "OWNER_REVIEW_PROFILE_ROSTER_MISMATCH", "Контролируемая конфигурация не соответствует выбранному проекту", false),
_ when code.Contains("PROCESS", StringComparison.Ordinal) || code.Contains("PLANT3D_2026_NOT_FOUND", StringComparison.Ordinal) => new("ProcessStartFailed", "PROCESS_START_FAILED", "Не удалось запустить Autodesk", true),
_ when code.Contains("ACTIVATION", StringComparison.Ordinal) => new("PackageActivationFailed", "PACKAGE_ACTIVATION_FAILED", "Не удалось активировать пакет", false),
_ when code.Contains("MANIFEST", StringComparison.Ordinal) => new("PackagePreparationFailed", "PACKAGE_MANIFEST_INVALID", "Пакет повреждён", false),
_ => new(fallbackStage, code, "Операция не выполнена: " + code, processStartAttempted)
};
}
@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Launcher.Next.Application;
public static class LegacyCompatibilityOverlayParser
{
private sealed record Package(string Id,string Product,int ProductYear,string Architecture,string Channel,string Version);
private sealed record Entry(string MappingId,string Pattern,string LegacyPluginId,string? LegacyCatalog,string? LegacyInstall,Package Package);
private sealed record Document(int SchemaVersion,IReadOnlyList<Entry> Mappings);
private static readonly JsonSerializerOptions Options=new(){PropertyNameCaseInsensitive=true,UnmappedMemberHandling=JsonUnmappedMemberHandling.Disallow};
public static LegacyCompatibilityOverlay Parse(string json)
{
try
{
var document=JsonSerializer.Deserialize<Document>(json,Options)??throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");
if(document.SchemaVersion!=1) throw new InvalidOperationException("LEGACY_MAPPING_SCHEMA_UNSUPPORTED");
if(document.Mappings.Count==0||document.Mappings.GroupBy(x=>x.MappingId,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("LEGACY_MAPPING_DUPLICATE");
var entries=document.Mappings.Select(x=>{
if(string.IsNullOrWhiteSpace(x.MappingId)||string.IsNullOrWhiteSpace(x.LegacyPluginId)||string.IsNullOrWhiteSpace(x.Package.Id)||x.Package.Version is "latest"||x.Package.Version.Contains('*')||x.Package.Version.Contains('>')||x.Package.Version.Contains('<')||!System.Text.RegularExpressions.Regex.IsMatch(x.Package.Version,@"^\d+\.\d+\.\d+(\.\d+)?$")) throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");
if(!string.Equals(x.Package.Product,"Plant3D",StringComparison.Ordinal)||x.Package.ProductYear!=2026||!string.Equals(x.Package.Architecture,"x64",StringComparison.OrdinalIgnoreCase)||!new[]{"pilot","stable","development","controlled-local"}.Contains(x.Package.Channel,StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_MAPPING_INCOMPATIBLE");
return new LegacyCompatibilityMapping(x.LegacyPluginId,x.Package.Id,x.Package.Version,x.Package.Channel);
}).ToArray();
var first=document.Mappings[0].Package;
return new("legacy-overlay-v1",first.Product,first.ProductYear,first.Architecture,false,entries);
}
catch(JsonException){throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");}
}
}
@@ -0,0 +1,93 @@
using System.Xml;
using System.Xml.Linq;
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed record LegacyPluginDefinition(string Id, string Renewal, string Catalog, string Install, int DefinitionOrder, IReadOnlyList<string>? Machines = null)
{
public bool IsAllowedOn(string machineName) => Machines is null || Machines.Count == 0 || Machines.Contains(machineName, StringComparer.OrdinalIgnoreCase);
}
public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList<LegacyPluginDefinition> Plugins, IReadOnlyList<string>? Attributes = null, IReadOnlyList<string>? Sources = null, string? Target = null, string? Loader = null, string? ObjectPath = null);
public sealed record LegacyLauncherSettings(string Help, string CareMarker, IReadOnlyList<LegacyProductPattern> Patterns);
public sealed record LegacyDownloadListEntry(int ElementIndex, string RawId, string NormalizedId, int? FirstOccurrenceIndex, string SourceShape);
public sealed record LegacyDownloadSelection(IReadOnlyList<string> PluginIds, IReadOnlyList<LegacyDownloadListEntry>? Entries = null)
{
public IReadOnlyList<string> EffectivePluginIds => PluginIds.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
public sealed record LegacyCompatibilityMapping(string LegacyId, string PackageId, string Version, string Channel);
public sealed record LegacyCompatibilityOverlay(string MappingId, string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, IReadOnlyList<LegacyCompatibilityMapping> Plugins);
public sealed class LegacyConfigurationAdapter
{
private static readonly XmlReaderSettings XmlSettings = new() { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 1_000_000 };
public LegacyLauncherSettings ParseSettings(string xml)
{
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); var root = XDocument.Load(reader).Root ?? throw new InvalidOperationException("LEGACY_SETTINGS_INVALID");
var path = root.Element("path") ?? throw new InvalidOperationException("LEGACY_SETTINGS_PATH_MISSING");
var patterns = root.Elements("data").Select(data =>
{
var pattern = data.Element("pattern") ?? throw new InvalidOperationException("LEGACY_PATTERN_MISSING");
string Value(string name) => pattern.Element(name)?.Value ?? throw new InvalidOperationException("LEGACY_PATTERN_FIELD_MISSING:" + name);
var plugins = data.Elements("plugin").Select((plugin, index) => new LegacyPluginDefinition(
plugin.Element("id")?.Value ?? throw new InvalidOperationException("LEGACY_PLUGIN_ID_MISSING"),
plugin.Element("renewal")?.Value ?? throw new InvalidOperationException("LEGACY_RENEWAL_MISSING"),
plugin.Element("catalog")?.Value ?? throw new InvalidOperationException("LEGACY_CATALOG_MISSING"),
plugin.Element("install")?.Value ?? throw new InvalidOperationException("LEGACY_INSTALL_MISSING"), index,
plugin.Elements("machine").Select(x => x.Value.Trim()).Where(x => x.Length > 0).ToArray())).ToArray();
return new LegacyProductPattern(Value("name"), Value("filter"), Value("roster"), plugins,
pattern.Elements("attrib").Select(x => x.Value).ToArray(),
pattern.Elements("source").Elements("course").Select(x => x.Value).ToArray(),
pattern.Element("target")?.Value, pattern.Element("loader")?.Value, pattern.Element("object")?.Value);
}).ToArray();
return new(path.Element("help")?.Value ?? throw new InvalidOperationException("LEGACY_HELP_MISSING"), path.Element("care")?.Value ?? throw new InvalidOperationException("LEGACY_CARE_MISSING"), patterns);
}
public LegacyDownloadSelection ParseDownloadList(string xml)
{
try
{
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings);
var root = XDocument.Load(reader).Root;
if (root?.Name != "root") throw new InvalidOperationException("DOWNLOAD_LIST_ROOT_INVALID");
// F2_Xml serialized only root/id and removed only exact empty strings. Real project files
// also contain source-grouped <area><id> entries; preserve their document order as the
// modern plan while retaining raw values and duplicate evidence.
var direct = root.Elements("id").Select(x => (Element: x, Shape: "Root"));
var grouped = root.Elements("area").SelectMany(area => area.Elements("id").Select(x => (Element: x, Shape: "Area")));
var entries = (direct.Any() ? direct : grouped).Select((entry, index) =>
{
var raw = entry.Element.Value;
var normalized = raw; // legacy F2_Xml did not Trim or case-normalize IDs.
return new LegacyDownloadListEntry(index, raw, normalized, null, entry.Shape);
}).ToArray();
var seen = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var annotated = entries.Select(entry =>
{
if (entry.RawId == "") return entry;
if (seen.TryGetValue(entry.NormalizedId, out var first)) return entry with { FirstOccurrenceIndex = first };
seen[entry.NormalizedId] = entry.ElementIndex;
return entry;
}).ToArray();
return new(annotated.Where(x => x.RawId != "").Select(x => x.RawId).ToArray(), annotated);
}
catch (XmlException) { throw new InvalidOperationException("DOWNLOAD_LIST_XML_MALFORMED"); }
}
public IReadOnlyList<PackageRequirement> Join(LegacyProductPattern pattern, LegacyDownloadSelection selection, LegacyCompatibilityOverlay overlay)
{
var result = new List<PackageRequirement>();
foreach (var id in selection.EffectivePluginIds)
{
var definition = pattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray();
if (definition.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED");
if (definition.Length > 1) throw new InvalidOperationException("AMBIGUOUS_LEGACY_PLUGIN_DEFINITION");
var mapping = overlay.Plugins.Where(x => string.Equals(x.LegacyId, id, StringComparison.OrdinalIgnoreCase)).ToArray();
if (mapping.Length == 0) throw new InvalidOperationException("LEGACY_MAPPING_NOT_FOUND");
if (mapping.Length > 1) throw new InvalidOperationException("LEGACY_MAPPING_DUPLICATE");
result.Add(new(mapping[0].PackageId, overlay.Product, overlay.ProductYear, overlay.Architecture, mapping[0].Channel, mapping[0].Version, overlay.MappingId, overlay.AllowOfflineFallback));
}
return result;
}
}
@@ -0,0 +1,51 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed record LegacyConfigurationProbeRequest(ResolvedLegacySettingsPath Settings, ResolvedLegacyDownloadListPath DownloadList, ResolvedLegacyCompatibilityOverlayPath Overlay, ResolvedLegacyProbeEvidenceRoot EvidenceRoot, string PatternName, string OperationId);
public sealed record LegacyConfigurationProbeResult(string TerminalState, IReadOnlyList<PackageRequirement> Requirements, string EvidencePath, IReadOnlyList<string> Diagnostics);
/// <summary>Read-only configuration inspection. It intentionally has no package,
/// activation, process, or Autodesk dependency.</summary>
public sealed class LegacyConfigurationProbe(LegacyConfigurationAdapter adapter)
{
public async Task<LegacyConfigurationProbeResult> RunAsync(LegacyConfigurationProbeRequest request, CancellationToken cancellationToken = default)
{
var settingsPath = request.Settings.Value;
var selectionPath = request.DownloadList.Value;
var overlayPath = request.Overlay.Value;
var evidenceRoot = request.EvidenceRoot.Value;
if (!File.Exists(settingsPath) || !File.Exists(selectionPath) || !File.Exists(overlayPath)) throw new InvalidOperationException("LEGACY_PROBE_INPUT_PATH_INVALID");
if (evidenceRoot.StartsWith(@"\\", StringComparison.Ordinal) || SameOrNested(evidenceRoot, Path.GetDirectoryName(settingsPath)!) || SameOrNested(evidenceRoot, Path.GetDirectoryName(selectionPath)!) || SameOrNested(evidenceRoot, Path.GetDirectoryName(overlayPath)!)) throw new InvalidOperationException("LEGACY_PROBE_PATH_COLLISION");
Directory.CreateDirectory(evidenceRoot);
var before = await Snapshot(settingsPath, selectionPath, overlayPath, cancellationToken);
try
{
var settings = adapter.ParseSettings(await ReadOnlyTextAsync(settingsPath, cancellationToken));
var pattern = settings.Patterns.Where(x => string.Equals(x.Name, request.PatternName, StringComparison.Ordinal)).ToArray();
if (pattern.Length != 1) throw new InvalidOperationException(pattern.Length == 0 ? "LEGACY_PATTERN_NOT_FOUND" : "LEGACY_PATTERN_AMBIGUOUS");
var overlay = LegacyCompatibilityOverlayParser.Parse(await ReadOnlyTextAsync(overlayPath, cancellationToken));
var requirements = adapter.Join(pattern[0], adapter.ParseDownloadList(await ReadOnlyTextAsync(selectionPath, cancellationToken)), overlay);
var after = await Snapshot(settingsPath, selectionPath, overlayPath, cancellationToken);
if (!before.SequenceEqual(after)) throw new InvalidOperationException("LEGACY_PROBE_SOURCE_CHANGED_DURING_READ");
var output = await WriteEvidenceAsync(evidenceRoot, request.OperationId, new { evidenceSchemaVersion=1, evidenceKind="legacy-configuration-probe", mode="LegacyConfigurationProbe", operationId=request.OperationId, terminalState="Succeeded", exitClassification="Success", selectedPattern=pattern[0].Name, selectedIdCount=requirements.Count, requirements, inputs=before.Select(x=>Redact(x,request.OperationId)), sideEffects=SideEffects() }, cancellationToken);
return new("Succeeded",requirements,output,[]);
}
catch(Exception ex)
{
var code=ex.Message.StartsWith("LEGACY_",StringComparison.Ordinal)||ex.Message.StartsWith("AMBIGUOUS_",StringComparison.Ordinal)?ex.Message:"LEGACY_PROBE_INTERNAL_FAILURE";
var output=await WriteEvidenceAsync(evidenceRoot,request.OperationId,new { evidenceSchemaVersion=1,evidenceKind="legacy-configuration-probe",mode="LegacyConfigurationProbe",operationId=request.OperationId,terminalState="Blocked",exitClassification=code,diagnostics=new[]{code},inputs=before.Select(x=>Redact(x,request.OperationId)),sideEffects=SideEffects() },cancellationToken);
return new("Blocked",[],output,[code]);
}
}
private static object SideEffects()=>new { sourceWrites=false,payloadCopied=false,stagingCreated=false,packageCommitted=false,activationAttempted=false,pointerMutationAttempted=false,autoCadLaunchAttempted=false,dfsWriteAttempted=false };
private static bool SameOrNested(string child,string parent) { var c=Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; var p=Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; return c.StartsWith(p,StringComparison.OrdinalIgnoreCase); }
private static async Task<string[]> Snapshot(string a,string b,string c,CancellationToken ct) => [await One("LegacySettingsPath",a,ct),await One("LegacyDownloadListPath",b,ct),await One("LegacyCompatibilityOverlayPath",c,ct)];
private static async Task<string> One(string role,string path,CancellationToken ct) { var info=new FileInfo(path); if(info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LEGACY_PROBE_REPARSE_SOURCE_REJECTED"); await using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.Read); return string.Join('|',role,Path.GetFileName(path),info.Length,info.LastWriteTimeUtc.Ticks,Convert.ToHexString(await SHA256.HashDataAsync(stream,ct)),path); }
private static async Task<string> ReadOnlyTextAsync(string path,CancellationToken ct) { await using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.Read); using var reader=new StreamReader(stream,Encoding.UTF8,true); return await reader.ReadToEndAsync(ct); }
private static object Redact(string value,string operationId) { var parts=value.Split('|'); return new { role=parts[0],basename=parts[1],length=parts[2],lastWriteUtcTicks=parts[3],sha256=parts[4],pathFingerprint=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(operationId+"|"+parts[5]))) }; }
private static async Task<string> WriteEvidenceAsync(string root,string operationId,object evidence,CancellationToken ct) { var operation=Path.Combine(root,operationId); Directory.CreateDirectory(operation); var final=Path.Combine(operation,"legacy-configuration-probe.json"); var temp=final+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(evidence),ct); File.Move(temp,final,true); return final; }
}
@@ -0,0 +1,88 @@
using System.Security.Cryptography;
namespace Launcher.Next.Application;
public sealed record LegacySettingsSnapshot(string SourceEndpoint, string SourceFingerprint, string Sha256, long Length, DateTimeOffset LastWriteUtc, DateTimeOffset AcquiredUtc, string LocalSnapshotPath, bool OfflineFallback);
public static class LegacyNetworkConfiguration
{
public const string ApprovedSettingsEndpoint = @"\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml";
public const string LegacyBimAlias = @"N:\BIM";
public const string ApprovedBimEndpoint = @"\\phimproject.com\common\BIM";
public static string ResolveApprovedAlias(string legacyPath)
{
if (string.IsNullOrWhiteSpace(legacyPath)) throw new InvalidOperationException("LEGACY_NETWORK_PATH_INVALID");
var normalized = legacyPath.Replace('/', '\\');
if (string.Equals(normalized, LegacyBimAlias, StringComparison.OrdinalIgnoreCase)) return ApprovedBimEndpoint;
var prefix = LegacyBimAlias + "\\";
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_NETWORK_ALIAS_NOT_APPROVED");
return ApprovedBimEndpoint + normalized[(prefix.Length - 1)..];
}
public static LegacySettingsSnapshot AcquireApprovedSnapshot(string endpoint, string localRoot)
{
if (!string.Equals(endpoint, ApprovedSettingsEndpoint, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_SETTINGS_ENDPOINT_NOT_APPROVED");
if (!Path.IsPathFullyQualified(endpoint) || !endpoint.StartsWith(@"\\", StringComparison.Ordinal) || endpoint.Contains('?') || endpoint.Contains('*')) throw new InvalidOperationException("LEGACY_SETTINGS_ENDPOINT_INVALID");
if (!File.Exists(endpoint) || File.GetAttributes(endpoint).HasFlag(FileAttributes.ReparsePoint)) return OfflineSnapshot(endpoint, localRoot);
var first = new FileInfo(endpoint);
var sourceHashBefore = Hash(endpoint);
var snapshots = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots");
Directory.CreateDirectory(snapshots);
var temporary = Path.Combine(snapshots, Guid.NewGuid().ToString("N") + ".tmp");
File.Copy(endpoint, temporary, false);
var copiedHash = Hash(temporary);
var after = new FileInfo(endpoint);
var sourceHashAfter = Hash(endpoint);
if (first.Length != after.Length || first.LastWriteTimeUtc != after.LastWriteTimeUtc || !string.Equals(sourceHashBefore, sourceHashAfter, StringComparison.Ordinal) || !string.Equals(copiedHash, sourceHashAfter, StringComparison.Ordinal))
{
File.Delete(temporary);
throw new InvalidOperationException("LEGACY_SETTINGS_CHANGED_DURING_ACQUISITION");
}
var final = Path.Combine(snapshots, copiedHash + ".xml");
if (File.Exists(final)) File.Delete(temporary); else File.Move(temporary, final);
if (!string.Equals(Hash(final), copiedHash, StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_SETTINGS_SNAPSHOT_HASH_MISMATCH");
return new(endpoint, Fingerprint(endpoint, copiedHash), copiedHash, first.Length, new DateTimeOffset(first.LastWriteTimeUtc), DateTimeOffset.UtcNow, final, false);
}
private static LegacySettingsSnapshot OfflineSnapshot(string endpoint, string localRoot)
{
var snapshots = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots");
if (!Directory.Exists(snapshots)) throw new InvalidOperationException("LEGACY_SETTINGS_NETWORK_UNAVAILABLE");
var candidate = Directory.GetFiles(snapshots, "*.xml").OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
if (candidate is null) throw new InvalidOperationException("LEGACY_SETTINGS_NETWORK_UNAVAILABLE");
var info = new FileInfo(candidate);
var hash = Hash(candidate);
return new(endpoint, Fingerprint(endpoint, hash), hash, info.Length, new DateTimeOffset(info.LastWriteTimeUtc), DateTimeOffset.UtcNow, candidate, true);
}
private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
private static string Fingerprint(string endpoint, string hash) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(endpoint + "|" + hash)));
}
public static class LegacyReadOnlySnapshots
{
public static LegacySettingsSnapshot Acquire(string source, string localRoot, string role)
{
if (!Path.IsPathFullyQualified(source) || !source.StartsWith(@"\\", StringComparison.Ordinal) || source.Contains('?') || source.Contains('*') || !File.Exists(source) || File.GetAttributes(source).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("READONLY_SNAPSHOT_SOURCE_INVALID");
var before = new FileInfo(source);
var beforeHash = Hash(source);
var directory = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots", role);
Directory.CreateDirectory(directory);
var temporary = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".tmp");
File.Copy(source, temporary, false);
var copiedHash = Hash(temporary);
var after = new FileInfo(source);
var afterHash = Hash(source);
if (before.Length != after.Length || before.LastWriteTimeUtc != after.LastWriteTimeUtc || beforeHash != afterHash || copiedHash != afterHash)
{
File.Delete(temporary);
throw new InvalidOperationException("READONLY_SNAPSHOT_SOURCE_CHANGED");
}
var final = Path.Combine(directory, copiedHash + ".xml");
if (File.Exists(final)) File.Delete(temporary); else File.Move(temporary, final);
return new(source, Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(role + "|" + source + "|" + copiedHash))), copiedHash, before.Length, new DateTimeOffset(before.LastWriteTimeUtc), DateTimeOffset.UtcNow, final, false);
}
private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
}
@@ -0,0 +1,39 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Launcher.Next.Application;
public sealed record LegacyProbeOverride(
int SchemaVersion,
string Mode,
string SettingsXml,
string DownloadListXml,
string CompatibilityOverlay,
string SelectedPattern,
string EvidenceOutputRoot);
public static class LegacyProbeOverrideParser
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
public static LegacyProbeOverride Parse(string json)
{
try
{
var value = JsonSerializer.Deserialize<LegacyProbeOverride>(json, Options)
?? throw new InvalidOperationException("LEGACY_PROBE_OVERRIDE_MISSING");
if (value.SchemaVersion != 1) throw new InvalidOperationException("LEGACY_PROBE_OVERRIDE_SCHEMA_UNSUPPORTED");
if (!string.Equals(value.Mode, "LegacyConfigurationProbe", StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_PROBE_MODE_REQUIRED");
if (new[] { value.SettingsXml, value.DownloadListXml, value.CompatibilityOverlay, value.SelectedPattern, value.EvidenceOutputRoot }.Any(string.IsNullOrWhiteSpace))
throw new InvalidOperationException("LEGACY_PROBE_INPUT_MISSING");
if (new[] { value.SettingsXml, value.DownloadListXml, value.CompatibilityOverlay, value.EvidenceOutputRoot }.Any(x => Uri.TryCreate(x, UriKind.Absolute, out var uri) && !uri.IsFile || x.Contains('*') || x.Contains('?')))
throw new InvalidOperationException("LEGACY_PROBE_INPUT_PATH_INVALID");
return value;
}
catch (JsonException) { throw new InvalidOperationException("LEGACY_PROBE_OVERRIDE_UNKNOWN_FIELD"); }
}
}
@@ -0,0 +1,38 @@
namespace Launcher.Next.Application;
public sealed record LegacyProductSelection(string Id, string DisplayName, string Filter, string RequiredFileName, string Pattern, string[] SourceVerifiedLegacyIds, string[] UnmappedLegacyIds, bool SupportsPlantBootstrap);
public sealed record LegacyPluginPlan(string RosterPath, string Source, IReadOnlyList<string> OrderedIds, IReadOnlyList<string> UnmappedIds, string? SnapshotPath = null, string? SnapshotSha256 = null);
public static class LegacyProductSelections
{
public static readonly LegacyProductSelection Plant3D = new("plant3d", "Plant 3D 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant 3D_2019", [], [], true);
public static readonly LegacyProductSelection Pnid = new("pnid", "Plant P&ID 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant PnID_2019", ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], true);
public static readonly LegacyProductSelection Navisworks = new("navisworks", "Navisworks Manage 2026", "nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", ".nwd", "Navisworks_Manage_2018", ["7dd981169fa14d21aaef3fcdb0e7e970"], ["7dd981169fa14d21aaef3fcdb0e7e970"], false);
public static LegacyProductSelection For(string id) => id switch { "plant3d" => Plant3D, "pnid" => Pnid, "navisworks" => Navisworks, _ => throw new InvalidOperationException("LEGACY_PRODUCT_UNKNOWN") };
public static string ValidateSelectedFile(LegacyProductSelection product, string selected)
{
var file = Path.GetFullPath(selected);
if (!File.Exists(file) || File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LEGACY_PROJECT_FILE_INVALID");
var correctName = product.RequiredFileName.StartsWith('.') ? file.EndsWith(product.RequiredFileName, StringComparison.OrdinalIgnoreCase) : string.Equals(Path.GetFileName(file), product.RequiredFileName, StringComparison.OrdinalIgnoreCase);
if (!correctName) throw new InvalidOperationException("LEGACY_PROJECT_FILE_PRODUCT_MISMATCH");
return file;
}
public static string AutomaticRoster(LegacyProductSelection product, string selectedFile)
{
var projectDirectory = Path.GetDirectoryName(ValidateSelectedFile(product, selectedFile))!;
return Path.GetFullPath(Path.Combine(projectDirectory, "..", "_System", product.Pattern, "Applications", "download_list.xml"));
}
public static LegacyPluginPlan ReadRoster(LegacyProductSelection product, string selectedFile, string? manualXml, LegacyConfigurationAdapter parser, string? snapshotRoot = null)
{
var roster = manualXml is null ? AutomaticRoster(product, selectedFile) : Path.GetFullPath(manualXml);
if (!File.Exists(roster) || File.GetAttributes(roster).HasFlag(FileAttributes.ReparsePoint) || !string.Equals(Path.GetFileName(roster), "download_list.xml", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_NOT_FOUND");
var snapshot = roster.StartsWith(@"\\", StringComparison.Ordinal) && snapshotRoot is not null ? LegacyReadOnlySnapshots.Acquire(roster, snapshotRoot, "project-rosters") : null;
var ids = parser.ParseDownloadList(File.ReadAllText(snapshot?.LocalSnapshotPath ?? roster)).PluginIds;
if (product.SourceVerifiedLegacyIds.Length > 0 && ids.Any(id => !product.SourceVerifiedLegacyIds.Contains(id, StringComparer.OrdinalIgnoreCase))) throw new InvalidOperationException("LEGACY_PLUGIN_PRODUCT_MISMATCH");
return new(roster, manualXml is null ? "Automatic" : "Manual", ids, product.UnmappedLegacyIds.Intersect(ids, StringComparer.OrdinalIgnoreCase).ToArray(), snapshot?.LocalSnapshotPath, snapshot?.Sha256);
}
}
@@ -0,0 +1,60 @@
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed class PackageResolutionUseCase
{
private readonly IPackageCatalogPackageAcquirer catalog; private readonly IPackageSynchronizer synchronizer; private readonly IPackageRequirementCacheBinder binder; private readonly ILastKnownGoodPackageResolver cache; private readonly ILauncherTelemetry telemetry; private readonly IPackageMaterializer? materializer;
public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, ILauncherTelemetry t) { catalog=c; synchronizer=s; binder=b; cache=l; telemetry=t; }
public PackageResolutionUseCase(IPackageCatalogPackageAcquirer c, IPackageSynchronizer s, IPackageRequirementCacheBinder b, ILastKnownGoodPackageResolver l, IPackageMaterializer m, ILauncherTelemetry t) : this(c,s,b,l,t) { materializer=m; }
public async Task<PackageResolutionResult> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken)
{
var evidence = new List<PackageResolutionEvidence> { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) };
telemetry.Write("PackageResolutionStarted", new Dictionary<string, object?> { ["packageId"] = requirement.PackageId, ["product"] = requirement.Product, ["channel"] = requirement.Channel, ["offlineAllowed"] = requirement.AllowOfflineFallback });
try
{
var online = await catalog.AcquireAsync(requirement, cancellationToken);
if (online is not null && Matches(requirement, online.Manifest))
{
if (materializer is not null)
{
var committed = await materializer.MaterializeAsync(online.Manifest, online.SourceRoot, cancellationToken);
await binder.BindAsync(requirement, committed.Version, cancellationToken);
evidence.Add(new("ONLINE_PACKAGE_MATERIALIZED_NOT_ACTIVATED", committed.Version));
return new(new CachedPackageResolution(committed.PackageId, committed.Version, committed.LocalPackageRoot, false, "MATERIALIZED_NOT_ACTIVATED"), true, evidence);
}
await synchronizer.SynchronizeAsync(online.Manifest, online.SourceRoot, cancellationToken);
await binder.BindAsync(requirement, online.Manifest.Version, cancellationToken);
var selected = await cache.ResolveAsync(requirement, cancellationToken);
if (selected is not null && !selected.IsOfflineFallback)
{
evidence.Add(new("ONLINE_PACKAGE_SELECTED", online.Manifest.Version));
telemetry.Write("PackageResolutionSelected", new Dictionary<string, object?> { ["code"] = "ONLINE_PACKAGE_SELECTED", ["packageId"] = selected.PackageId, ["version"] = selected.Version });
return new(selected, true, evidence);
}
evidence.Add(new("ONLINE_PACKAGE_REJECTED", "LOCAL_REVALIDATION_FAILED"));
}
else evidence.Add(new("CATALOG_UNAVAILABLE", online is null ? "NO_CATALOG_PACKAGE" : "REQUIREMENT_MISMATCH"));
}
catch (Exception ex) when (ex is not OperationCanceledException) { evidence.Add(new("CATALOG_UNAVAILABLE", "ACQUISITION_OR_STAGING_FAILED")); }
if (!requirement.AllowOfflineFallback)
{
evidence.Add(new("OFFLINE_FALLBACK_DISABLED", "POLICY"));
evidence.Add(new("NO_SAFE_PACKAGE_AVAILABLE", "BLOCKED"));
telemetry.Write("PackageResolutionBlocked", new Dictionary<string, object?> { ["code"] = "OFFLINE_FALLBACK_DISABLED", ["packageId"] = requirement.PackageId });
return new(null, false, evidence);
}
var cached = await cache.ResolveAsync(requirement, cancellationToken);
if (cached is null)
{
evidence.Add(new("NO_SAFE_PACKAGE_AVAILABLE", "CACHE_REJECTED"));
telemetry.Write("PackageResolutionBlocked", new Dictionary<string, object?> { ["code"] = "NO_SAFE_PACKAGE_AVAILABLE", ["packageId"] = requirement.PackageId });
return new(null, false, evidence);
}
var code = cached.IsOfflineFallback ? "PREVIOUS_LKG_SELECTED" : "VALID_CURRENT_CACHE_SELECTED";
evidence.Add(new(code, cached.Version));
telemetry.Write("PackageResolutionSelected", new Dictionary<string, object?> { ["code"] = code, ["packageId"] = cached.PackageId, ["version"] = cached.Version });
return new(cached, false, evidence);
}
private static bool Matches(PackageRequirement r, PackageManifest m) => string.Equals(r.PackageId, m.PackageId, StringComparison.Ordinal) && string.Equals(r.Product, m.Product, StringComparison.Ordinal) && r.ProductYear == m.ProductYear && string.Equals(r.Architecture, m.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(r.Channel, m.RolloutChannel, StringComparison.OrdinalIgnoreCase) && string.Equals(r.RequiredVersion, m.Version, StringComparison.Ordinal);
}
@@ -0,0 +1,59 @@
using System.Xml;
using System.Xml.Linq;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public enum RosterSource { ProjectRosterJson, LegacyDownloadListXml, CentralDefaultRoster, None }
public sealed record RosterPackage(string Id, string Version, string Channel, bool Enabled, int LoadOrder, bool AllowedForMachine = true);
public sealed record RosterContext(string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, string LegacyRosterId);
public sealed record NormalizedRoster(string RosterId, string Product, int Year, string Architecture, bool AllowOfflineFallback, IReadOnlyList<RosterPackage> Packages);
public sealed record RosterSelection(RosterSource Source, NormalizedRoster? Roster, string Code);
public sealed class RosterAdapter
{
public NormalizedRoster ParseJson(string json)
{
using var doc=JsonDocument.Parse(json); var r=doc.RootElement;
if (!r.TryGetProperty("schemaVersion",out var s)||s.GetInt32()!=1) throw new InvalidOperationException("ROSTER_SCHEMA_INVALID");
string V(string n)=>r.TryGetProperty(n,out var x)&&!string.IsNullOrWhiteSpace(x.GetString())?x.GetString()!:throw new InvalidOperationException("ROSTER_FIELD_REQUIRED:"+n);
var packages=r.GetProperty("packages").EnumerateArray().Select(x=>new RosterPackage(x.GetProperty("id").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_ID_REQUIRED"),x.GetProperty("version").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_VERSION_REQUIRED"),x.GetProperty("channel").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_CHANNEL_REQUIRED"),!x.TryGetProperty("enabled",out var e)||e.GetBoolean(),x.TryGetProperty("loadOrder",out var o)?o.GetInt32():0)).ToArray();
if(packages.GroupBy(x=>x.Id,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("ROSTER_DUPLICATE_PACKAGE");
return new(V("rosterId"),V("product"),r.GetProperty("year").GetInt32(),V("architecture"),r.TryGetProperty("offlinePolicy",out var p)&&p.GetString()=="AllowVerifiedLastKnownGood",packages.OrderBy(x=>x.LoadOrder).ThenBy(x=>x.Id,StringComparer.Ordinal).ToArray());
}
public NormalizedRoster ParseLegacyDownloadList(string xml, RosterContext context)
{
var settings=new XmlReaderSettings{DtdProcessing=DtdProcessing.Prohibit,XmlResolver=null,MaxCharactersInDocument=1_000_000}; using var reader=XmlReader.Create(new StringReader(xml),settings); var doc=XDocument.Load(reader);
var packages=doc.Descendants("Plugin").Select((x,i)=>new RosterPackage((string?)x.Attribute("id")??throw new InvalidOperationException("LEGACY_PACKAGE_ID_REQUIRED"),(string?)x.Attribute("version")??throw new InvalidOperationException("AMBIGUOUS_LEGACY_SEMANTICS"),(string?)x.Attribute("channel")??"pilot",!string.Equals((string?)x.Attribute("install"),"false",StringComparison.OrdinalIgnoreCase),i)).ToArray();
if(packages.GroupBy(x=>x.Id,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("ROSTER_DUPLICATE_PACKAGE");
return new(context.LegacyRosterId,context.Product,context.ProductYear,context.Architecture,context.AllowOfflineFallback,packages);
}
public RosterSelection Select(string? projectJson,string? legacyXml,string? centralJson,RosterContext context)
{ if(projectJson is not null)return new(RosterSource.ProjectRosterJson,ParseJson(projectJson),"PROJECT_ROSTER_JSON"); if(legacyXml is not null)return new(RosterSource.LegacyDownloadListXml,ParseLegacyDownloadList(legacyXml,context),"LEGACY_DOWNLOAD_LIST_XML"); if(centralJson is not null)return new(RosterSource.CentralDefaultRoster,ParseJson(centralJson),"CENTRAL_DEFAULT_ROSTER"); return new(RosterSource.None,null,"NO_ROSTER_AVAILABLE"); }
public IReadOnlyList<PackageRequirement> Requirements(NormalizedRoster roster)=>roster.Packages.Where(x=>x.Enabled&&x.AllowedForMachine).Select(x=>new PackageRequirement(x.Id,roster.Product,roster.Year,roster.Architecture,x.Channel,x.Version,roster.RosterId,roster.AllowOfflineFallback)).ToArray();
}
public sealed record PackagePlanResult(bool IsComplete,IReadOnlyList<PackageResolutionResult> Packages,IReadOnlyList<PackageResolutionEvidence> Evidence);
public sealed class RosterPackagePlanUseCase(RosterAdapter rosterAdapter,PackageResolutionUseCase packageResolution)
{
public async Task<PackagePlanResult> ResolveAsync(RosterSelection selection,CancellationToken cancellationToken=default)
{
var evidence=new List<PackageResolutionEvidence>();
if(selection.Roster is null){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ROSTER_AVAILABLE"));return new(false,Array.Empty<PackageResolutionResult>(),evidence);}
evidence.Add(new("ROSTER_SOURCE_SELECTED",$"{selection.Code}:{selection.Roster.RosterId}"));
evidence.Add(new("ROSTER_PARSED",selection.Roster.RosterId));
evidence.Add(new("ROSTER_VALIDATED",selection.Roster.RosterId));
var requirements=rosterAdapter.Requirements(selection.Roster);
if(requirements.Count==0){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ENABLED_PACKAGE_REQUIREMENTS"));return new(false,Array.Empty<PackageResolutionResult>(),evidence);}
var results=new List<PackageResolutionResult>();
foreach(var requirement in requirements)
{
evidence.Add(new("PACKAGE_REQUIREMENT_CREATED",$"{requirement.PackageId}:{requirement.RosterIdentity}"));
var result=await packageResolution.ResolveAsync(requirement,cancellationToken);
results.Add(result);evidence.AddRange(result.Evidence);
if(!result.IsResolved){evidence.Add(new("PACKAGE_PLAN_BLOCKED",$"PACKAGE_REQUIREMENT_REJECTED:{requirement.PackageId}"));return new(false,results,evidence);}
}
evidence.Add(new("PACKAGE_PLAN_COMPLETED",selection.Roster.RosterId));
return new(true,results,evidence);
}
}
+37 -4
View File
@@ -7,15 +7,48 @@ public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDi
{ {
public ProductInstallation? DiscoverPlant3D(int year) public ProductInstallation? DiscoverPlant3D(int year)
{ {
var install = registry.GetValue($@"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-xxxx:409", "InstallDir") as string; if (year != 2026) return null;
if (string.IsNullOrWhiteSpace(install)) return null; var exe = Path.Combine(install, "acad.exe"); const string plantInstall = @"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-9117\Install";
return File.Exists(exe) ? new(exe, year.ToString(), "x64", []) : null; var install = registry.GetValue(plantInstall, "InstallDir") as string;
var exe = string.IsNullOrWhiteSpace(install) ? null : Path.Combine(install, "acad.exe");
if (exe is null || !File.Exists(exe) || !Directory.Exists(Path.Combine(install!, "PLNT3D"))) return null;
return new(exe, "2026", "x64", []);
} }
} }
public sealed class StartupScriptBuilder : IStartupScriptBuilder public sealed class StartupScriptBuilder : IStartupScriptBuilder
{ {
public string BuildNetloadScript(string root, string entry) public string BuildNetloadScript(string root, string entry)
{ var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"(command \"._netload\" \"{full.Replace("\\", "\\\\").Replace("\"", "\\\"")}\")\n"; } { var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"_.NETLOAD\n\"{full.Replace("\"", "\\\"")}\"\n"; }
public string BuildAutomationScript(ResolvedAutomationScriptPaths paths)
{
var fixture = Path.GetFullPath(paths.FixtureOrthoDwg);
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
var dialogFreeOpen = "_.FILEDIA\n0\n_.CMDDIA\n0\n";
var diagnostic = paths.Diagnostic
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
: string.Empty;
var commands = paths.Selection is null ? BuildDefaultCommands(paths) : BuildSelectedEntityCommands(paths);
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
}
private static string BuildDefaultCommands(ResolvedAutomationScriptPaths paths) => paths.Apply
? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n"
: paths.Diagnostic
? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n"
: "PXP-OA\nPXP-OA\n";
private static string BuildSelectedEntityCommands(ResolvedAutomationScriptPaths paths)
{
var selection = paths.Selection!;
selection.Validate();
var invoke = "(setq P_REC_ANNOT_SELECTED_ENTITY (handent \"" + selection.ObjectHandle.ToUpperInvariant() + "\"))\n" +
"(if (and P_REC_ANNOT_SELECTED_ENTITY (= (cdr (assoc 0 (entget P_REC_ANNOT_SELECTED_ENTITY))) \"" + selection.ExpectedDxfName.ToUpperInvariant() + "\")) " +
"(command-s \"" + selection.CommandName.ToUpperInvariant() + "\" P_REC_ANNOT_SELECTED_ENTITY) " +
"(progn (prompt \"\\nP_REC_ANNOT_SELECTION_REJECTED\") (quit)))\n";
return paths.Apply ? "_.UNDO\n_MARK\n" + invoke + invoke + "_.UNDO\n_BACK\n" : invoke + invoke;
}
} }
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{ {
+24 -2
View File
@@ -1,6 +1,7 @@
namespace Launcher.Next.Core; namespace Launcher.Next.Core;
public enum LauncherMode { Legacy, NextPilot, NextPreferred } public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
public enum PluginExecutionMode { Smoke, DryRun, Apply }
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles); public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false); public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
public sealed record StartupDefinition(string Type, string Command); public sealed record StartupDefinition(string Type, string Command);
@@ -16,18 +17,39 @@ public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); } { public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
public sealed record PackageProgress(string Stage, int Completed, int Total); public sealed record PackageProgress(string Stage, int Completed, int Total);
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion); public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
public sealed record PackagePayloadBinding(string RelativePath,long Length,string Sha256);
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 2, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null, IReadOnlyList<PackagePayloadBinding>? PayloadFiles = null, long PointerGenerationBefore = 0, string? EvidenceCorrelationId = null);
public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt);
public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion);
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot);
public sealed record PackageStagingOwnership(string OperationId, string PackageId, string Version, string StagingDirectory, DateTimeOffset CreatedUtc, string MarkerIdentity);
public sealed record PackageStagingCleanupResult(string Outcome, string OperationId);
public enum PackageLifecycleFaultPoint { AfterStagingAllocated, DuringPayloadCopy, AfterPayloadCopyBeforeValidation, BeforeImmutableCommit, AfterImmutableCommitBeforeReturn, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, BeforeRollbackReadback, DuringOwnedStagingCleanup }
public interface IPackageLifecycleFaultInjector { void ThrowIfRequested(PackageLifecycleFaultPoint point); }
public sealed class NoOpPackageLifecycleFaultInjector : IPackageLifecycleFaultInjector { public void ThrowIfRequested(PackageLifecycleFaultPoint point) { } }
public interface IPackageMaterializer { Task<ImmutableLocalPackage> MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public sealed record PackageRequirement(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity, bool AllowOfflineFallback);
public sealed record CatalogPackage(PackageManifest Manifest, string SourceRoot);
public sealed record PackageResolutionEvidence(string Code, string Detail);
public sealed record PackageResolutionResult(CachedPackageResolution? Selected, bool Online, IReadOnlyList<PackageResolutionEvidence> Evidence)
{ public bool IsResolved => Selected is not null; }
public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun); public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart); public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); } public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); } public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageCatalogPackageAcquirer { Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); } public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); } public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); } public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); } public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); } public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
public sealed record CachedPackageResolution(string PackageId, string Version, string LocalPackageRoot, bool IsOfflineFallback, string Reason);
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); } public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); } public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply, ControlledEntitySelection? Selection = null);
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); } public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); } public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
public interface IClock { DateTimeOffset UtcNow { get; } } public interface IClock { DateTimeOffset UtcNow { get; } }
+240
View File
@@ -0,0 +1,240 @@
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Launcher.Next.Core;
public sealed record LauncherPathVariables(IReadOnlyDictionary<string, string?> Values)
{
public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null;
}
public sealed record ResolvedLauncherPaths(IReadOnlyDictionary<string, string?> Values)
{
public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null;
public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot));
public string AcadExecutable => Require(nameof(AcadExecutable));
public string? AutomationScriptPath => Get(nameof(AutomationScriptPath));
public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg));
}
/// <summary>Catalog source role resolved by <see cref="IPathResolver"/>. Raw
/// filesystem strings are intentionally not accepted by catalog acquisition.</summary>
public sealed record ResolvedCatalogRoot
{
internal ResolvedCatalogRoot(string value) => Value = value;
public string Value { get; }
}
public sealed record ResolvedLegacySettingsPath { internal ResolvedLegacySettingsPath(string value) => Value=value; public string Value { get; } }
public sealed record ResolvedLegacyDownloadListPath { internal ResolvedLegacyDownloadListPath(string value) => Value=value; public string Value { get; } }
public sealed record ResolvedLegacyCompatibilityOverlayPath { internal ResolvedLegacyCompatibilityOverlayPath(string value) => Value=value; public string Value { get; } }
public sealed record ResolvedLegacyProbeEvidenceRoot { internal ResolvedLegacyProbeEvidenceRoot(string value) => Value=value; public string Value { get; } }
public sealed record ResolvedLocalPackageCacheRoot { internal ResolvedLocalPackageCacheRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageVersionStoreRoot { internal ResolvedPackageVersionStoreRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageStagingRoot { internal ResolvedPackageStagingRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedActivePackageRoot { internal ResolvedActivePackageRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageLifecycleEvidenceRoot { internal ResolvedPackageLifecycleEvidenceRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackagePilotRoot { internal ResolvedPackagePilotRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageCatalogRoot { internal ResolvedPackageCatalogRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedImmutablePackagePath { internal ResolvedImmutablePackagePath(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageLifecycleRoots(
ResolvedPackagePilotRoot Pilot,
ResolvedPackageCatalogRoot Catalog,
ResolvedPackageStagingRoot Staging,
ResolvedPackageVersionStoreRoot VersionStore,
ResolvedActivePackageRoot Active,
ResolvedPackageLifecycleEvidenceRoot Evidence);
public interface IPathResolver
{
ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null);
string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null);
ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables);
ResolvedLegacySettingsPath ResolveLegacySettingsPath(LauncherPathVariables variables);
ResolvedLegacyDownloadListPath ResolveLegacyDownloadListPath(LauncherPathVariables variables);
ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables);
ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables);
ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value);
ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value);
}
public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environment = null) : IPathResolver
{
private static readonly Regex Token = new(@"\$\{(?<name>[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled);
private readonly IReadOnlyDictionary<string, string?> environment = environment ?? Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null)
{
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
// Explicit null values represent optional paths (for example the Apply
// permit during Preview). They are retained as null rather than being
// treated as an unknown variable; required values are still enforced
// below.
foreach (var name in variables.Values.Keys)
resolved[name] = variables[name] is null ? null : ResolveVariable(name, variables, resolved, []);
foreach (var name in resolved.Keys.ToArray())
if (resolved[name] is { } value && value.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
{
var corporate = ResolveVariable(LauncherPathVariableNames.CorporateDfsRoot, variables, resolved, []) ?? throw new InvalidOperationException("PATH_VARIABLE_EMPTY:CorporateDfsRoot");
resolved[name] = Path.Combine(corporate, value[3..]);
}
foreach (var name in required ?? [])
if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
return new(resolved);
}
public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null)
{
var resolved = Expand(value, variables, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), []);
if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved))
throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED");
if (resolved.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
{
var corporate = Resolve(variables, [LauncherPathVariableNames.CorporateDfsRoot]).Require(LauncherPathVariableNames.CorporateDfsRoot);
resolved = Path.Combine(corporate, resolved[3..]);
}
var full = Path.GetFullPath(resolved);
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED");
if (!string.IsNullOrWhiteSpace(constrainedRoot))
{
var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables));
if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT");
}
return full;
}
public ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables)
{
var resolved = Resolve(variables, [LauncherPathVariableNames.CorporateCatalogRoot, LauncherPathVariableNames.CorporateDfsRoot]);
var catalog = ResolvePath(resolved.Require(LauncherPathVariableNames.CorporateCatalogRoot), variables);
var corporate = ResolvePath(resolved.Require(LauncherPathVariableNames.CorporateDfsRoot), variables);
if (!catalog.StartsWith(corporate.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("PATH_ROLE_COLLISION");
return new ResolvedCatalogRoot(catalog);
}
public ResolvedLegacySettingsPath ResolveLegacySettingsPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacySettingsPath", variables));
public ResolvedLegacyDownloadListPath ResolveLegacyDownloadListPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacyDownloadListPath", variables));
public ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacyCompatibilityOverlayPath", variables));
public ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables)
{
var value=ResolvePath(Resolve(variables,["LegacyProbeEvidenceRoot"]).Require("LegacyProbeEvidenceRoot"),variables);
if(value.StartsWith(@"\\",StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_PROBE_EVIDENCE_ROOT_NOT_LOCAL");
return new(value);
}
public ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value)
{ if (string.IsNullOrWhiteSpace(value)||Uri.IsWellFormedUriString(value,UriKind.Absolute)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); }
public ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value)
{
var pilot=ResolvePackagePilotRoot(value); string Child(string name)=>Path.GetFullPath(Path.Combine(pilot.Value,name));
var paths=new[]{Child("catalog"),Child("staging"),Child("version-store"),Child("active"),Child("evidence")};
if(paths.Distinct(StringComparer.OrdinalIgnoreCase).Count()!=paths.Length) throw new InvalidOperationException("PACKAGE_ROOT_COLLISION");
foreach(var path in paths) if(!path.StartsWith(pilot.Value.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_TOPOLOGY_INVALID");
return new(pilot,new(paths[0]),new(paths[1]),new(paths[2]),new(paths[3]),new(paths[4]));
}
private string ResolveExistingFile(string role, LauncherPathVariables variables)
{
var value=ResolvePath(Resolve(variables,[role]).Require(role),variables);
if(!File.Exists(value) || Directory.Exists(value)) throw new InvalidOperationException("LEGACY_PROBE_INPUT_NOT_FILE");
return value;
}
private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack)
{
if (resolved.TryGetValue(name, out var cached)) return cached;
if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}");
try
{
var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null);
if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}");
var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
expanded = expanded.Replace('/', Path.DirectorySeparatorChar);
resolved[name] = expanded;
return expanded;
}
finally { stack.Remove(name); }
}
private string Expand(string value, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack) =>
Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
}
public static class LauncherPathVariableNames
{
public const string CorporateDfsRoot = "CorporateDfsRoot";
public const string CorporateLauncherRoot = "CorporateLauncherRoot";
public const string CorporateCatalogRoot = "CorporateCatalogRoot";
public const string CorporatePackagesRoot = "CorporatePackagesRoot";
public const string CorporateProfilesRoot = "CorporateProfilesRoot";
public const string CorporatePalettesRoot = "CorporatePalettesRoot";
public const string CorporateFontsRoot = "CorporateFontsRoot";
public const string CorporateTemplatesRoot = "CorporateTemplatesRoot";
public const string CorporateScriptsRoot = "CorporateScriptsRoot";
public const string ProjectName = "ProjectName";
public const string DisciplineName = "DisciplineName";
public const string ProjectRoot = "ProjectRoot";
public const string DisciplineRoot = "DisciplineRoot";
public const string PlantProjectFile = "PlantProjectFile";
public const string ProjectRosterPath = "ProjectRosterPath";
public const string ProjectApplicationsRoot = "ProjectApplicationsRoot";
public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot";
public const string LocalLauncherRoot = "LocalLauncherRoot";
public const string LocalPackageCache = "LocalPackageCache";
public const string LocalActiveRoot = "LocalActiveRoot";
public const string LocalSessionRoot = "LocalSessionRoot";
public const string LocalEvidenceRoot = "LocalEvidenceRoot";
public const string LocalLogsRoot = "LocalLogsRoot";
public const string LocalRollbackRoot = "LocalRollbackRoot";
public const string AcadExecutable = "AcadExecutable";
public const string PRecEntryAssembly = "PRecEntryAssembly";
public const string FixtureOrthoDwg = "FixtureOrthoDwg";
public const string FixtureModelDwg = "FixtureModelDwg";
public const string AutomationScriptPath = "AutomationScriptPath";
public const string AutomationConfigPath = "AutomationConfigPath";
public const string AutomationEvidencePath = "AutomationEvidencePath";
public const string AutomationPermitPath = "AutomationPermitPath";
public const string AutomationTranscriptPath = "AutomationTranscriptPath";
}
public static class LauncherConfigurationLayers
{
private static readonly HashSet<string> Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot];
public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson)
{
try
{
var merged = Parse(corporateDefaultsJson);
Merge(merged, Parse(machineJson), false);
Merge(merged, Parse(sessionJson), false);
return PilotConfigurationParser.Parse(merged.ToJsonString());
}
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath)
{
var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null;
var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null;
var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null;
return Load(defaults, machine, session);
}
private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? [];
private static void Merge(JsonObject target, JsonObject overlay, bool roster)
{
foreach (var entry in overlay)
{
if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths)
{
var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths;
foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone();
}
else target[entry.Key] = entry.Value?.DeepClone();
}
}
}
+13
View File
@@ -25,3 +25,16 @@ public sealed class PackageManifestValidator : IPackageManifestValidator
return issues.Count == 0 ? ValidationResult.Success : new ValidationResult(issues); return issues.Count == 0 ? ValidationResult.Success : new ValidationResult(issues);
} }
} }
/// <summary>Structural validation for product-neutral catalog acquisition; it is
/// deliberately not a substitute for a product compatibility policy.</summary>
public sealed class StructuralPackageManifestValidator
{
public ValidationResult Validate(PackageManifest manifest)
{
if (string.IsNullOrWhiteSpace(manifest.Product))
return new ValidationResult([new("product", "Package product is required.")]);
return new PackageManifestValidator().Validate(
manifest with { Product = "Plant3D" }, manifest.ProductYear, manifest.Architecture);
}
}
+131
View File
@@ -0,0 +1,131 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Launcher.Next.Core;
public sealed record PilotLocalConfiguration
{
public int SchemaVersion { get; init; }
public LauncherMode LauncherMode { get; init; } = LauncherMode.DryRun;
public bool PilotEnabled { get; init; }
public bool AllowProcessStart { get; init; }
public bool RequireExplicitExecute { get; init; } = true;
public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke;
// Apply is never a normal pilot default. It is accepted only by this
// dedicated test branch and still requires the plugin-side fixture permit.
public bool AllowApply { get; init; }
public string? ApplyPermitPath { get; init; }
public string? AutomationMode { get; init; }
public bool AutomationDiagnostic { get; init; }
public string? AutomationConfigurationPath { get; init; }
public string? FixtureMarkerPath { get; init; }
// Controlled automation may reproduce an actual Editor.GetEntity boundary
// only with this immutable, explicitly typed fixture selection. It is never
// populated for normal production launches.
public ControlledEntitySelection? AutomationSelection { get; init; }
// Test-only: permits a pilot process to be terminated after its plugin has
// persisted completed automation evidence. It never represents graceful exit.
public bool ForceAfterEvidence { get; init; }
public int GracefulExitTimeoutSeconds { get; init; } = 30;
public string? PilotRoot { get; init; }
public string? SelectedProductId { get; init; }
public string? SelectedProfileName { get; init; }
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production;
public bool ProductionDeploy { get; init; }
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
public static PilotLocalConfiguration SafeDefault { get; } = new();
}
public sealed record ControlledEntitySelection(string CommandName, string ObjectHandle, string ExpectedDxfName)
{
public void Validate()
{
if (string.IsNullOrWhiteSpace(CommandName) || !CommandName.All(value => char.IsAsciiLetterOrDigit(value) || value is '-' or '_') ||
string.IsNullOrWhiteSpace(ObjectHandle) || ObjectHandle.Length > 32 || !ObjectHandle.All(Uri.IsHexDigit) ||
string.IsNullOrWhiteSpace(ExpectedDxfName) || !ExpectedDxfName.All(value => char.IsAsciiLetterOrDigit(value) || value == '_'))
throw new InvalidOperationException("AUTOMATION_ENTITY_SELECTION_INVALID");
}
}
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
{ public bool IsValid => Errors.Count == 0; }
public static class PilotConfigurationParser
{
public static PilotConfigurationLoadResult Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new(PilotLocalConfiguration.SafeDefault, []);
try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; options.Converters.Add(new JsonStringEnumConverter()); var config = JsonSerializer.Deserialize<PilotLocalConfiguration>(json, options);
if (config is null || config.SchemaVersion != 1) return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]);
return new(config, []); }
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
}
public sealed record PilotRootValidationResult(bool IsValid, string? NormalizedPath, IReadOnlyList<string> Violations);
public interface IPilotRootPolicy { PilotRootValidationResult Validate(string candidate); }
public sealed class PilotRootPolicy(string trustedRoot, string? testRoot = null) : IPilotRootPolicy
{
public PilotRootValidationResult Validate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || !Path.IsPathFullyQualified(candidate) || candidate.StartsWith("\\\\", StringComparison.Ordinal) || candidate.Contains(':', StringComparison.Ordinal) && candidate.IndexOf(':') != 1) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
var full = Path.GetFullPath(candidate); var permitted = new[] { trustedRoot, testRoot }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Path.GetFullPath(x!));
if (!permitted.Any(x => full.Equals(x, StringComparison.OrdinalIgnoreCase) || full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
return new(true, full, []);
}
}
public sealed record AutoCadProfileCandidate(string Name, bool IsCurrent, bool IsPlantSpecific, IReadOnlyList<string> Evidence);
public interface IAutoCadProfileDiscovery { IReadOnlyList<AutoCadProfileCandidate> DiscoverProfiles(ProductInstallation product); }
public static class ProfileSelector
{
public static AutoCadProfileCandidate? Select(IReadOnlyList<AutoCadProfileCandidate> candidates, string? explicitName, out string? violation)
{ var eligible = candidates.Where(x => x.IsPlantSpecific && x.Evidence.Count > 0).ToArray(); var selected = explicitName is null ? eligible : eligible.Where(x => string.Equals(x.Name, explicitName, StringComparison.Ordinal)).ToArray(); violation = selected.Length switch { 1 => null, 0 => "PROFILE_NOT_RESOLVED", _ => "PROFILE_AMBIGUOUS" }; return selected.Length == 1 ? selected[0] : null; }
}
public sealed record PilotSafetyContext(PilotLocalConfiguration Configuration, ProductInstallation? Product, AutoCadProfileCandidate? Profile, PilotRootValidationResult Root, ValidationResult Manifest, bool HashesValid, bool AutodeskDllPresent, bool AcadRunning);
public sealed record PilotSafetyDecision(bool IsAllowed, IReadOnlyList<string> Violations);
public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafetyContext context, bool forExecution); }
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
{
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
{
var v = new List<string>();
if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE");
if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED");
if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED");
if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED");
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED");
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
try { c.Configuration.AutomationSelection?.Validate(); } catch (InvalidOperationException) { v.Add("AUTOMATION_ENTITY_SELECTION_INVALID"); }
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026");
if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v);
}
}
public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList<string> Arguments, string WorkingDirectory, IReadOnlyDictionary<string,string> EnvironmentVariables);
public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision);
public sealed class AcadArgumentBuilder
{
public IReadOnlyList<string> Build(AcadLaunchMode mode, string? automationScriptPath)
{
var arguments = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" };
if (mode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe)
{
if (string.IsNullOrWhiteSpace(automationScriptPath) || !Path.IsPathFullyQualified(automationScriptPath)) throw new InvalidOperationException("AUTOMATION_SCRIPT_PATH_REQUIRED");
arguments.Add("/b"); arguments.Add(automationScriptPath);
}
else if (!string.IsNullOrWhiteSpace(automationScriptPath)) throw new InvalidOperationException("PRODUCTION_BATCH_SCRIPT_FORBIDDEN");
return arguments;
}
}
public static class PilotPlanFactory
{
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision, AcadLaunchMode mode = AcadLaunchMode.Production)
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, entry, scriptHash, mode); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
}
public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id);
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
public sealed class ExecutionPermitService : IExecutionPermitService
{ private readonly HashSet<Guid> used = []; public ExecutionPermit Issue(PilotLaunchPlan p, int seconds) => new(p.PlanId, p.ManifestSha256 + ":" + p.StartupScriptSha256, DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(seconds, 1, 600)), Guid.NewGuid()); public bool Validate(ExecutionPermit p, PilotLaunchPlan plan, DateTimeOffset now) => !used.Contains(p.Id) && p.ExpiresAtUtc >= now && p.PlanId == plan.PlanId && p.Fingerprint == plan.ManifestSha256 + ":" + plan.StartupScriptSha256 && used.Add(p.Id); }
@@ -0,0 +1,13 @@
namespace Launcher.Next.Core;
public sealed record PilotPreparationRequest(PilotLocalConfiguration Configuration, ProductInstallation Product, PackageManifest Manifest, string ManifestHash, string PackageSourceRoot, string PilotRoot, string StartupScriptPath);
public sealed record PilotPreparationResult(PilotLaunchPlan? Plan, PilotSafetyDecision SafetyDecision);
public sealed record PilotExecutionRequest(PilotPreparationRequest Preparation, PilotLaunchPlan Plan, ExecutionPermit Permit);
public sealed record PilotExecutionResult(bool Started, int? ProcessId, string? BlockCode, string? VersionDirectory, string? StartupScriptPath, string? SessionId = null, string? ActivationSnapshotPath = null);
public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent, string? SessionId = null, string? ActivationSnapshotPath = null);
public sealed record PilotRollbackResult(bool Completed, string? ErrorCode);
public interface IPilotExecutionService
{
Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken);
Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken);
Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace Launcher.Next.Core;
public enum StartupRosterCandidateStatus
{
CandidateOnly,
LegacyCandidatePresent,
ConflictingCandidatePresent,
UnexpectedAutoloadRegistration,
UnresolvedStartupEntry
}
public sealed record StartupRosterEntry(string Source, string? Registration, string? PackageId,
string? Version, string? EntryAssembly, string? EntryPath, string? EntrySha256,
bool Eligible, string LoadTiming);
public sealed record StartupRosterReport(StartupRosterCandidateStatus Status,
string RequestedPackageId, string RequestedVersion, string? RequestedEntrySha256,
IReadOnlyList<StartupRosterEntry> Entries, DateTimeOffset CreatedUtc)
{
public bool IsSafe => Status == StartupRosterCandidateStatus.CandidateOnly;
}
/// Read-only, bounded discovery of P_Rec_Annot startup candidates in controlled local roots.
public static class StartupRosterPreflight
{
public static StartupRosterReport Inspect(string requestedPackageRoot, PackageManifest requested,
IEnumerable<string>? additionalRoots = null)
{
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(requestedPackageRoot) };
foreach (var root in additionalRoots ?? DiscoverKnownRoots())
if (!string.IsNullOrWhiteSpace(root) && Directory.Exists(root)) roots.Add(Path.GetFullPath(root));
var entries = new List<StartupRosterEntry>();
var requestedPath = Path.Combine(Path.GetFullPath(requestedPackageRoot), requested.EntryAssembly);
var requestedHash = requested.Files.FirstOrDefault(f => f.Path.Equals(requested.EntryAssembly, StringComparison.OrdinalIgnoreCase))?.Sha256;
if (File.Exists(requestedPath))
entries.Add(new("RequestedPackage", requested.Startup.Type + ":" + requested.Startup.Command, requested.PackageId, requested.Version,
requested.EntryAssembly, requestedPath, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(requestedPath))), true, "before-netload"));
else entries.Add(new("RequestedPackage", requested.Startup.Type + ":" + requested.Startup.Command, requested.PackageId, requested.Version,
requested.EntryAssembly, null, requestedHash, false, "before-netload"));
foreach (var root in roots) DiscoverRoot(root, requested, entries);
var eligible = entries.Where(e => e.Eligible).ToArray();
StartupRosterCandidateStatus status;
if (entries.Any(e => e.EntryPath is null)) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
else if (eligible.Length == 0) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
else if (eligible.Length == 1 && string.Equals(eligible[0].EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase)
&& string.Equals(eligible[0].PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(eligible[0].Version, requested.Version, StringComparison.OrdinalIgnoreCase)) status = StartupRosterCandidateStatus.CandidateOnly;
else if (eligible.Any(e => string.Equals(e.PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(e.Version, requested.Version, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.ConflictingCandidatePresent;
else if (eligible.Any(e => !string.Equals(e.EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.LegacyCandidatePresent;
else status = StartupRosterCandidateStatus.UnexpectedAutoloadRegistration;
return new(status, requested.PackageId, requested.Version, requestedHash, entries, DateTimeOffset.UtcNow);
}
public static void Write(string path, StartupRosterReport report) =>
File.WriteAllText(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
private static IEnumerable<string> DiscoverKnownRoots()
{
var controlledTemp = string.Concat("C:", Path.DirectorySeparatorChar, "dev", Path.DirectorySeparatorChar, "TEMP");
foreach (var temp in new[] { Path.GetTempPath(), controlledTemp }.Distinct(StringComparer.OrdinalIgnoreCase))
if (Directory.Exists(temp))
foreach (var root in Directory.EnumerateDirectories(temp, "local-parity-launch-*", SearchOption.TopDirectoryOnly))
yield return Path.Combine(root, "Applications");
foreach (var root in new[] { Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + "\\Autodesk\\ApplicationPlugins",
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + "\\Autodesk\\ApplicationPlugins" }) yield return root;
}
private static void DiscoverRoot(string root, PackageManifest requested, List<StartupRosterEntry> entries)
{
if (!Directory.Exists(root)) return;
foreach (var dir in Directory.EnumerateDirectories(root, "P_Rec_Annot", SearchOption.TopDirectoryOnly))
{
var current = Path.Combine(dir, "current.json");
if (File.Exists(current))
{
using var doc = JsonDocument.Parse(File.ReadAllText(current));
var version = doc.RootElement.TryGetProperty("Current", out var c) ? c.GetString() : null;
var packageRoot = version is null ? null : Path.Combine(dir, "versions", version);
AddManifest(packageRoot, "LauncherCurrent", "netload", requested, entries);
}
else AddManifest(dir, "ApplicationPlugins", "autoload", requested, entries);
}
foreach (var bundle in Directory.EnumerateDirectories(root, "P_Rec_Annot*.bundle", SearchOption.TopDirectoryOnly)) AddManifest(bundle, "PackageContents", "autoload", requested, entries);
}
private static void AddManifest(string? packageRoot, string source, string registration, PackageManifest requested, List<StartupRosterEntry> entries)
{
if (packageRoot is null) { entries.Add(new(source, registration, "P_Rec_Annot", null, null, null, null, false, "before-netload")); return; }
var manifestPath = Path.Combine(packageRoot, "package.manifest.json");
if (!File.Exists(manifestPath)) { entries.Add(new(source, registration, "P_Rec_Annot", Path.GetFileName(packageRoot), null, null, null, false, "before-netload")); return; }
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
var root = doc.RootElement; string? S(string n) => root.TryGetProperty(n, out var p) ? p.GetString() : null;
var id = S("PackageId") ?? "P_Rec_Annot"; var version = S("Version"); var asm = S("EntryAssembly") ?? requested.EntryAssembly;
var path = Path.Combine(packageRoot, asm); string? hash = File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : null;
var eligible = File.Exists(path);
entries.Add(new(source, registration, id, version, asm, eligible ? path : null, hash, eligible, "before-netload"));
}
}
@@ -0,0 +1,39 @@
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
/// <summary>
/// Reads a catalog descriptor only. The returned source is later copied into
/// local immutable staging by <see cref="IPackageSynchronizer"/>; it is never
/// an active launch root.
/// </summary>
public sealed class FileSystemCatalogPackageAcquirer(ResolvedCatalogRoot catalogRoot) : IPackageCatalogPackageAcquirer
{
private const int MaxFiles = 256;
private const long MaxBytes = 512L * 1024 * 1024;
public async Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken)
{
var root = catalogRoot.Value;
var candidate = Path.GetFullPath(Path.Combine(root, requirement.PackageId, requirement.RequiredVersion));
if (!Directory.Exists(root) || IsReparsePoint(root) || !IsWithin(root, candidate) || !Directory.Exists(candidate) || IsReparsePoint(candidate))
return null;
var manifestPath = Path.Combine(candidate, "package.manifest.json");
if (!File.Exists(manifestPath) || IsReparsePoint(manifestPath))
return null;
var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, cancellationToken));
if (manifest.Files.Count > MaxFiles || manifest.Files.Sum(file => file.Length) > MaxBytes ||
manifest.Files.Any(file => string.IsNullOrWhiteSpace(file.Path) || Path.IsPathRooted(file.Path) ||
file.Path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(segment => segment is ".." or ".")))
return null;
return new CatalogPackage(manifest, candidate);
}
private static bool IsWithin(string root, string candidate) =>
candidate.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
private static bool IsReparsePoint(string path) =>
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
@@ -0,0 +1,14 @@
using System.Diagnostics;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed record ProcessInvocationResult(bool Started, int? ProcessId, string? BlockCode);
public interface IOperatingSystemProcessInvoker { ProcessInvocationResult Start(ProcessLaunchSpecification specification); }
public interface IAutoCadProcessProbe { bool IsAcadRunning(); }
public sealed class OperatingSystemProcessInvoker : IOperatingSystemProcessInvoker
{ public ProcessInvocationResult Start(ProcessLaunchSpecification s) { var info = new ProcessStartInfo { FileName=s.ExecutablePath, WorkingDirectory=s.WorkingDirectory, UseShellExecute=false }; foreach(var a in s.Arguments) info.ArgumentList.Add(a); foreach(var e in s.EnvironmentVariables) info.Environment[e.Key]=e.Value; var p=Process.Start(info); return new(p is not null,p?.Id,null); } }
public sealed class GuardedAutodeskProcessLauncher(INextPilotSafetyGate gate, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker)
{
public static readonly bool RealExecutionReleaseEnabled = true;
public ProcessInvocationResult Start(PilotSafetyContext context, PilotLaunchPlan plan, ExecutionPermit? permit, IExecutionPermitService permits)
{ var checkedContext = context with { AcadRunning = probe.IsAcadRunning() }; var decision = gate.Evaluate(checkedContext, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First()); if (permit is null || !permits.Validate(permit, plan, DateTimeOffset.UtcNow)) return new(false,null,"EXECUTION_PERMIT_MISSING"); if (!RealExecutionReleaseEnabled) return new(false,null,"REAL_EXECUTION_RELEASE_NOT_ENABLED"); return invoker.Start(plan.Process); }
}
+121 -10
View File
@@ -5,27 +5,138 @@ namespace Launcher.Next.Infrastructure;
public sealed class Sha256FileHasher : IFileHasher public sealed class Sha256FileHasher : IFileHasher
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } } { public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageLifecycleFaultInjector? faults = null) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, IPackageMaterializer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder
{ {
private sealed record Pointer(string Current, string? Previous); // Machine-process local serialization only; package roots must remain local.
private static readonly SemaphoreSlim PointerGate = new(1, 1);
private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 2, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null, IReadOnlyList<PackagePayloadBinding>? PayloadFiles = null);
private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity);
private const string StoredManifestFileName = "package.manifest.json";
private const string StoredBindingFileName = "package.binding.json";
private string PackageRoot(string id) => Path.Combine(root, "Applications", id); private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json"); private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync<Pointer>(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null; private async Task<Pointer?> ReadPointer(string id, CancellationToken ct)
{
if (!File.Exists(PointerPath(id))) return null;
await using var stream = File.OpenRead(PointerPath(id));
var pointer = await JsonSerializer.DeserializeAsync<Pointer>(stream, cancellationToken: ct);
return pointer is null || pointer.SchemaVersion is < 1 or > 2 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer;
}
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current; public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous; public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct) public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
{ {
var dir = Path.Combine(PackageRoot(id), "versions", version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir); await PointerGate.WaitAsync(ct); try
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); {
var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true); var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); var operationId=Guid.NewGuid().ToString("N"); var content=await ContentFingerprintAsync(id,version,ct);
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerTempWrite); var temp = PointerPath(id) + "." + operationId + ".tmp"; await using (var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)) { await JsonSerializer.SerializeAsync(stream,new Pointer(version, prior?.Current,2,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet,content.Files),cancellationToken:ct); await stream.FlushAsync(ct); }
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReplace); File.Move(temp, PointerPath(id), true); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReadback); var verified=await ReadPointer(id,ct); if (verified?.Current!=version || verified.ActivationOperationId!=operationId) throw new InvalidOperationException("POINTER_REVALIDATION_FAILED");
return new(version, prior?.Current); return new(version, prior?.Current);
} finally { PointerGate.Release(); }
}
public async Task<PackageActivationResult> ActivateImmutableAsync(ImmutableLocalPackage package, PackageRequirement requirement, CancellationToken ct)
{
if (!string.Equals(package.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(package.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !await MatchesImmutableManifestAsync(package,requirement,ct)) return new("Blocked", null);
var prior = await ReadPointer(package.PackageId, ct);
if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(prior.ActivationOperationId ?? Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, prior.ActivatedAtUtc ?? DateTimeOffset.UtcNow, prior.Generation, PackageRoot(package.PackageId),prior.SchemaVersion,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity,prior.PayloadFiles,Math.Max(0,prior.Generation-1),prior.ActivationOperationId));
var activated = await ActivateAsync(package.PackageId, package.Version, ct);
var after=await ReadPointer(package.PackageId,ct); return new("Activated", new(after!.ActivationOperationId!, package.PackageId, activated.CurrentVersion, activated.PreviousVersion, after.ActivatedAtUtc ?? DateTimeOffset.UtcNow, after.Generation, PackageRoot(package.PackageId),after.SchemaVersion,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity,after.PayloadFiles,Math.Max(0,after.Generation-1),after.ActivationOperationId));
}
public async Task<PackageRollbackResult> RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct)
{
var current=await GetCurrentVersionAsync(receipt.PackageId,ct);
var pointer=await ReadPointer(receipt.PackageId,ct); if (receipt.SchemaVersion!=2 || pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration || !string.Equals(pointer.ManifestSha256,receipt.ManifestSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(pointer.PayloadSetSha256,receipt.PayloadSetSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(receipt.ActiveRootIdentity,PackageRoot(receipt.PackageId),StringComparison.OrdinalIgnoreCase)) return new("RollbackBlockedStateChanged",current);
if (string.IsNullOrWhiteSpace(receipt.PreviousVersion)) return new("RollbackNotApplicable",current);
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReplace); var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReadback);
return new("RolledBack",restored.CurrentVersion);
} }
public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; } public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; }
public async Task BindAsync(PackageRequirement requirement, string version, CancellationToken ct)
{
var root = VersionDirectory(requirement.PackageId, version);
if (!Directory.Exists(root) || !string.Equals(requirement.RequiredVersion, version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_REQUIREMENT_BINDING_INVALID");
await File.WriteAllTextAsync(Path.Combine(root, StoredBindingFileName), JsonSerializer.Serialize(new CacheBinding(requirement.PackageId, requirement.Product, requirement.ProductYear, requirement.Architecture, requirement.Channel, requirement.RequiredVersion, requirement.RosterIdentity)), ct);
}
public async Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken ct)
{
var pointer = await ReadPointer(requirement.PackageId, ct);
if (pointer is null) return null;
var current = await VerifyVersionAsync(requirement, pointer.Current, ct);
if(current is not null && !await PointerMatchesContentAsync(pointer,requirement.PackageId,pointer.Current,ct)) current=null;
if (current is not null) return new(requirement.PackageId, pointer.Current, current, false, "CURRENT_VERIFIED");
if (!requirement.AllowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null;
var previous = await VerifyVersionAsync(requirement, pointer.Previous, ct);
return previous is null ? null : new(requirement.PackageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED");
}
public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null) public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
{ {
var staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); var materialized = await MaterializeAsync(manifest, sourceRoot, ct, progress);
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); var source = Path.Combine(sourceRoot, f.Path); var dest = Path.GetFullPath(Path.Combine(staging, f.Path)); if (!dest.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); } return await ActivateAsync(materialized.PackageId, materialized.Version, ct);
var versionDir = Path.Combine(PackageRoot(manifest.PackageId), "versions", manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return await ActivateAsync(manifest.PackageId, manifest.Version, ct); }
catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
} }
/// <summary>Copies and commits an immutable local version; never mutates activation pointers.</summary>
public async Task<ImmutableLocalPackage> MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
{
if (!new PackageManifestValidator().Validate(manifest, manifest.ProductYear, manifest.Architecture).IsValid) throw new InvalidOperationException("MANIFEST_INVALID");
if (manifest.Files.Any(x => string.Equals(x.Path, StoredManifestFileName, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("MANIFEST_RESERVED_FILE");
var operationId = Guid.NewGuid().ToString("N"); var staging = Path.Combine(root, "Launcher", "staging", operationId); Directory.CreateDirectory(staging); await File.WriteAllTextAsync(Path.Combine(staging, ".package-staging-owner.json"), JsonSerializer.Serialize(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId)), ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterStagingAllocated);
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringPayloadCopy); var source = PackageFilePath(sourceRoot, f.Path); var sourceInfo=new FileInfo(source); if(!sourceInfo.Exists||sourceInfo.Length!=f.Length||!string.Equals(await hasher.Sha256Async(source,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SOURCE_HASH_MISMATCH"); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation);
await File.WriteAllTextAsync(Path.Combine(staging, StoredManifestFileName), JsonSerializer.Serialize(manifest), ct);
File.Delete(Path.Combine(staging, ".package-staging-owner.json")); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeImmutableCommit); if (Directory.Exists(versionDir)) { if(!await ExistingMatchesAsync(versionDir,manifest,ct)) throw new InvalidOperationException("IMMUTABLE_PACKAGE_CONFLICT"); Directory.Delete(staging,true); } else Directory.Move(staging, versionDir); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn); return new(manifest.PackageId, manifest.Version, versionDir); }
catch { await CleanupOwnedStagingAsync(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId), CancellationToken.None); throw; }
}
public async Task<PackageStagingCleanupResult> CleanupOwnedStagingAsync(PackageStagingOwnership ownership, CancellationToken ct)
{
var stagingRoot=Path.GetFullPath(Path.Combine(root,"Launcher","staging")); var directory=Path.GetFullPath(ownership.StagingDirectory); var prefix=stagingRoot.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar;
if (!directory.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || directory.Equals(stagingRoot,StringComparison.OrdinalIgnoreCase)) return new("OwnershipUnknown",ownership.OperationId);
if (!Directory.Exists(directory)) return new("AlreadyAbsent",ownership.OperationId);
var marker=Path.Combine(directory,".package-staging-owner.json"); if (!File.Exists(marker)) return new("OwnershipUnknown",ownership.OperationId);
var stored=JsonSerializer.Deserialize<PackageStagingOwnership>(await File.ReadAllTextAsync(marker,ct)); if (stored is null || stored.OperationId!=ownership.OperationId || stored.PackageId!=ownership.PackageId || stored.Version!=ownership.Version) return new("OwnershipMismatch",ownership.OperationId);
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringOwnedStagingCleanup); Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId);
}
private async Task<string?> VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct)
{
string versionRoot;
try { versionRoot = VersionDirectory(requirement.PackageId, version); }
catch { return null; }
var manifestPath = Path.Combine(versionRoot, StoredManifestFileName);
var bindingPath = Path.Combine(versionRoot, StoredBindingFileName);
if (!Directory.Exists(versionRoot) || new DirectoryInfo(versionRoot).Attributes.HasFlag(FileAttributes.ReparsePoint) || !File.Exists(manifestPath) || !File.Exists(bindingPath) || new FileInfo(manifestPath).Attributes.HasFlag(FileAttributes.ReparsePoint) || new FileInfo(bindingPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) return null;
PackageManifest manifest;
CacheBinding binding;
try { manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); binding = JsonSerializer.Deserialize<CacheBinding>(await File.ReadAllTextAsync(bindingPath, ct))!; }
catch { return null; }
if (binding is null || !Matches(requirement, binding) || !string.Equals(manifest.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(manifest.Version, version, StringComparison.Ordinal) || !string.Equals(manifest.Product, requirement.Product, StringComparison.Ordinal) || manifest.ProductYear != requirement.ProductYear || !string.Equals(manifest.Architecture, requirement.Architecture, StringComparison.OrdinalIgnoreCase) || !string.Equals(manifest.RolloutChannel, requirement.Channel, StringComparison.OrdinalIgnoreCase) || !string.Equals(manifest.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !new PackageManifestValidator().Validate(manifest, requirement.ProductYear, requirement.Architecture).IsValid) return null;
foreach (var file in manifest.Files)
{
var path = PackageFilePath(versionRoot, file.Path);
var info = new FileInfo(path);
if (!info.Exists || info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length != file.Length || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return null;
}
return versionRoot;
}
private async Task<(string Manifest,string PayloadSet,IReadOnlyList<PackagePayloadBinding> Files)> ContentFingerprintAsync(string id,string version,CancellationToken ct)
{ var dir=VersionDirectory(id,version); var manifestPath=Path.Combine(dir,StoredManifestFileName); var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath,ct)); var manifestHash=await hasher.Sha256Async(manifestPath,ct); var files=manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>new PackagePayloadBinding(x.Path.Replace('\\','/'),x.Length,x.Sha256.ToUpperInvariant())).ToArray(); var payload=string.Join("|",files.Select(x=>$"{x.RelativePath}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload))),files); }
private async Task<bool> PointerMatchesContentAsync(Pointer pointer,string id,string version,CancellationToken ct)
{ if(pointer.SchemaVersion==1) return true; try { var content=await ContentFingerprintAsync(id,version,ct); return string.Equals(pointer.ManifestSha256,content.Manifest,StringComparison.OrdinalIgnoreCase)&&string.Equals(pointer.PayloadSetSha256,content.PayloadSet,StringComparison.OrdinalIgnoreCase)&&pointer.PayloadFiles is not null&&pointer.PayloadFiles.SequenceEqual(content.Files); } catch{return false;} }
private async Task<bool> ExistingMatchesAsync(string directory,PackageManifest expected,CancellationToken ct)
{ try { var stored=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(directory,StoredManifestFileName),ct)); if(!ManifestEquivalent(stored,expected)) return false; foreach(var file in expected.Files){var path=PackageFilePath(directory,file.Path);var info=new FileInfo(path);if(!info.Exists||info.Length!=file.Length||!string.Equals(await hasher.Sha256Async(path,ct),file.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}catch{return false;} }
private static bool ManifestEquivalent(PackageManifest left,PackageManifest right) =>
left.SchemaVersion==right.SchemaVersion && left.PackageId==right.PackageId && left.Version==right.Version && left.Product==right.Product && left.ProductYear==right.ProductYear && string.Equals(left.Architecture,right.Architecture,StringComparison.OrdinalIgnoreCase) && left.EntryAssembly==right.EntryAssembly && left.Startup==right.Startup && string.Equals(left.RolloutChannel,right.RolloutChannel,StringComparison.OrdinalIgnoreCase) && left.Files.Count==right.Files.Count && left.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).SequenceEqual(right.Files.OrderBy(x=>x.Path,StringComparer.Ordinal));
private async Task<bool> MatchesImmutableManifestAsync(ImmutableLocalPackage package,PackageRequirement requirement,CancellationToken ct)
{ try { if (!package.LocalPackageRoot.Equals(VersionDirectory(package.PackageId,package.Version),StringComparison.OrdinalIgnoreCase)) return false; var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(package.LocalPackageRoot,StoredManifestFileName),ct)); if (!string.Equals(manifest.PackageId,requirement.PackageId,StringComparison.Ordinal)||!string.Equals(manifest.Version,requirement.RequiredVersion,StringComparison.Ordinal)||!string.Equals(manifest.Product,requirement.Product,StringComparison.Ordinal)||manifest.ProductYear!=requirement.ProductYear||!string.Equals(manifest.Architecture,requirement.Architecture,StringComparison.OrdinalIgnoreCase)||!string.Equals(manifest.RolloutChannel,requirement.Channel,StringComparison.OrdinalIgnoreCase)) return false; foreach(var f in manifest.Files){var p=PackageFilePath(package.LocalPackageRoot,f.Path); var i=new FileInfo(p); if(!i.Exists||i.Length!=f.Length||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) return false;} return true; } catch { return false; } }
private static string PackageFilePath(string packageRoot, string relativePath)
{
var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var path = Path.GetFullPath(Path.Combine(root, relativePath));
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE");
return path;
}
private string VersionDirectory(string packageId, string version)
{
if (string.IsNullOrWhiteSpace(version) || !string.Equals(Path.GetFileName(version), version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_VERSION_INVALID");
return PackageFilePath(Path.Combine(PackageRoot(packageId), "versions"), version);
}
private static bool Matches(PackageRequirement requirement, CacheBinding binding) => string.Equals(requirement.PackageId, binding.PackageId, StringComparison.Ordinal) && string.Equals(requirement.Product, binding.Product, StringComparison.Ordinal) && requirement.ProductYear == binding.ProductYear && string.Equals(requirement.Architecture, binding.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(requirement.Channel, binding.Channel, StringComparison.OrdinalIgnoreCase) && string.Equals(requirement.RequiredVersion, binding.RequiredVersion, StringComparison.Ordinal) && string.Equals(requirement.RosterIdentity, binding.RosterIdentity, StringComparison.Ordinal);
} }
@@ -0,0 +1,194 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class PilotExecutionService(
IPackageManifestValidator validator,
IPilotRootPolicy rootPolicy,
INextPilotSafetyGate gate,
IFileHasher hasher,
IStartupScriptBuilder scriptBuilder,
IExecutionPermitService permits,
IAutoCadProcessProbe probe,
IOperatingSystemProcessInvoker invoker,
IPathResolver? pathResolver = null) : IPilotExecutionService
{
private sealed record Pointer(string Version);
private sealed record ActivationSnapshot(
string TransactionId, string PackageId, string PackageVersion,
string? CurrentContent, string? CurrentSha256,
string? PreviousContent, string? PreviousSha256,
string VersionDirectory, string StartupScriptPath, string StagingPath);
private sealed record PersistedSession(
int SchemaVersion, string SessionId, string TransactionId, string State,
string PackageId, string PackageVersion, string ManifestSha256,
string PilotRoot, string VersionDirectory, string StartupScriptPath,
string LaunchMode, IReadOnlyDictionary<string, string?> ResolvedPaths, IReadOnlyList<string> AcadArguments, string? ActualStartupScriptSha256,
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
string? LastErrorCode, string? LastErrorMessage);
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var resolver = pathResolver ?? new PathResolver();
var paths = resolver.Resolve(new LauncherPathVariables(request.Configuration.PathVariables), [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]);
if (!string.Equals(Path.GetFullPath(request.PilotRoot), paths.LocalLauncherRoot, StringComparison.OrdinalIgnoreCase))
return new(null, new(false, ["PILOT_ROOT_PATH_VARIABLE_MISMATCH"]));
var root = rootPolicy.Validate(paths.LocalLauncherRoot);
var validation = validator.Validate(request.Manifest, 2026, "x64");
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
var product = request.Product with { ExecutablePath = paths.AcadExecutable };
var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]);
var safety = gate.Evaluate(new(request.Configuration, product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
if (!safety.IsAllowed) return new(null, safety);
var automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT";
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
var plan = PilotPlanFactory.Create(product, profile, request.Manifest, request.ManifestHash, paths.LocalLauncherRoot, automation ? paths.Require(LauncherPathVariableNames.AutomationScriptPath) : string.Empty, scriptHash, safety, request.Configuration.LaunchMode);
var environment = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
if (automation)
{
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode!;
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = paths.Require(LauncherPathVariableNames.AutomationConfigPath);
}
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
}
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
{
var plan = request.Plan;
var resolver = pathResolver ?? new PathResolver();
var resolvedPaths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables));
var root = rootPolicy.Validate(request.Preparation.PilotRoot);
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
var context = new PilotSafetyContext(request.Preparation.Configuration, request.Preparation.Product,
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
var decision = gate.Evaluate(context, true);
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(plan.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, plan.PilotRoot, plan.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision, request.Preparation.Configuration.LaunchMode).PlanId, StringComparison.Ordinal))
return new(false, null, "PILOT_PLAN_CHANGED", null, null);
var sessionId = Guid.NewGuid().ToString("N");
var transactionId = Guid.NewGuid().ToString("N");
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
var versionDirectory = plan.VersionDirectory;
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
var scriptPath = plan.StartupScriptPath;
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
var currentPath = Path.Combine(packageRoot, "current.json");
var previousPath = Path.Combine(packageRoot, "previous.json");
var current = await ReadTextOrNull(currentPath, cancellationToken);
var previous = await ReadTextOrNull(previousPath, cancellationToken);
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
await WriteSession(plan.PilotRoot, new PersistedSession(2, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, request.Preparation.Configuration.LaunchMode.ToString(), resolvedPaths.Values, plan.Process.Arguments, null, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
try
{
Directory.CreateDirectory(staging);
foreach (var file in request.Preparation.Manifest.Files)
{
cancellationToken.ThrowIfCancellationRequested();
var source = Path.Combine(request.Preparation.PackageSourceRoot, file.Path);
var target = Path.GetFullPath(Path.Combine(staging, file.Path));
if (!target.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE");
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(source, target, true);
if (!string.Equals(await hasher.Sha256Async(target, cancellationToken), file.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_VALIDATION_FAILED");
}
Directory.CreateDirectory(Path.GetDirectoryName(versionDirectory)!);
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory);
await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null);
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
var automation = request.Preparation.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
if (automation)
{
var paths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables), [LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.FixtureModelDwg, LauncherPathVariableNames.LocalSessionRoot]);
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT");
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply, request.Preparation.Configuration.AutomationSelection));
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
}
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
var actualScriptHash = automation ? Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(scriptPath, cancellationToken))) : null;
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", StartupScriptPath = automation ? scriptPath : string.Empty, AcadArguments = arguments, ActualStartupScriptSha256 = actualScriptHash, UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = automation ? scriptPath : string.Empty, Process = plan.Process with { Arguments = arguments, WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
if (!result.Started)
{
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
return new(false, null, result.BlockCode, versionDirectory, scriptPath, sessionId, snapshotPath);
}
var session = (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "WaitingForRuntimeCheck", ProcessId = result.ProcessId, ProcessStartUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow };
await WriteSession(plan.PilotRoot, session, cancellationToken);
return new(true, result.ProcessId, null, versionDirectory, scriptPath, sessionId, snapshotPath);
}
catch (Exception ex)
{
await MarkFailed(plan.PilotRoot, ex.Message, cancellationToken);
throw;
}
}
private static string ReadFixtureOrtho(string? automationConfigurationPath)
{
if (string.IsNullOrWhiteSpace(automationConfigurationPath) || !File.Exists(automationConfigurationPath)) throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING");
using var json = JsonDocument.Parse(File.ReadAllText(automationConfigurationPath));
if (!json.RootElement.TryGetProperty("orthoDrawingPath", out var value) || string.IsNullOrWhiteSpace(value.GetString())) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
return Path.GetFullPath(value.GetString()!);
}
public async Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken)
{
try
{
var session = await ReadSession(request.PilotRoot, cancellationToken);
var snapshotPath = request.ActivationSnapshotPath ?? session?.ActivationSnapshotPath;
if (string.IsNullOrWhiteSpace(snapshotPath) || !File.Exists(snapshotPath)) return new(false, "ACTIVATION_SNAPSHOT_MISSING");
var snapshot = await JsonSerializer.DeserializeAsync<ActivationSnapshot>(File.OpenRead(snapshotPath), cancellationToken: cancellationToken);
if (snapshot is null) return new(false, "ACTIVATION_SNAPSHOT_INVALID");
if (session?.ProcessId is int pid && probe.IsAcadRunning() && IsProcessRunning(pid)) return new(false, "PROCESS_STILL_RUNNING");
var packageRoot = Path.Combine(request.PilotRoot, "packages", snapshot.PackageId);
await Restore(Path.Combine(packageRoot, "current.json"), snapshot.CurrentContent, snapshot.CurrentSha256, cancellationToken);
await Restore(Path.Combine(packageRoot, "previous.json"), snapshot.PreviousContent, snapshot.PreviousSha256, cancellationToken);
if (File.Exists(snapshot.StartupScriptPath)) File.Delete(snapshot.StartupScriptPath);
if (Directory.Exists(snapshot.StagingPath)) Directory.Delete(snapshot.StagingPath, true);
if (Directory.Exists(snapshot.VersionDirectory)) Directory.Delete(snapshot.VersionDirectory, true);
if (session is not null) await WriteSession(request.PilotRoot, session with { State = "RolledBack", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = null, LastErrorMessage = null }, cancellationToken);
return new(true, null);
}
catch { return new(false, "ROLLBACK_FAILED"); }
}
private async Task<bool> ValidateSource(PackageManifest manifest, string root, CancellationToken ct)
{
foreach (var file in manifest.Files)
{
var path = Path.Combine(root, file.Path);
if (!File.Exists(path) || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return false;
}
return true;
}
private static bool IsProcessRunning(int pid) { try { return !System.Diagnostics.Process.GetProcessById(pid).HasExited; } catch { return false; } }
private static string? Hash(string? text) => text is null ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
private static async Task<string?> ReadTextOrNull(string path, CancellationToken ct) => File.Exists(path) ? await File.ReadAllTextAsync(path, ct) : null;
private static async Task WriteTextAtomic(string path, string content, CancellationToken ct, bool exists)
{ if (!exists) { if (File.Exists(path)) File.Delete(path); return; } Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp = path + ".tmp"; await File.WriteAllTextAsync(temp, content, ct); File.Move(temp, path, true); }
private static async Task WriteJsonAtomic<T>(string path, T value, CancellationToken ct) => await WriteTextAtomic(path, JsonSerializer.Serialize(value), ct, true);
private static async Task<PersistedSession?> ReadSession(string root, CancellationToken ct)
{ var path = Path.Combine(root, "state", "pilot-session.json"); if (!File.Exists(path)) return null; await using var stream = File.OpenRead(path); return await JsonSerializer.DeserializeAsync<PersistedSession>(stream, cancellationToken: ct); }
private static Task WriteSession(string root, PersistedSession session, CancellationToken ct) => WriteJsonAtomic(Path.Combine(root, "state", "pilot-session.json"), session, ct);
private static async Task MarkFailed(string root, string code, CancellationToken ct)
{ var s = await ReadSession(root, ct); if (s is not null) await WriteSession(root, s with { State = "Failed", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = code, LastErrorMessage = code }, ct); }
private static async Task Restore(string path, string? content, string? sha, CancellationToken ct)
{ if (content is null) { if (File.Exists(path)) File.Delete(path); return; } if (!string.Equals(Hash(content), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SNAPSHOT_HASH_INVALID"); await WriteTextAtomic(path, content, ct, true); if (!string.Equals(Hash(await File.ReadAllTextAsync(path, ct)), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("POINTER_RESTORE_FAILED"); }
}
+16
View File
@@ -0,0 +1,16 @@
# AutoCAD startup
Plant 3D 2026 is started with the confirmed contract:
```text
acad.exe /product PLNT3D /language ru-RU /b <absolute-local-script>
```
The generated script has one command:
```text
_.NETLOAD
"<absolute local versioned PRecAnnot.Plant3D2026.dll>"
```
No `/p`, legacy LISP, `OrthoLoader`, UNC path, trusted-path mutation, or secondary dependency NETLOAD is used.
+51
View File
@@ -0,0 +1,51 @@
# Package activation
Files are copied to an operation-owned staging directory, rehashed and moved to a deterministic immutable version location. Materialization never changes activation pointers. Explicit activation writes schema-v2 `current.json` through an operation-owned temporary file, flush, replace and readback. The pointer binds manifest SHA-256, deterministic payload-set SHA-256 and every payload relative path/length/SHA-256.
Activation and receipt-bound rollback are serialized by a process-local `SemaphoreSlim`. Cross-process coordination is not verified and network-share pointer mutation is not supported. The controlled lifecycle pilot uses only an explicit local root and does not imply corporate DFS, AutoCAD runtime or production acceptance.
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
## Last-known-good resolution
Each activated version retains its validated package manifest alongside its payload.
`PackageResolutionUseCase` receives an explicit roster requirement: package ID,
product/year/architecture, channel, exact version, roster identity and offline
policy. On an online acquisition it stages locally, binds that requirement to the
immutable version, then revalidates through `LocalPackageStore.ResolveAsync`.
The resolver checks manifest, binding, identity/product compatibility, entry and
all payload lengths/SHA-256 before returning a local version. It returns current
first. Only when current is invalid and the requirement explicitly permits offline
fallback can it return previous. A cache entry with another roster, channel or
version is rejected. It never reads or executes a package from DFS, and a failed
or corrupted update never becomes last-known-good. Resolution follows only the explicit current/previous pointers and never enumerates the version store to find an approximate candidate.
Resolution emits stable evidence codes: `ONLINE_PACKAGE_SELECTED`,
`VALID_CURRENT_CACHE_SELECTED`, `PREVIOUS_LKG_SELECTED`,
`OFFLINE_FALLBACK_DISABLED`, and `NO_SAFE_PACKAGE_AVAILABLE`.
## Roster-to-package plan
`RosterAdapter` keeps source formats outside the package domain. It selects,
in order, a project `roster.json`, a legacy `download_list.xml`, then an
explicit central default; malformed project JSON is a block, not an implicit
fallback. JSON roster version 1 requires a stable roster ID, product, year,
architecture, explicit offline policy and unique package IDs. Legacy XML disables
DTD/external entities and accepts only proven `Plugin` fields
(`id`, `version`, optional `channel` and `install`).
`RosterPackagePlanUseCase` converts enabled records into deterministic,
load-order-sorted requirements and resolves every one before launch preparation.
It emits roster/source, requirement and plan-completed/blocked evidence. The
filesystem catalog adapter returns a descriptor for acquisition only; local
immutable staging remains the only active package root. These adapters and the
controlled local catalog scenarios are verified by tests; a corporate DFS pilot
and Autodesk launch with a real roster are not runtime verified.
Catalog acquisition is bounded before staging: the configured catalog root and
package root must not be reparse points, the descriptor must remain below the
configured root, manifests may contain at most 256 files and 512 MiB declared
payload, and absolute or traversal payload paths are rejected. A corporate pilot
requires explicit configured roster and catalog roles; absent roles block before
any corporate payload is read.
+25
View File
@@ -0,0 +1,25 @@
# Launcher path resolution
`PathResolver` is the only resolver for launcher path variables. Configuration
is layered in this order: `launcher.session.json`, `launcher.machine.json`,
then `launcher.defaults.json`; built-in defaults are non-path safety defaults.
Corporate and local locations are variables, not source-code paths. The
supported variable names are: `CorporateDfsRoot`, `CorporateLauncherRoot`,
`CorporateCatalogRoot`, `CorporatePackagesRoot`, `CorporateProfilesRoot`,
`CorporatePalettesRoot`, `CorporateFontsRoot`, `CorporateTemplatesRoot`,
`CorporateScriptsRoot`, `ProjectName`, `DisciplineName`, `ProjectRoot`,
`DisciplineRoot`, `PlantProjectFile`, `ProjectRosterPath`,
`ProjectApplicationsRoot`, `ProjectPaletteOverlayRoot`, `LocalLauncherRoot`,
`LocalPackageCache`, `LocalActiveRoot`, `LocalSessionRoot`,
`LocalEvidenceRoot`, `LocalLogsRoot`, `LocalRollbackRoot`, `AcadExecutable`,
`PRecEntryAssembly`, `FixtureOrthoDwg`, `AutomationScriptPath`,
`AutomationConfigPath`, `AutomationEvidencePath`, and `AutomationPermitPath`.
Undefined corporate locations remain `null` / `NOT VERIFIED`; the resolver does
not invent a DFS hierarchy. The legacy `A:` drive is accepted only as input and
is canonicalized through configured `CorporateDfsRoot`; it is never emitted.
`Production` launch arguments are `/product PLNT3D /language ru-RU` only.
`NextPilotPreview`, `NextPilotApply`, and `LifecycleProbe` require a resolved
script beneath the local session root and add exactly one `/b` argument.
+7
View File
@@ -0,0 +1,7 @@
# Pilot state machine
`Prepared → Activating → ProcessStarting → WaitingForRuntimeCheck → RolledBack`
Failures are persisted as `Failed`. A new run is blocked by an active session; a completed `RolledBack` session permits a new run. `pilot rollback` restores state from its persisted activation snapshot and is repeatable.
The current session schema persists the terminal state. It does not yet persist a transition-history array; evidence must not infer intermediate transitions after the fact.
+5
View File
@@ -0,0 +1,5 @@
# Rollback mechanics
Rollback is performed only after the pilot-owned AutoCAD PID has exited. It restores pointer contents from the persisted snapshot, removes the generated SCR, cleans staging, deletes the activated pilot version, and writes session state `RolledBack`.
If an application process exits unexpectedly, `pilot rollback` can be run in a new Launcher Next process because it reads the persisted session and activation snapshot.
+7
View File
@@ -0,0 +1,7 @@
# Launcher Next runtime mechanics
The controlled pilot flow is: CLI `pilot run` → local `pilot.local.json` → manifest generation and SHA-256 validation → `PrepareAsync` → one-time in-memory permit → `ExecuteAsync` → staging → version activation → startup SCR → guarded `Process.Start` → wait for the specific AutoCAD PID → `RollbackAsync`.
The pilot uses only `${LocalLauncherRoot}` resolved from local configuration. It does not modify the legacy launcher, AutoCAD profiles, registry, trusted paths, or the legacy Applications directory.
The runtime command is `acad.exe /product PLNT3D /language ru-RU /b <startup-script>`; no profile argument is supplied. The SCR invokes exactly one `NETLOAD`, for the local versioned entry assembly only.
@@ -0,0 +1 @@
{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"offlineResolutionImplemented":true,"offlineResolutionVerifiedByTests":true,"productionCatalogOutageRuntimeVerified":false,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false}
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"pilotTooling": "VERIFIED_BY_TESTS",
"realCorporateRosterParsing": "BLOCKED",
"realCorporatePackageAcquisition": "BLOCKED",
"blockReason": "CORPORATE_CATALOG_AND_ROSTER_PATHS_NOT_CONFIGURED",
"localImmutableStaging": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
"localPackageLifecycle": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
"controlledOutageRecovery": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
"crossProcessActivationCoordination": "NOT_RUNTIME_VERIFIED",
"productionActivation": "NOT_RUNTIME_VERIFIED",
"autodeskRuntime": "NOT_RUNTIME_VERIFIED",
"productionReadiness": "NOT_RUNTIME_VERIFIED",
"redaction": "No corporate paths, roster contents, package payload, session identity, or machine data were recorded."
}
@@ -0,0 +1,31 @@
{
"date": "2026-08-03",
"launcherMode": "NextPilot",
"pluginExecutionMode": "DryRun",
"startupNetloadCount": 1,
"profileArgumentPresent": false,
"legacyBootstrapUsed": false,
"processStarted": true,
"assemblyLoadedFromVersionedPackage": true,
"orthoDetection": "Confirmed",
"viewportCount": 1,
"objectsExamined": 0,
"phpRecAnnotRuns": 2,
"pxpAavRuns": 2,
"pxpOaRuns": 1,
"commandErrors": 0,
"dbmodBefore": 0,
"dbmodAfter": 0,
"plansDeterministic": null,
"sourcePlantObjectsRead": false,
"dataLinksSheetToSourceMapping": "blocked",
"rollbackCompleted": true,
"startupScriptRemoved": true,
"activatedVersionRemoved": true,
"fixtureDwgUnchanged": null,
"fixtureDcfUnchanged": false,
"fixtureChangedFiles": ["Piping.dcf"],
"ribbonVerified": null,
"unhandledExceptions": 0,
"productionDeploymentPerformed": false
}
+29
View File
@@ -0,0 +1,29 @@
{
"launcherMode": "NextPilot",
"product": "Autodesk AutoCAD Plant 3D 2026",
"packageId": "P_Rec_Annot",
"packageVersion": "1.0.0.0",
"manifestValidated": true,
"hashesValidated": true,
"startupNetloadCount": 1,
"processStarted": true,
"processId": 9960,
"launcherRunnerExitCode": null,
"assemblyLoadedFromVersionedPackage": true,
"commands": { "PHP_REC_ANNOT": true, "PXP-AAV": true, "PXP-OA": true },
"drawingType": "NotOrtho",
"dbmodBefore": 0,
"dbmodAfter": null,
"ribbon": null,
"unhandledExceptions": 0,
"pluginDefects": ["PLANT_PROJECT_DISPLAY_NAME_EMPTY"],
"automaticRollback": true,
"sessionState": "RolledBack",
"startupScriptRemoved": true,
"activatedVersionRemoved": true,
"currentPointerRestored": true,
"previousPointerRestored": true,
"legacyFilesModified": null,
"productionDeployment": false,
"notes": ["The background process exit code was not captured by the launcher host.", "Legacy local sentinel files were absent; hashes were not available.", "Session records terminal RolledBack state; historic intermediate states were not persisted."]
}
+6 -1
View File
@@ -1,6 +1,11 @@
# Foundation scope # Foundation scope
Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled. The next authorized slice is registry discovery validation and a non-production package catalog pilot. Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled.
Package selection now has a product-neutral orchestration contract: an explicit
roster requirement is resolved through catalog acquisition/local staging, or—only
with explicit policy—through a fully revalidated current/previous local cache.
This is verified by local integration tests, not by a production catalog outage.
`tests/Launcher.Next.Tests/fixtures/p-rec-annot-pilot.manifest.json` is a synthetic P_Rec_Annot contract. Its all-zero hash is deliberately replaced by a hash of a test-created one-byte placeholder; it does not identify or package a real Autodesk plugin. `tests/Launcher.Next.Tests/fixtures/p-rec-annot-pilot.manifest.json` is a synthetic P_Rec_Annot contract. Its all-zero hash is deliberately replaced by a hash of a test-created one-byte placeholder; it does not identify or package a real Autodesk plugin.
+30
View File
@@ -0,0 +1,30 @@
# Legacy launcher capability matrix
Source audit: `All_Serv_Install/Form1.cs`, `F1_Xml.cs`, `F2_Xml.cs`, `All_Serv_Load/Form1.cs`, `All_Serv_InstallUpdater/Form1.cs`, `Control_Sum_XML.cs`, `P_Serv_Starter/Program.cs`, `launcher_starter.lsp`, `Settings.xml`, and `download_list_plant.xml`.
| Legacy capability | User-visible behavior | Modern equivalent | Status |
| --- | --- | --- | --- |
| Settings startup validation | Shows configurations only after required XML fields validate | Strict `LegacyConfigurationAdapter.ParseSettings` and UI file selection | SOURCE_VERIFIED / IMPLEMENTED |
| Pattern selection | Lists pattern name, filter and roster, then enables project/plugin workflow | Launcher Next lists patterns and selected filter/roster | SOURCE_VERIFIED / IMPLEMENTED |
| Ordered download list | Resolves ordered plugin IDs, sources and renewal aliases | Adapter preserves ordered IDs; overlay join rejects missing/ambiguous IDs | SOURCE_VERIFIED / VERIFIED_BY_TESTS |
| Plugin copy/install | Recursively copies arbitrary catalog directories and optional install payloads | Verified manifest acquisition to immutable local package only | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
| Update checks | Reconciles server tree using mutable MD5 `control_sums.xml` | No trust is assigned to legacy checksum XML; SHA-256 manifest validation is required | SOURCE_VERIFIED / IGNORED_NON_OPERATIONAL_WITH_REASON |
| Local active state | Deletes/replaces local catalogs before launch | Materialize, validate, immutable commit and explicit activation | IMPLEMENTED / CONTROLLED_RUNTIME_VERIFIED |
| Starter LSP | Stages loader, mutates SupportPath and loads DLL/DVB/LISP | Not reproduced: direct legacy payload execution and persistent SupportPath mutation are unsafe | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
| Plant discovery | Legacy BAT starts Plant 3D fire-and-forget | Product-specific ACAD-9117 registry discovery and UI status | SOURCE_VERIFIED / IMPLEMENTED |
| Interactive launch | Starts BAT without PID/evidence or wait | Requires verified local active package, explicit safe launch plan, PID check and evidence | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
| Error/status UX | WinForms message boxes and partial continuation | Explicit UI ready/block status and diagnostics | SOURCE_VERIFIED / IMPLEMENTED |
## Blocking external inputs
The checked-in legacy settings reference `N:`/`Q:` production sources and legacy binaries. No owner-approved 2026 compatibility overlay, local manifest-backed catalog, or exact package roster is present in this worktree. Therefore enabling Prepare or Launch would either guess mappings or execute unverified legacy payloads; both violate the Launcher invariants.
## Source-verified product selection contract
| Product | Legacy source behavior | Launcher Next implementation | Verification / remaining input |
| --- | --- | --- | --- |
| Plant 3D | `Form1.button_select_project_Click` opens the selected pattern's file filter; automatic list comes from `%PROJECTDIRECTORY%\\..\\_System\\<pattern>\\Applications\\download_list.xml`; list mode explicitly opens `download_list.xml`. | Product-specific `OpenFileDialog`, exact relative roster resolver and manual XML selection feed the existing controlled Plant package/`/b` bootstrap flow. | `PLANT3D_CONTROLLED_WORKFLOW_VERIFIED`; launch/bootstrap path unchanged. |
| Plant P&ID | Filter is `Project files |Project.xml|All files (*.*)|*.*`; legacy starter was shared with Plant 3D. | Requires exactly `Project.xml`; resolves `Plant PnID_2019` automatic roster or explicit manual `download_list.xml`; preserves list order. | `PNID_UI_DISCOVERY_PROJECT_AND_PLAN_VERIFIED`; mappings blocked for `c1b999d6c16029e9ac5b1e1a8afe1500`, `5b80379c3ec715e7da0ee414c53aa7bc`, `1111`. No 2026 package is invented. |
| Navisworks Manage | Filter is `nwd files (*.nwd)|*.nwd|All files (*.*)|*.*`; automatic roster is project-relative by the same formula. The reviewed source does not establish a Navisworks plugin bootstrap. | Requires `.nwd`; resolves `Navisworks_Manage_2018` automatic roster or explicit manual `download_list.xml`; does not select Plant `/b`, `NETLOAD`, or PXP-OA bootstrap. | `NAVISWORKS_UI_DISCOVERY_PROJECT_AND_PLAN_VERIFIED`; mapping blocked for `7dd981169fa14d21aaef3fcdb0e7e970`. |
The automatic resolver performs no directory scan or fallback: it reads only the canonicalized source-verified location. Manual selection keeps the selected project file unchanged, enforces the `download_list.xml` basename, rejects reparse files and relies on the strict XML parser for malformed and duplicate IDs.
@@ -0,0 +1,9 @@
# Owner project roster compatibility
Read-only analysis of `\\phimproject.com\a\ATest_Total\_System\Plant 3D_2019\Applications\download_list.xml` recorded SHA-256 `493CA3B94ECF937CA09D2A37120A6D86DC81597B5C10284A1DF37E3D0352979D` (3,589 bytes; UTF-8 BOM).
Its real shape is `root → area → id`: one `area`, 33 ordered IDs, no empty IDs and no duplicate IDs. `b455ee6eaed79d76f084846a60ce3284` is entry 27 and joins to `P_Rec_Annot` / legacy `OrthoLoader.dll`; the explicit 2026 mapping resolves it to `P_Rec_Annot` `0.1.0` / `PRecAnnot.Plant3D2026.dll`.
Legacy `All_Serv_Load/F2_Xml.cs` used `[XmlElement("id")]` on `F2_Root`, which reads only direct `root/id` entries and removes only exact empty strings. It neither trims nor rejects duplicate IDs; nested `area/id` entries are ignored. Launcher Next now preserves that direct-ID behavior for diagnostics while deterministically expanding the real `area/id` source order for the modern plan. Duplicate source IDs remain visible, while package execution uses a case-insensitive effective unique list.
The project roster is acquired from UNC into an owned local snapshot before parsing. XML parse/shape errors, missing Settings definitions, ambiguous definitions and absent 2026 mappings are distinct stages. Known-but-unmapped plugins remain visible and block launch without being misreported as invalid XML or process failure.
+9
View File
@@ -0,0 +1,9 @@
# Owner-review network configuration
The only approved legacy Settings endpoint is `\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml`. Launcher Next reads it read-only, validates it as the exact UNC file, snapshots it under the local controlled root, verifies the copied SHA-256, and parses only the local snapshot.
`N:\BIM\...` is supported only as a versioned alias for `\\phimproject.com\common\BIM\...`; other drive aliases are rejected. No payload is executed from the network source.
For the controlled Plant owner-review distribution, the real `Plant 3D_2019` definition supplies the project-relative roster template. The controlled roster contains the real Settings plugin ID `b455ee6eaed79d76f084846a60ce3284`, mapped explicitly to `P_Rec_Annot` `0.1.0` / `PRecAnnot.Plant3D2026.dll`. This is a migration mapping: its legacy `OrthoLoader.dll` name is not executed.
When the network endpoint is unavailable, Launcher can use only an existing validated local snapshot and identifies that condition as offline fallback in runtime evidence; it never silently substitutes a synthetic Settings file.
@@ -0,0 +1,3 @@
# Controlled NextPilot execution
`NextPilot` is opt-in. Missing or invalid local configuration defaults to DryRun, with process start disabled and plugin mode Smoke. Prepare builds a deterministic plan only; Execute requires all safety conditions and a one-time, short-lived permit. `RealExecutionReleaseEnabled` is compile-time false in this foundation, so the process adapter always returns `REAL_EXECUTION_RELEASE_NOT_ENABLED` before calling the OS invoker.
+3
View File
@@ -0,0 +1,3 @@
# Manual runtime checkpoint
Before any real execution: review the LaunchPlan, explicitly confirm product/profile/root/package, enable real execution only in a separately reviewed commit, rebuild/test, create a dedicated runtime branch, then Prepare, issue permit, Execute, manually validate AutoCAD UI, and rollback.
+27
View File
@@ -0,0 +1,27 @@
# P_Rec_Annot Ortho DryRun
Date: 2026-08-03
Launcher Next executed the isolated `NextPilot` flow in `DryRun` mode. The
generated AutoCAD startup script contained one `NETLOAD` for the versioned
local entry assembly; no legacy bootstrap was used and no `/p` argument was
passed.
The actual Ortho drawing was `TestPipeOrtho.dwg`. `PHP_REC_ANNOT` ran twice,
`PXP-AAV` ran twice, and `PXP-OA` ran once. Each completed with Ortho detected,
one viewport, zero examined objects, zero planned operations, and no command
error. `DBMOD` was `0` both before and after the commands.
The Ortho sheet's `SelectAcPpRowIds` call raised `DLException: NotFound` in the
preceding attempt. The read adapter now treats that condition as an empty,
read-only result so it cannot fail a DryRun command. It does not yet map an
Ortho sheet back to source-model DataLinks rows; therefore successful source
Pipe discovery and a non-empty AnnotationPlan remain unverified.
After Plant 3D closed, Launcher Next reported `RolledBack`; the startup script,
version directory, current/previous pointers, and staging contents were absent.
The fixture SHA comparison found one changed project file: `Piping.dcf`.
Its last-write time predates the first DryRun command, so the observation cannot
be attributed to a P_Rec_Annot write. Nevertheless, project/DCF invariance is
not established by this run.
+11
View File
@@ -0,0 +1,11 @@
# P_Rec_Annot real Smoke pilot
Date: 2026-07-31. Launcher Next started Plant 3D 2026 as PID 9960 from an isolated local pilot package. The entry assembly was loaded from the versioned package directory through one generated NETLOAD.
The manual runtime checkpoint confirmed `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA` executed in Smoke mode; drawing type was `NotOrtho`; initial DBMOD was `0`; no unhandled exceptions were observed. The final DBMOD and Ribbon observations were not supplied, so they are intentionally not asserted.
After normal Plant shutdown, automatic rollback completed. No `acad.exe` remained. Startup scripts, staging contents, package pointers and activated version were absent; the activation snapshot was retained. The checked local legacy Application files were absent, so no before/after hashes were available.
The background runner exit code was not captured by its `Start-Process` host. The persisted terminal state and the post-run `pilot status` command both reported `RolledBack`.
Known plugin defect: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`. Plant context was found but displayed an empty project name. Impact was diagnostic/UI-only for this Smoke pilot; write protection and command execution were unaffected. Follow-up: inspect `PlantApplication.CurrentProject` metadata and distinguish absent display name from false-positive project detection.
+11
View File
@@ -0,0 +1,11 @@
# Pilot local configuration
Configuration files are `${LocalLauncherRoot}\config\launcher.defaults.json`,
`launcher.machine.json`, and `launcher.session.json`. They are never
auto-created or tracked. Unknown/corrupt configuration blocks pilot mode;
session values take precedence over machine values, which take precedence over
corporate defaults.
```json
{"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300}
```
+3
View File
@@ -0,0 +1,3 @@
# Prepare, Execute and rollback
Prepare performs no package/store/script/process writes. Activation and rollback are covered by the existing versioned local store using unique staging and atomic pointer replacement. Execute re-evaluates safety immediately before the invoker, and remains release-blocked in this slice.
+3
View File
@@ -0,0 +1,3 @@
# Read-only profile discovery
The adapter returns candidates with evidence. An explicit exact selection is accepted; otherwise exactly one confirmed Plant-specific candidate is required. Zero candidates blocks with `PROFILE_NOT_RESOLVED`; multiple candidates block with `PROFILE_AMBIGUOUS`. No profile is created or modified.
@@ -0,0 +1,9 @@
{
"schemaVersion": 1,
"mode": "LegacyConfigurationProbe",
"settingsXml": "${LEGACY_PROBE_ROOT}\\Settings.xml",
"downloadListXml": "${LEGACY_PROBE_ROOT}\\download_list.xml",
"compatibilityOverlay": "${LEGACY_PROBE_ROOT}\\legacy-package-overlay.json",
"selectedPattern": "Exact Pattern Name",
"evidenceOutputRoot": "${LEGACY_PROBE_EVIDENCE_ROOT}"
}
+54
View File
@@ -0,0 +1,54 @@
param(
[Parameter(Mandatory = $true)][string]$OutputRoot,
[string]$SourceFixtureRoot = 'C:\dev\TEMP\local-parity-launch-20260806'
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$releaseRoot = Join-Path $repoRoot 'Launcher.Next.App\bin\x64\Release\net8.0-windows'
$output = [IO.Path]::GetFullPath($OutputRoot)
$fixture = [IO.Path]::GetFullPath($SourceFixtureRoot)
if (-not (Test-Path -LiteralPath (Join-Path $releaseRoot 'Launcher.Next.App.exe'))) { throw 'OWNER_REVIEW_RELEASE_EXE_MISSING' }
if (-not (Test-Path -LiteralPath (Join-Path $fixture 'catalog\P_Rec_Annot\0.1.0\package.manifest.json'))) { throw 'OWNER_REVIEW_CATALOG_INPUT_MISSING' }
if (Test-Path -LiteralPath $output) { throw 'OWNER_REVIEW_OUTPUT_ALREADY_EXISTS' }
$app = Join-Path $output 'app'
$controlled = Join-Path $output 'controlled'
New-Item -ItemType Directory -Path $app, $controlled, (Join-Path $controlled 'configuration'), (Join-Path $controlled 'project'), (Join-Path $controlled 'evidence') | Out-Null
Copy-Item -Path (Join-Path $releaseRoot '*') -Destination $app -Recurse
Copy-Item -LiteralPath (Join-Path $fixture 'catalog') -Destination $controlled -Recurse
Copy-Item -LiteralPath (Join-Path $fixture 'project\Project.xml') -Destination (Join-Path $controlled 'project\Project.xml')
$rosterDirectory = Join-Path $controlled '_System\Plant 3D_2019\Applications'
New-Item -ItemType Directory -Path $rosterDirectory | Out-Null
'<root><id>b455ee6eaed79d76f084846a60ce3284</id></root>' | Set-Content -LiteralPath (Join-Path $rosterDirectory 'download_list.xml') -Encoding utf8
'{"schemaVersion":1,"mappings":[{"mappingId":"owner-network-p-rec-annot","pattern":"Plant 3D_2019","legacyPluginId":"b455ee6eaed79d76f084846a60ce3284","legacyCatalog":"P_Rec_Annot","legacyInstall":"OrthoLoader.dll","package":{"id":"P_Rec_Annot","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}}]}' | Set-Content -LiteralPath (Join-Path $controlled 'configuration\compatibility-overlay.json') -Encoding utf8
$profile = [ordered]@{
schemaVersion = 1
name = 'Owner Review Controlled Local PXP-OA'
root = $controlled
settingsPath = (Join-Path $controlled 'configuration\settings-pending.xml')
downloadListPath = (Join-Path $rosterDirectory 'download_list.xml')
overlayPath = (Join-Path $controlled 'configuration\compatibility-overlay.json')
catalogRoot = (Join-Path $controlled 'catalog')
patternName = 'Plant 3D_2019'
evidenceRoot = (Join-Path $controlled 'evidence')
networkSettingsEndpoint = '\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml'
}
$profilePath = Join-Path $controlled 'configuration\owner-review-profile.json'
$profile | ConvertTo-Json | Set-Content -LiteralPath $profilePath -Encoding utf8
@{ launcherCommit = (git -C $repoRoot rev-parse HEAD).Trim(); distribution = 'OwnerReviewControlledLocal'; createdUtc = [DateTimeOffset]::UtcNow } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $app 'owner-review-release.json') -Encoding utf8
@"
@echo off
"%~dp0Launcher.Next.App.exe" --mode LocalParityLaunch --config "%~dp0..\controlled\configuration\owner-review-profile.json"
"@ | Set-Content -LiteralPath (Join-Path $app 'Launch Owner Review.cmd') -Encoding ascii
[pscustomobject]@{
DistributionRoot = $output
Executable = (Join-Path $app 'Launcher.Next.App.exe')
Launcher = (Join-Path $app 'Launch Owner Review.cmd')
Profile = $profilePath
Project = (Join-Path $controlled 'project\Project.xml')
AutomaticRoster = (Join-Path $rosterDirectory 'download_list.xml')
} | Format-List
@@ -0,0 +1,30 @@
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class ControlledExecutionTests
{
[Fact] public void ApplyMode_RequiresExplicitAllowApply()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.Contains("APPLY_NOT_EXPLICITLY_ALLOWED", d.Violations); }
[Fact] public void ApplyMode_WithExplicitAllowApply_IsAllowedByLauncherGate()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, ApplyPermitPath = "C:\\test\\permit.json", RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.True(d.IsAllowed); }
[Fact] public void ApplyMode_RequiresLocalPermitPath()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.Contains("APPLY_PERMIT_PATH_REQUIRED", d.Violations); }
[Fact] public void MissingPilotConfiguration_DefaultsToDryRun() => Assert.Equal(LauncherMode.DryRun, PilotConfigurationParser.Parse(null).Configuration.LauncherMode);
[Fact] public void InvalidPilotConfiguration_BlocksPilot() => Assert.False(PilotConfigurationParser.Parse("{").IsValid);
[Fact] public void PilotRoot_Unc_IsRejected() => Assert.False(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("\\\\server\\share").IsValid);
[Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").IsValid);
[Fact] public void SafetyGate_ProcessStartDisabled_BlocksExecute()
{ var c=new PilotLocalConfiguration { SchemaVersion=1, LauncherMode=LauncherMode.NextPilot, PilotEnabled=true, PluginExecutionMode=PluginExecutionMode.Smoke }; var decision=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("PROCESS_START_DISABLED",decision.Violations); }
[Fact] public void ForceAfterEvidence_RequiresTestAutomation()
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED",d.Violations); }
[Fact] public void ForceAfterEvidence_WithLocalAutomationConfiguration_IsAllowed()
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true,AutomationMode="FirstLimitedApplyRuntimeTest",AutomationConfigurationPath="C:\\test\\automation.json",FixtureMarkerPath="C:\\test\\fixture.marker",LaunchMode=AcadLaunchMode.NextPilotPreview }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.True(d.IsAllowed); }
[Fact] public void Permit_IsOneTime()
{ var p=Plan(); var service=new ExecutionPermitService(); var permit=service.Issue(p,60); Assert.True(service.Validate(permit,p,DateTimeOffset.UtcNow)); Assert.False(service.Validate(permit,p,DateTimeOffset.UtcNow)); }
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
{ var p=Plan(); var config=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=false,PluginExecutionMode=PluginExecutionMode.Smoke }; var context=new PilotSafetyContext(config,p.Product,p.Profile,new(true,p.PilotRoot,[]),ValidationResult.Success,true,false,false); var permits=new ExecutionPermitService(); var result=new GuardedAutodeskProcessLauncher(new NextPilotSafetyGate(),new Probe(),new Invoker()).Start(context,p,permits.Issue(p,60),permits); Assert.Equal("PROCESS_START_DISABLED",result.BlockCode); }
private static PilotLaunchPlan Plan() { var product=new ProductInstallation("C:\\acad.exe","2026","x64",[]); var profile=new AutoCadProfileCandidate("Pilot",false,true,["registry"]); var manifest=new PackageManifest(1,"P","1","Plant3D",2026,"x64","P.dll",[new("P.dll",1,new string('A',64))],new("netload","P")); return PilotPlanFactory.Create(product,profile,manifest,"M","C:\\AutoCAD User Support\\LauncherNextPilot","C:\\script.scr","S",new(true,[])); }
private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning()=>false; }
private sealed class Invoker : IOperatingSystemProcessInvoker { public ProcessInvocationResult Start(ProcessLaunchSpecification _) => throw new Xunit.Sdk.XunitException("must not start"); }
}
+37 -1
View File
@@ -7,9 +7,12 @@ namespace Launcher.Next.Tests;
public sealed class FoundationTests public sealed class FoundationTests
{ {
private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT")); private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"), RolloutChannel: "pilot");
private static PackageRequirement Requirement(string version = "2026.1.0", bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline);
private static PackageFile Package(string path, string hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") => new(path, 1, hash); private static PackageFile Package(string path, string hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") => new(path, 1, hash);
[Fact] public void Manifest_ValidPackage_IsAccepted() => Assert.True(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid); [Fact] public void Manifest_ValidPackage_IsAccepted() => Assert.True(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_DirectValidator_RejectsUnknownProduct() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")) with { Product = "UnknownProduct" }, 2026, "x64").IsValid);
[Fact] public void StructuralManifestValidator_AllowsExplicitNonPlantProductStructure() => Assert.True(new StructuralPackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")) with { Product = "Revit" }).IsValid);
[Fact] public void Manifest_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid); [Fact] public void Manifest_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AbsolutePath_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("C:\\evil.dll")), 2026, "x64").IsValid); [Fact] public void Manifest_AbsolutePath_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("C:\\evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_MissingEntryAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("Other.dll")), 2026, "x64").IsValid); [Fact] public void Manifest_MissingEntryAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("Other.dll")), 2026, "x64").IsValid);
@@ -21,6 +24,31 @@ public sealed class FoundationTests
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); } { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
[Fact] public async Task Synchronizer_HashMismatch_IsRejected() [Fact] public async Task Synchronizer_HashMismatch_IsRejected()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); } { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
[Fact] public async Task LastKnownGood_RejectsCorruptCurrent_AndUsesVerifiedPreviousOffline()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
try
{
await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-one"); var firstHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, firstHash)), source, CancellationToken.None); await store.BindAsync(Requirement(), "2026.1.0", CancellationToken.None);
await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-two"); var secondHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); var second = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, secondHash)) with { Version = "2026.2.0" }; await store.SynchronizeAsync(second, source, CancellationToken.None); await store.BindAsync(Requirement("2026.2.0"), "2026.2.0", CancellationToken.None);
await System.IO.File.WriteAllTextAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll"), "corrupt");
var resolved = await store.ResolveAsync(Requirement("2026.1.0"), CancellationToken.None);
Assert.NotNull(resolved); Assert.Equal("2026.1.0", resolved!.Version); Assert.True(resolved.IsOfflineFallback); Assert.Equal("PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED", resolved.Reason);
}
finally { Directory.Delete(root, true); }
}
[Fact] public async Task LastKnownGood_DoesNotFallback_WhenOfflinePolicyIsDisabled()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher());
try { await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); await store.BindAsync(Requirement(), "2026.1.0", CancellationToken.None); await store.SynchronizeAsync((Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)) with { Version = "2026.2.0" }), source, CancellationToken.None); await store.BindAsync(Requirement("2026.2.0", false), "2026.2.0", CancellationToken.None); System.IO.File.Delete(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll")); Assert.Null(await store.ResolveAsync(Requirement("2026.1.0", false), CancellationToken.None)); }
finally { Directory.Delete(root, true); }
}
[Fact] public async Task Synchronizer_RejectsReservedStoredManifestPayload()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes));
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var manifest = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash), new PackageFile("package.manifest.json", 1, hash)); var error = await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(manifest, source, CancellationToken.None)); Assert.Equal("MANIFEST_RESERVED_FILE", error.Message); }
finally { Directory.Delete(root, true); }
}
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces() [Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); } { var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess() [Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
@@ -36,4 +64,12 @@ public sealed class FoundationTests
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); } try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); }
finally { Directory.Delete(root, true); } finally { Directory.Delete(root, true); }
} }
[Fact] public void PilotCli_UsesContentAddressedVersionForImmutableActivation()
{
var root = new DirectoryInfo(AppContext.BaseDirectory);
while (root is not null && !File.Exists(Path.Combine(root.FullName, "Launcher.Next.sln"))) root = root.Parent;
var source = File.ReadAllText(Path.Combine(root!.FullName, "Launcher.Next.App", "PilotCli.cs"));
Assert.Contains("contentIdentity", source);
Assert.Contains("assemblyVersion + \"-\" + contentIdentity", source);
}
} }
@@ -7,8 +7,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" /> <ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Application\Launcher.Next.Application.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" /> <ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" /> <ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
<ProjectReference Include="..\..\Launcher.Next.App\Launcher.Next.App.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup> <ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
</Project> </Project>
@@ -0,0 +1,39 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LegacyNetworkConfigurationTests
{
[Fact]
public void ApprovedSettingsEndpoint_IsExactUncFile()
{
Assert.Equal(@"\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml", LegacyNetworkConfiguration.ApprovedSettingsEndpoint);
Assert.StartsWith(@"\\", LegacyNetworkConfiguration.ApprovedSettingsEndpoint, StringComparison.Ordinal);
Assert.EndsWith("Settings.xml", LegacyNetworkConfiguration.ApprovedSettingsEndpoint, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(@"N:\BIM", @"\\phimproject.com\common\BIM")]
[InlineData(@"N:\BIM\save\Apps\Plant3D\DriveA", @"\\phimproject.com\common\BIM\save\Apps\Plant3D\DriveA")]
public void ApprovedNDriveAlias_ResolvesOnlyBimNamespace(string original, string expected)
=> Assert.Equal(expected, LegacyNetworkConfiguration.ResolveApprovedAlias(original));
[Theory]
[InlineData(@"N:\Other\payload.dll")]
[InlineData(@"Q:\BIM\save\Apps")]
[InlineData("relative\\Settings.xml")]
public void UnknownLegacyAliases_AreRejected(string path)
=> Assert.Equal("LEGACY_NETWORK_ALIAS_NOT_APPROVED", Assert.Throws<InvalidOperationException>(() => LegacyNetworkConfiguration.ResolveApprovedAlias(path)).Message);
[Fact]
public void UnapprovedEndpoint_IsRejectedBeforeAnyNetworkAccess()
{
var root = Path.Combine(Path.GetTempPath(), "launcher-network-" + Guid.NewGuid().ToString("N"));
try
{
Assert.Equal("LEGACY_SETTINGS_ENDPOINT_NOT_APPROVED", Assert.Throws<InvalidOperationException>(() => LegacyNetworkConfiguration.AcquireApprovedSnapshot(@"\\example.invalid\Settings.xml", root)).Message);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}
@@ -0,0 +1,49 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LegacyNetworkSettingsParserTests
{
[Fact]
public void FullLegacyPattern_PreservesSourcesAttributesAndMachineRestriction()
{
const string xml = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant 3D_2019</name><filter>Project files |Project.xml|All files (*.*)|*.*</filter><roster>%PROJECTDIRECTORY%\\..\\_System\\Plant 3D_2019\\Applications\\download_list.xml</roster><source><course>N:\\BIM\\save\\Apps\\Plant3D\\DriveA</course></source><source><course>N:\\BIM\\save\\Apps\\Plant3D\\DriveQ</course></source><target>target</target><loader>loader</loader><object>object</object><attrib>first</attrib><attrib>second</attrib></pattern><plugin><id>b455ee6eaed79d76f084846a60ce3284</id><renewal></renewal><catalog>P_Rec_Annot</catalog><install>OrthoLoader.dll</install><machine>BUILD-01</machine></plugin></data></root>";
var settings = new LegacyConfigurationAdapter().ParseSettings(xml);
var pattern = Assert.Single(settings.Patterns);
Assert.Equal(["N:\\BIM\\save\\Apps\\Plant3D\\DriveA", "N:\\BIM\\save\\Apps\\Plant3D\\DriveQ"], pattern.Sources);
Assert.Equal(["first", "second"], pattern.Attributes);
Assert.Equal("target", pattern.Target);
var plugin = Assert.Single(pattern.Plugins);
Assert.Equal("P_Rec_Annot", plugin.Catalog);
Assert.True(plugin.IsAllowedOn("build-01"));
Assert.False(plugin.IsAllowedOn("OTHER"));
}
[Fact]
public void PluginWithoutMachineRestriction_IsAllowedEverywhere()
{
var plugin = new LegacyPluginDefinition("id", "", "catalog", "install", 0);
Assert.True(plugin.IsAllowedOn(Environment.MachineName));
}
[Fact]
public void RealOwnerRosterShape_UsesAreaIdsInSourceOrderAndPreservesDuplicateEvidence()
{
const string roster = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><!-- comment --><area><agname>1</agname><id>first</id><id> second </id><id>first</id><id></id></area><ignored><id>not-selected</id></ignored></root>";
var selection = new LegacyConfigurationAdapter().ParseDownloadList(roster);
Assert.Equal(["first", " second ", "first"], selection.PluginIds);
Assert.Equal(["first", " second "], selection.EffectivePluginIds);
Assert.Equal("Area", selection.Entries![0].SourceShape);
Assert.Equal(0, selection.Entries[2].FirstOccurrenceIndex);
Assert.Equal("", selection.Entries[3].RawId);
}
[Fact]
public void DownloadListErrors_AreSpecific()
{
var adapter = new LegacyConfigurationAdapter();
Assert.Equal("DOWNLOAD_LIST_ROOT_INVALID", Assert.Throws<InvalidOperationException>(() => adapter.ParseDownloadList("<other/>")).Message);
Assert.Equal("DOWNLOAD_LIST_XML_MALFORMED", Assert.Throws<InvalidOperationException>(() => adapter.ParseDownloadList("<root>")).Message);
}
}
@@ -0,0 +1,100 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LegacyProductSelectionTests
{
[Fact]
public void SourceVerifiedProducts_ExposeExactOpenFileFilters()
{
Assert.Equal("Project files |Project.xml|All files (*.*)|*.*", LegacyProductSelections.Pnid.Filter);
Assert.Equal("nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", LegacyProductSelections.Navisworks.Filter);
Assert.Equal("Project.xml", LegacyProductSelections.Pnid.RequiredFileName);
Assert.Equal(".nwd", LegacyProductSelections.Navisworks.RequiredFileName);
}
[Fact]
public void AutomaticRoster_UsesOnlyExactLegacyRelativeLocation()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("Project.xml");
var actual = LegacyProductSelections.AutomaticRoster(LegacyProductSelections.Pnid, project);
Assert.Equal(Path.Combine(fixture.Root, "_System", "Plant PnID_2019", "Applications", "download_list.xml"), actual);
Assert.DoesNotContain("scan", actual, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SelectedProjectFile_IsProductSpecificAndRejectsWrongExtension()
{
using var fixture = new SelectionFixture();
var nwd = fixture.ProjectFile("model.nwd");
Assert.Equal(nwd, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, nwd));
Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Pnid, nwd));
}
[Fact]
public void AutomaticPnidRoster_PreservesOrderAndReportsExactUnmappedIds()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("Project.xml");
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>c1b999d6c16029e9ac5b1e1a8afe1500</id><id>5b80379c3ec715e7da0ee414c53aa7bc</id><id>1111</id></root>");
var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
Assert.Equal("Automatic", plan.Source);
Assert.Equal(["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], plan.OrderedIds);
Assert.Equal(plan.OrderedIds, plan.UnmappedIds);
}
[Fact]
public void ManualRoster_DoesNotChangeProjectAndUsesExplicitDownloadList()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("model.nwd");
var manual = Path.Combine(fixture.Root, "download_list.xml");
File.WriteAllText(manual, "<root><id>7dd981169fa14d21aaef3fcdb0e7e970</id></root>");
var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, manual, new LegacyConfigurationAdapter());
Assert.Equal("Manual", plan.Source);
Assert.Equal(manual, plan.RosterPath);
Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], plan.UnmappedIds);
Assert.Equal(project, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, project));
}
[Fact]
public void MissingAutomaticRoster_FailsClosedWithoutSearchingOtherDirectories()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("model.nwd");
File.WriteAllText(Path.Combine(fixture.Root, "download_list.xml"), "<root><id>7dd981169fa14d21aaef3fcdb0e7e970</id></root>");
var ex = Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, null, new LegacyConfigurationAdapter()));
Assert.Equal("LEGACY_DOWNLOAD_LIST_NOT_FOUND", ex.Message);
}
[Fact]
public void ProductIncompatibleIds_AreBlocked_AndDuplicateIdsRemainDiagnosticInput()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("Project.xml");
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>unexpected</id></root>");
Assert.Equal("LEGACY_PLUGIN_PRODUCT_MISMATCH", Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message);
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>1111</id><id>1111</id></root>");
var duplicatePlan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
Assert.Equal(["1111", "1111"], duplicatePlan.OrderedIds);
}
[Fact]
public void Navisworks_DoesNotAdvertisePlantBootstrap()
{
Assert.False(LegacyProductSelections.Navisworks.SupportsPlantBootstrap);
Assert.True(LegacyProductSelections.Pnid.SupportsPlantBootstrap);
Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], LegacyProductSelections.Navisworks.UnmappedLegacyIds);
}
private sealed class SelectionFixture : IDisposable
{
public SelectionFixture() { Root = Path.Combine(Path.GetTempPath(), "launcher-next-selection-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(Root, "Project")); }
public string Root { get; }
public string ProjectFile(string name) { var path = Path.Combine(Root, "Project", name); File.WriteAllText(path, "fixture"); return path; }
public void WriteAutomatic(string pattern, string xml) { var directory = Path.Combine(Root, "_System", pattern, "Applications"); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "download_list.xml"), xml); }
public void Dispose() { if (Directory.Exists(Root)) Directory.Delete(Root, true); }
}
}
@@ -0,0 +1,39 @@
using System.Text.Json;
using Launcher.Next.App;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LocalParityEvidenceTests
{
[Fact]
public async Task EvidenceWriter_AtomicallyPublishesValidatedSchema()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(root);
var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, state = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None);
Assert.True(File.Exists(path));
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path));
Assert.Equal(4, document.RootElement.GetProperty("schemaVersion").GetInt32());
Assert.False(File.Exists(path + ".tmp"));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
}
[Fact]
public async Task EvidenceWriter_ReplacesStaleTemporaryArtifact()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(Path.Combine(root, "controlled-local-parity-launch.json.tmp"), "stale");
var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, terminalResult = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None);
Assert.Contains("PACKAGE_LOAD_VERIFIED", await File.ReadAllTextAsync(path));
Assert.False(File.Exists(path + ".tmp"));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
}
}
@@ -0,0 +1,47 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class OwnerReviewStateTests
{
[Fact]
public void MissingAutomaticRoster_IsRosterFailure_NotProcessStartFailure()
{
var failure = LauncherOperationFailures.Classify("LEGACY_DOWNLOAD_LIST_NOT_FOUND", "RosterResolutionFailed");
Assert.Equal("RosterResolutionFailed", failure.Stage);
Assert.Equal("AUTOMATIC_DOWNLOAD_LIST_NOT_FOUND", failure.Code);
Assert.False(failure.ProcessStartAttempted);
Assert.DoesNotContain("запустить Autodesk", failure.UserMessage, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("DOWNLOAD_LIST_XML_MALFORMED", "PluginPlanFailed", "DOWNLOAD_LIST_XML_MALFORMED")]
[InlineData("LEGACY_PLUGIN_PRODUCT_MISMATCH", "PluginPlanFailed", "PLUGIN_PRODUCT_MISMATCH")]
[InlineData("LEGACY_MAPPING_NOT_FOUND", "PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND")]
[InlineData("LOCAL_PARITY_ROSTER_PROFILE_MISMATCH", "RosterResolutionFailed", "OWNER_REVIEW_PROFILE_ROSTER_MISMATCH")]
public void SelectionAndMappingErrors_KeepTheirOperationStage(string source, string stage, string code)
{
var failure = LauncherOperationFailures.Classify(source, "ProjectValidationFailed");
Assert.Equal(stage, failure.Stage);
Assert.Equal(code, failure.Code);
Assert.False(failure.ProcessStartAttempted);
}
[Fact]
public void LaunchFailure_IsAttributedOnlyToActualProcessStartCode()
{
var failure = LauncherOperationFailures.Classify("PLANT3D_PROCESS_NOT_CREATED", "PackagePreparationFailed");
Assert.Equal("ProcessStartFailed", failure.Stage);
Assert.True(failure.ProcessStartAttempted);
Assert.Equal("Не удалось запустить Autodesk", failure.UserMessage);
}
[Fact]
public void UnknownPreparationFailure_DoesNotBecomeProcessFailure()
{
var failure = LauncherOperationFailures.Classify("LOCAL_PARITY_PATTERN_NOT_FOUND", "PackagePreparationFailed");
Assert.Equal("PackagePreparationFailed", failure.Stage);
Assert.False(failure.ProcessStartAttempted);
}
}
@@ -0,0 +1,118 @@
using System.Security.Cryptography;
using System.Text.Json;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PackageLifecycleTests
{
private sealed class Fault(PackageLifecycleFaultPoint point) : IPackageLifecycleFaultInjector { public void ThrowIfRequested(PackageLifecycleFaultPoint requested) { if(requested==point) throw new IOException("INJECTED"); } }
[Fact] public async Task InjectedMaterializationFaultPreservesPointers()
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]); var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.AfterStagingAllocated)); try { await Assert.ThrowsAsync<IOException>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None)); Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None)); } finally { if(Directory.Exists(root)) Directory.Delete(root,true); } }
[Fact] public void PilotRootRejectsUncAndRelativePaths()
{ var resolver=new PathResolver(new Dictionary<string,string?>()); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot(@"\\server\share")); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot("relative")); }
[Fact] public void LifecycleRootsAreTypedDistinctAndConfined()
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var roots=new PathResolver(new Dictionary<string,string?>()).ResolvePackageLifecycleRoots(root); var values=new[]{roots.Catalog.Value,roots.Staging.Value,roots.VersionStore.Value,roots.Active.Value,roots.Evidence.Value}; Assert.Equal(values.Length,values.Distinct(StringComparer.OrdinalIgnoreCase).Count()); Assert.All(values,x=>Assert.StartsWith(roots.Pilot.Value,x,StringComparison.OrdinalIgnoreCase)); }
[Theory]
[InlineData(PackageLifecycleFaultPoint.AfterStagingAllocated)]
[InlineData(PackageLifecycleFaultPoint.DuringPayloadCopy)]
[InlineData(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation)]
[InlineData(PackageLifecycleFaultPoint.BeforeImmutableCommit)]
[InlineData(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn)]
public async Task DeterministicMaterializationFaultsNeverMutatePointers(PackageLifecycleFaultPoint point)
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));try{await Assert.ThrowsAnyAsync<Exception>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None));Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None));Assert.Null(await store.GetPreviousVersionAsync("Lifecycle.Package",CancellationToken.None));}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true);
private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
new("payload.dat", b.Length, Convert.ToHexString(SHA256.HashData(b)))], new("none", ""), RolloutChannel: "pilot");
[Fact]
public async Task MutatedImmutablePayloadBlocksActivation()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); var bytes=new byte[]{1}; await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes); var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var package=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await File.WriteAllBytesAsync(Path.Combine(package.LocalPackageRoot,"entry.dll"),new byte[]{9}); Assert.Equal("Blocked",(await store.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None)).Outcome); }
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
}
[Fact]
public async Task ActivationReceiptBindsPointerGenerationAndRejectsStaleRollback()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); var bytes=new byte[]{7}; await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes); var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var receipt= (await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None)).Receipt!; Assert.True(receipt.PointerGeneration>0); var c=await store.MaterializeAsync(Manifest("3.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(c,Req("3.0.0"),CancellationToken.None); Assert.Equal("RollbackBlockedStateChanged",(await store.RollbackAsync(receipt,CancellationToken.None)).Outcome); }
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
}
[Fact]
public async Task OwnedCleanupNeverDeletesForeignSibling()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var staging=Path.Combine(root,"Launcher","staging"); var owned=Path.Combine(staging,"owned"); var foreign=Path.Combine(staging,"foreign"); Directory.CreateDirectory(owned); Directory.CreateDirectory(foreign); var ownership=new PackageStagingOwnership("owned","P","1",owned,DateTimeOffset.UtcNow,"owned"); await File.WriteAllTextAsync(Path.Combine(owned,".package-staging-owner.json"),System.Text.Json.JsonSerializer.Serialize(ownership)); var store=new LocalPackageStore(root,new Sha256FileHasher());
try { Assert.Equal("Cleaned",(await store.CleanupOwnedStagingAsync(ownership,CancellationToken.None)).Outcome); Assert.True(Directory.Exists(foreign)); Assert.Equal("AlreadyAbsent",(await store.CleanupOwnedStagingAsync(ownership,CancellationToken.None)).Outcome); }
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
}
[Fact]
public async Task MaterializeDoesNotCreatePointersAndReturnsImmutableVersion()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source);
var a = new byte[] { 1, 2 }; var b = new byte[] { 3, 4, 5 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b);
var store = new LocalPackageStore(root, new Sha256FileHasher());
try
{
var result = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(result.LocalPackageRoot, "package.manifest.json")));
Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "current.json")));
Assert.False(File.Exists(Path.Combine(root, "Applications", "Lifecycle.Package", "previous.json")));
Assert.DoesNotContain("staging", result.LocalPackageRoot, StringComparison.OrdinalIgnoreCase);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task RepeatedExactMaterializationRevalidatesCommittedManifestAndPayload()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{6,7};await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes);var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var first=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);var second=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);Assert.Equal(first.LocalPackageRoot,second.LocalPackageRoot);Assert.False(File.Exists(Path.Combine(root,"Applications","Lifecycle.Package","current.json"))); }
finally { if(Directory.Exists(root))Directory.Delete(root,true); }
}
[Fact]
public async Task ActivationIsIdempotentAndRollbackRestoresPrevious()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
var a = new byte[] { 1 }; var b = new byte[] { 2 }; var c = new byte[] { 3 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), a); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), b);
try
{
var pa = await store.MaterializeAsync(Manifest("1.0.0", a, b), source, CancellationToken.None); var aa = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("Activated", aa.Outcome);
var again = await store.ActivateImmutableAsync(pa, Req("1.0.0"), CancellationToken.None); Assert.Equal("AlreadyActiveExact", again.Outcome);
await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), c); var pb = await store.MaterializeAsync(Manifest("2.0.0", c, b), source, CancellationToken.None); var ab = await store.ActivateImmutableAsync(pb, Req("2.0.0"), CancellationToken.None); Assert.Equal("Activated", ab.Outcome);
var rollback = await store.RollbackAsync(ab.Receipt!, CancellationToken.None); Assert.Equal("RolledBack", rollback.Outcome); Assert.Equal("1.0.0", await store.GetCurrentVersionAsync("Lifecycle.Package", CancellationToken.None));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task StrictPointerBindsIndividualPayloadsAndExactLkgRejectsTampering()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{4,5};await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes);var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var p=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);await store.ActivateImmutableAsync(p,Req("1.0.0"),CancellationToken.None);var pointerPath=Path.Combine(root,"Applications","Lifecycle.Package","current.json");using(var doc=JsonDocument.Parse(await File.ReadAllTextAsync(pointerPath))){Assert.Equal(2,doc.RootElement.GetProperty("SchemaVersion").GetInt32());Assert.Equal(2,doc.RootElement.GetProperty("PayloadFiles").GetArrayLength());}Assert.NotNull(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None));var json=await File.ReadAllTextAsync(pointerPath);json=json.Replace("PayloadSetSha256\":\"","PayloadSetSha256\":\"00",StringComparison.Ordinal);await File.WriteAllTextAsync(pointerPath,json);Assert.Null(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None)); }
finally{if(Directory.Exists(root))Directory.Delete(root,true);}
}
[Theory]
[InlineData(PackageLifecycleFaultPoint.BeforePointerTempWrite)]
[InlineData(PackageLifecycleFaultPoint.BeforePointerReplace)]
[InlineData(PackageLifecycleFaultPoint.BeforePointerReadback)]
public async Task PointerFaultsDoNotReturnFalseActivationAndReleaseLock(PackageLifecycleFaultPoint point)
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);try{var package=await new LocalPackageStore(root,new Sha256FileHasher()).MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None);var failing=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));await Assert.ThrowsAnyAsync<Exception>(()=>failing.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None));var healthy=new LocalPackageStore(root,new Sha256FileHasher());var result=await healthy.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None);Assert.Contains(result.Outcome,new[]{"Activated","AlreadyActiveExact"});}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
[Fact]
public async Task OldReceiptCannotRollbackNewerActivation()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "catalog"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "entry.dll"), bytes); await File.WriteAllBytesAsync(Path.Combine(source, "payload.dat"), bytes);
try { var a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var rb=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var r=await store.RollbackAsync(rb.Receipt!,CancellationToken.None); Assert.Equal("RolledBack",r.Outcome); }
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}
@@ -0,0 +1,60 @@
using System.Security.Cryptography;
using Launcher.Next.Application;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PackageResolutionUseCaseTests
{
private static PackageRequirement Requirement(string version, bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline);
private static PackageManifest Manifest(string version, byte[] bytes, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot") => new(1, "P_Rec_Annot", version, product, year, architecture, "PRecAnnot.Plant3D2026.dll", [new("PRecAnnot.Plant3D2026.dll", bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)))], new("netload", "PHP_REC_ANNOT"), RolloutChannel: channel);
[Fact] public async Task OnlineSuccess_StagesBindsAndRecordsOnlineEvidence()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1, 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var manifest = Manifest("1.0.0", bytes); var store = new LocalPackageStore(root, new Sha256FileHasher());
try { var result = await UseCase(new(new CatalogPackage(manifest, source))).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.True(result.IsResolved); Assert.True(result.Online); Assert.False(result.Selected!.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "ONLINE_PACKAGE_SELECTED"); Assert.False(result.Selected.LocalPackageRoot.StartsWith("\\\\", StringComparison.Ordinal)); }
finally { Directory.Delete(root, true); }
PackageResolutionUseCase UseCase(FakeCatalog catalog) => new(catalog, store, store, store, new Telemetry());
}
[Fact] public async Task ControlledOfflineRecovery_CorruptB_SelectsVerifiedAAndEvidence()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher());
try
{
var a = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), a); await store.SynchronizeAsync(Manifest("1.0.0", a), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None);
var b = new byte[] { 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), b); await store.SynchronizeAsync(Manifest("2.0.0", b), source, CancellationToken.None); await store.BindAsync(Requirement("2.0.0"), "2.0.0", CancellationToken.None);
await File.WriteAllBytesAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2.0.0", "PRecAnnot.Plant3D2026.dll"), new byte[] { 9 });
var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None);
Assert.True(result.IsResolved); Assert.False(result.Online); Assert.Equal("1.0.0", result.Selected!.Version); Assert.True(result.Selected.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "PREVIOUS_LKG_SELECTED"); Assert.Contains(result.Evidence, x => x.Code == "CATALOG_UNAVAILABLE");
}
finally { Directory.Delete(root, true); }
}
[Fact] public async Task UnavailableCatalog_WithFallbackDisabled_BlocksWithoutSelectingPrevious()
{
var store = new LocalPackageStore(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), new Sha256FileHasher());
var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0", false), CancellationToken.None);
Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "OFFLINE_FALLBACK_DISABLED"); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE");
}
[Fact] public async Task OnlineProductMismatch_IsRejectedAndBlocked()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher());
try { var result = await new PackageResolutionUseCase(new FakeCatalog(new CatalogPackage(Manifest("1.0.0", bytes, "Revit"), source)), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE"); }
finally { Directory.Delete(root, true); }
}
[Theory]
[InlineData("Revit", 2026, "x64", "pilot", "1.0.0", "project-a")]
[InlineData("Plant3D", 2025, "x64", "pilot", "1.0.0", "project-a")]
[InlineData("Plant3D", 2026, "x86", "pilot", "1.0.0", "project-a")]
[InlineData("Plant3D", 2026, "x64", "stable", "1.0.0", "project-a")]
[InlineData("Plant3D", 2026, "x64", "pilot", "2.0.0", "project-a")]
[InlineData("Plant3D", 2026, "x64", "pilot", "1.0.0", "project-b")]
public async Task CachedPackage_RejectsIncompatibleRequirement(string product, int year, string architecture, string channel, string version, string roster)
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher());
try { await store.SynchronizeAsync(Manifest("1.0.0", bytes), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None); Assert.Null(await store.ResolveAsync(Requirement(version, true, product, year, architecture, channel, roster), CancellationToken.None)); }
finally { Directory.Delete(root, true); }
}
private sealed class FakeCatalog(CatalogPackage? package) : IPackageCatalogPackageAcquirer { public Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) => Task.FromResult(package); }
private sealed class Telemetry : ILauncherTelemetry { public void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null) { } }
}
@@ -0,0 +1,151 @@
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PathAndLaunchTests
{
private static readonly IReadOnlyDictionary<string, string?> Paths = new Dictionary<string, string?>
{
[LauncherPathVariableNames.CorporateDfsRoot] = "\\\\phimproject.com\\a",
[LauncherPathVariableNames.ProjectName] = "Проект",
[LauncherPathVariableNames.DisciplineName] = "Plant 3D",
[LauncherPathVariableNames.ProjectRoot] = "${CorporateDfsRoot}\\${ProjectName}",
[LauncherPathVariableNames.DisciplineRoot] = "${ProjectRoot}/${DisciplineName}",
[LauncherPathVariableNames.LocalLauncherRoot] = "C:\\Launcher Pilot",
[LauncherPathVariableNames.LocalSessionRoot] = "${LocalLauncherRoot}\\sessions",
[LauncherPathVariableNames.AcadExecutable] = "C:\\Plant 3D 2026\\acad.exe",
[LauncherPathVariableNames.FixtureOrthoDwg] = "C:\\Fixture Space\\Тест.dwg",
[LauncherPathVariableNames.FixtureModelDwg] = "C:\\Fixture Space\\model.dwg",
[LauncherPathVariableNames.AutomationScriptPath] = "${LocalSessionRoot}\\preview.scr",
[LauncherPathVariableNames.AutomationConfigPath] = "${LocalSessionRoot}\\preview.json"
};
private static PathResolver Resolver(IReadOnlyDictionary<string, string?>? env = null) => new(env ?? new Dictionary<string, string?>());
[Fact] public void PathResolver_ResolvesSimpleVariable() => Assert.Equal("C:\\Launcher Pilot", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.LocalLauncherRoot));
[Fact] public void PathResolver_ResolvesNestedVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект\\Plant 3D", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
[Fact] public void PathResolver_ResolvesEnvironmentVariable() => Assert.Equal("C:\\From Environment", Resolver(new Dictionary<string, string?> { ["ROOT"] = "C:\\From Environment" }).Resolve(new(new Dictionary<string, string?> { ["Value"] = "${ROOT}" })).Require("Value"));
[Fact] public void PathResolver_RejectsUnknownVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "${Missing}" })));
[Fact] public void PathResolver_RejectsEmptyRequiredVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "" }), ["Value"]));
[Fact] public void PathResolver_PreservesOptionalNullVariable() => Assert.Null(Resolver().Resolve(new(new Dictionary<string, string?> { ["Optional"] = null })).Get("Optional"));
[Fact] public void PathResolver_RejectsCycle() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["A"] = "${B}", ["B"] = "${A}" })));
[Fact] public void PathResolver_PreservesUnicode() => Assert.Contains("Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
[Fact] public void PathResolver_NormalizesSeparators() => Assert.DoesNotContain('/', Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
[Fact] public void PathResolver_NormalizesLegacyDriveAToCorporateDfs() => Assert.Equal("\\\\phimproject.com\\a\\Project\\Plant", Resolver().ResolvePath("A:\\Project\\Plant", new(Paths)));
[Fact] public void PathResolver_DoesNotEmitDriveA() => Assert.False(Resolver().ResolvePath("A:\\Project", new(Paths)).StartsWith("A:\\", StringComparison.OrdinalIgnoreCase));
[Fact] public void PathResolver_CanonicalVariablesDoNotEmitDriveA() => Assert.False(Resolver().Resolve(new(new Dictionary<string, string?> { [LauncherPathVariableNames.CorporateDfsRoot] = "\\\\phimproject.com\\a", ["Legacy"] = "A:\\Project" })).Require("Legacy").StartsWith("A:\\", StringComparison.OrdinalIgnoreCase));
[Fact] public void PathResolver_RejectsTraversalOutsideRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("${LocalSessionRoot}\\..\\escape", new(Paths), "${LocalSessionRoot}"));
[Fact] public void PathResolver_RequiresAbsolutePath() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("relative\\file", new(Paths)));
[Fact] public void ProjectRoot_UsesProjectAndDisciplineVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
[Fact] public void CorporateDfsRoot_ComesFromConfiguration() => Assert.Equal("\\\\phimproject.com\\a", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.CorporateDfsRoot));
[Fact] public void ConfigurationLayers_SessionOverridesMachineOverridesDefaults()
{
var result = LauncherConfigurationLayers.Load("{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\defaults\",\"AcadExecutable\":\"C:\\\\acad.exe\"}}", "{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\machine\"}}", "{\"SchemaVersion\":1,\"PilotEnabled\":true,\"PathVariables\":{\"LocalSessionRoot\":\"C:\\\\session\"}}");
Assert.True(result.IsValid); Assert.Equal("C:\\machine", result.Configuration.PathVariables[LauncherPathVariableNames.LocalLauncherRoot]); Assert.True(result.Configuration.PilotEnabled);
}
[Fact] public void ProductionExecute_DoesNotAddBatchScript() => Assert.DoesNotContain("/b", new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null));
[Fact] public void ProductionExecute_RejectsAutomationScriptPath() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.Production, "C:\\session\\a.scr"));
[Fact] public void ProductionExecute_DoesNotCreateScript() => Assert.DoesNotContain(new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null), x => x.EndsWith(".scr", StringComparison.OrdinalIgnoreCase));
[Fact] public void ProductionExecute_DoesNotSetAutomationEnvironment() => Assert.DoesNotContain("P_REC_ANNOT_AUTOMATION", new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }.Keys);
[Fact] public void PreviewExecute_RequiresBatchScript() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, null));
[Fact] public void PreviewExecute_AddsExactlyOneBatchArgument() => Assert.Single(new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"), x => x == "/b");
[Fact] public void PreviewExecute_UsesResolvedScriptPath() => Assert.Contains(Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath), new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath)));
[Fact] public void PreviewExecute_RejectsPathOutsideSessionRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("C:\\elsewhere\\preview.scr", new(Paths), "${LocalSessionRoot}"));
[Fact] public void PreviewExecute_RequiresFixtureMarker() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", LaunchMode = AcadLaunchMode.NextPilotPreview }, false), false).Violations);
[Fact] public void PreviewExecute_RequiresProductionDisabled() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", FixtureMarkerPath = "C:\\session\\fixture.marker", LaunchMode = AcadLaunchMode.NextPilotPreview, ProductionDeploy = true }, false), false).Violations);
[Fact] public void AcadArguments_ProductionContainProductAndLanguage() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU"], a); }
[Fact] public void AcadArguments_AutomationContainProductLanguageAndBatch() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU", "/b", "C:\\session\\preview.scr"], a); }
[Fact] public void AcadArguments_DoNotContainProfileOrWorkspaceYet() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.DoesNotContain("/p", a); Assert.DoesNotContain("/w", a); }
[Fact] public void PreviewScript_IsDeterministicAndContainsRequiredCommandOrder()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root);
var entry = "PRecAnnot.dll"; var fixture = Path.Combine(root, "Fixture Орто.dwg"); File.WriteAllText(Path.Combine(root, entry), string.Empty); File.WriteAllText(fixture, string.Empty);
try
{
var model = Path.Combine(root, "model.dwg"); File.WriteAllText(model, string.Empty);
var paths = new ResolvedAutomationScriptPaths(root, entry, fixture, model, Path.Combine(root, "preview.scr"), false, false);
var first = new StartupScriptBuilder().BuildAutomationScript(paths); var second = new StartupScriptBuilder().BuildAutomationScript(paths);
Assert.Equal(first, second); Assert.EndsWith("\n", first); Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(first, "PXP-OA").Count);
Assert.True(first.IndexOf("_.FILEDIA\n0", StringComparison.Ordinal) < first.IndexOf("_.OPEN", StringComparison.Ordinal));
Assert.DoesNotContain(model, first, StringComparison.OrdinalIgnoreCase);
Assert.True(first.IndexOf("PHP_REC_AUTOMATION_PREPARE", StringComparison.Ordinal) < first.IndexOf("PXP-OA", StringComparison.Ordinal));
Assert.True(first.LastIndexOf("PXP-OA", StringComparison.Ordinal) < first.IndexOf("PHP_REC_AUTOMATION_FINALIZE", StringComparison.Ordinal));
}
finally { Directory.Delete(root, true); }
}
[Fact] public void DiagnosticScript_BracketsBothPxpOaCommands()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "diagnostic.scr"), true, false)); Assert.Contains("PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1", script); Assert.Contains("PHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2", script); }
finally { Directory.Delete(root, true); }
}
[Fact] public void ApplyScript_MarksCreatesDeduplicatesAndUndoes()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "apply.scr"), false, true)); Assert.Contains("_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK", script); }
finally { Directory.Delete(root, true); }
}
[Fact]
public void SelectedEntityScript_UsesExactHandleAndDxfTypeWithoutCoordinates()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
try
{
var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, true, new("PXP-AAV", "1a2b", "VIEWPORT")));
Assert.Contains("(handent \"1A2B\")", script); Assert.Contains("(command-s \"PXP-AAV\" P_REC_ANNOT_SELECTED_ENTITY)", script); Assert.Contains("P_REC_ANNOT_SELECTION_REJECTED", script); Assert.DoesNotContain("_.ZOOM", script); Assert.DoesNotContain("_.POINT", script);
}
finally { Directory.Delete(root, true); }
}
[Fact]
public void SelectedEntityScript_RejectsUnsafeSelectionTokens()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
try { Assert.Throws<InvalidOperationException>(() => new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, false, new("PXP-AAV;QUIT", "1A2B", "VIEWPORT")))); }
finally { Directory.Delete(root, true); }
}
[Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); }
[Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n");
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
[Fact] public void AutomationTranscript_IsInsideSessionRoot() => Assert.StartsWith("C:\\session\\", "C:\\session\\preview-transcript.log", StringComparison.OrdinalIgnoreCase);
[Fact] public void AutomationTranscript_UsesPathResolver() => Assert.Contains(LauncherPathVariableNames.AutomationTranscriptPath, typeof(LauncherPathVariableNames).GetFields().Select(x => x.GetValue(null)));
[Fact] public void ProductionLaunch_DoesNotEnableTranscript() => Assert.Null(new PilotLocalConfiguration().PathVariables.GetValueOrDefault(LauncherPathVariableNames.AutomationTranscriptPath));
[Fact] public void AutomationEntryAssembly_IsResolvedFromPackageSource()
{
var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.App", "PilotCli.cs"));
Assert.Contains("Path.Combine(options.PackageSource, options.EntryAssembly)", source);
}
[Fact] public void ProductionLaunch_DoesNotExposeSentinels() { var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.Autodesk", "AutodeskAdapters.cs")); Assert.DoesNotContain("PHP_REC_AUTOMATION_BEFORE_PXP_1", source[..source.IndexOf("BuildAutomationScript", StringComparison.Ordinal)]); }
[Fact] public void CorporateDfsRoot_RemainsConfigurationOnly() => Assert.DoesNotContain("phimproject.com", File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.Core", "LauncherPaths.cs")), StringComparison.OrdinalIgnoreCase);
[Fact] public void PilotRootPolicy_AcceptsItsTrustedRoot() => Assert.True(new PilotRootPolicy("C:\\Pilot").Validate("C:\\Pilot").IsValid);
[Fact] public void AutomationPrepare_ReportsSafetyViolations()
{
var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.App", "PilotCli.cs"));
Assert.Contains("AUTOMATION_PREPARE_BLOCKED", source);
}
[Fact] public void ProductionSource_HasNoForbiddenPathLiterals()
{
var root = FindRoot();
var forbidden = new[] { "A:\\", "\\\\phimproject.com\\a", "C:\\AutoCAD User Support", "C:\\CAD User Support", "C:\\dev\\" };
var production = new[] { "Launcher.Next.App", "Launcher.Next.Core", "Launcher.Next.Infrastructure", "Launcher.Next.Autodesk" }
.SelectMany(folder => Directory.GetFiles(Path.Combine(root, folder), "*.cs", SearchOption.AllDirectories));
foreach (var file in production)
{
foreach (var text in forbidden)
{
if (Path.GetFileName(file) == "LauncherPaths.cs" && text == "A:\\") continue; // explicit legacy-input alias parser exception
Assert.DoesNotContain(text, File.ReadAllText(file), StringComparison.OrdinalIgnoreCase);
}
}
}
private static PilotSafetyContext Context(PilotLocalConfiguration configuration, bool execution) => new(configuration with { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, RequireExplicitExecute = true, AllowProcessStart = execution }, new("C:\\acad.exe", "2026", "x64", []), new("", false, true, ["test"]), new(true, "C:\\Pilot", []), ValidationResult.Success, true, false, false);
private static string FindRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Launcher.Next.sln"))) directory = directory.Parent;
return directory!.FullName;
}
}
@@ -0,0 +1,18 @@
using Launcher.Next.Autodesk;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PlantDiscoveryTests
{
private sealed class Registry(string? install) : IRegistryReader
{ public object? GetValue(string path,string name)=>path.Contains("ACAD-9117",StringComparison.Ordinal)&&name=="InstallDir"?install:null; }
[Fact]
public void Plant2026DiscoveryUsesPlantSpecificRegistryKey()
{
var directory=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));Directory.CreateDirectory(Path.Combine(directory,"PLNT3D"));File.WriteAllBytes(Path.Combine(directory,"acad.exe"),[]);
try { var product=new PlantProductDiscovery(new Registry(directory)).DiscoverPlant3D(2026);Assert.NotNull(product);Assert.Equal("2026",product!.ProductVersion);Assert.Equal(Path.Combine(directory,"acad.exe"),product.ExecutablePath); }
finally { Directory.Delete(directory,true); }
}
}
@@ -0,0 +1,138 @@
using Launcher.Next.Application;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using System.Security.Cryptography;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class RosterAdaptersTests
{
private static readonly RosterContext Context = new("Plant3D", 2026, "x64", true, "legacy-project-a");
[Fact]
public void ProjectRoster_ProducesDeterministicRequirements()
{
var selected = new RosterAdapter().Select(Json(), null, null, Context);
var requirements = new RosterAdapter().Requirements(selected.Roster!);
Assert.Equal(RosterSource.ProjectRosterJson, selected.Source);
Assert.Equal(["P_A", "P_B"], requirements.Select(x => x.PackageId));
Assert.Equal(["1.0.0", "2.0.0"], requirements.Select(x => x.RequiredVersion));
}
[Fact]
public void LegacyDownloadList_PreservesSourceOrderAndUsesContext()
{
const string xml = "<downloadList><Plugin id='P_First' version='1.0.0' channel='stable'/><Plugin id='P_Second' version='2.0.0' install='false'/></downloadList>";
var selected = new RosterAdapter().Select(null, xml, null, Context);
Assert.Equal(RosterSource.LegacyDownloadListXml, selected.Source);
Assert.Equal("legacy-project-a", selected.Roster!.RosterId);
Assert.Equal(["P_First", "P_Second"], selected.Roster.Packages.Select(x => x.Id));
Assert.False(selected.Roster.Packages[1].Enabled);
}
[Fact]
public void LegacySettingsAndDownloadList_JoinOnlyThroughExplicitOverlay()
{
const string settings = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant</name><filter>*.dwg</filter><roster>r</roster></pattern><plugin><id>legacy-a</id><renewal>x</renewal><catalog>cat</catalog><install>load</install></plugin></data></root>";
const string selection = "<root><id>legacy-a</id></root>";
var adapter = new LegacyConfigurationAdapter();
var parsed = adapter.ParseSettings(settings);
var requirements = adapter.Join(parsed.Patterns[0], adapter.ParseDownloadList(selection), new("map-a", "Plant3D", 2026, "x64", false, [new("legacy-a", "PHIM.A", "1.0.0", "pilot")]));
Assert.Single(requirements); Assert.Equal("PHIM.A", requirements[0].PackageId);
}
[Fact]
public void ExplicitPRecUpdateMapping_PreservesRosterOrderAlongsidePRecAnnot()
{
const string settings = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant</name><filter>*.xml</filter><roster>r</roster></pattern><plugin><id>b455ee6eaed79d76f084846a60ce3284</id><renewal></renewal><catalog>P_Rec_Annot</catalog><install>OrthoLoader.dll</install></plugin><plugin><id>6ffef84b01097975f51fb3bbb11279e1</id><renewal></renewal><catalog>P_Rec_Update</catalog><install>P_Rec_Update.dll</install></plugin></data></root>";
const string selection = "<root><id>b455ee6eaed79d76f084846a60ce3284</id><id>6ffef84b01097975f51fb3bbb11279e1</id></root>";
const string overlay = """{"schemaVersion":1,"mappings":[{"mappingId":"p-rec-annot","pattern":"Plant","legacyPluginId":"b455ee6eaed79d76f084846a60ce3284","legacyCatalog":"P_Rec_Annot","legacyInstall":"OrthoLoader.dll","package":{"id":"P_Rec_Annot","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}},{"mappingId":"p-rec-update","pattern":"Plant","legacyPluginId":"6ffef84b01097975f51fb3bbb11279e1","legacyCatalog":"P_Rec_Update","legacyInstall":"P_Rec_Update.dll","package":{"id":"P_Rec_Update","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}}]}""";
var adapter = new LegacyConfigurationAdapter();
var requirement = adapter.Join(adapter.ParseSettings(settings).Patterns[0], adapter.ParseDownloadList(selection), LegacyCompatibilityOverlayParser.Parse(overlay));
Assert.Equal(["P_Rec_Annot", "P_Rec_Update"], requirement.Select(x => x.PackageId));
Assert.All(requirement, x => Assert.Equal("0.1.0", x.RequiredVersion));
}
[Fact]
public void LegacyDownloadList_DoesNotInventPluginMetadata()
{
var selection = new LegacyConfigurationAdapter().ParseDownloadList("<root><id>legacy-a</id></root>");
Assert.Equal(["legacy-a"], selection.PluginIds);
}
[Fact]
public void LegacyProbeOverride_RequiresExplicitProbeModeAndStrictSchema()
{
const string valid = """{"schemaVersion":1,"mode":"LegacyConfigurationProbe","settingsXml":"C:/Settings.xml","downloadListXml":"C:/download_list.xml","compatibilityOverlay":"C:/overlay.json","selectedPattern":"Plant","evidenceOutputRoot":"C:/evidence"}""";
Assert.Equal("Plant", LegacyProbeOverrideParser.Parse(valid).SelectedPattern);
Assert.Throws<InvalidOperationException>(() => LegacyProbeOverrideParser.Parse(valid.Replace("LegacyConfigurationProbe","Production")));
Assert.Throws<InvalidOperationException>(() => LegacyProbeOverrideParser.Parse(valid[..^1] + ",\"unknown\":true}"));
}
[Fact]
public void ProjectRoster_HasPriorityOverLegacy()
{
var selected = new RosterAdapter().Select(Json(), "<downloadList><Plugin id='Legacy' version='1.0.0'/></downloadList>", null, Context);
Assert.Equal(RosterSource.ProjectRosterJson, selected.Source);
}
[Fact]
public void MalformedProjectRoster_DoesNotFallBackImplicitly()
{
Assert.ThrowsAny<Exception>(() => new RosterAdapter().Select("{", "<downloadList><Plugin id='Legacy' version='1.0.0'/></downloadList>", Json(), Context));
}
[Fact]
public void CentralDefault_IsUsedOnlyWhenProjectAndLegacyAreAbsent()
{
var selected = new RosterAdapter().Select(null, null, Json(), Context);
Assert.Equal(RosterSource.CentralDefaultRoster, selected.Source);
Assert.Equal("CENTRAL_DEFAULT_ROSTER", selected.Code);
}
[Fact]
public void DuplicatePackage_IsRejected()
{
const string json = """{"schemaVersion":1,"rosterId":"project-a","product":"Plant3D","year":2026,"architecture":"x64","offlinePolicy":"AllowVerifiedLastKnownGood","packages":[{"id":"P_A","version":"1.0.0","channel":"stable"},{"id":"p_a","version":"2.0.0","channel":"stable"}]}""";
var ex = Assert.Throws<InvalidOperationException>(() => new RosterAdapter().ParseJson(json));
Assert.Equal("ROSTER_DUPLICATE_PACKAGE", ex.Message);
}
[Fact]
public void LegacyXml_RejectsDtd()
{
const string xml = "<!DOCTYPE x [<!ENTITY e SYSTEM 'file:///not-allowed'>]><downloadList><Plugin id='&e;' version='1.0.0'/></downloadList>";
Assert.ThrowsAny<Exception>(() => new RosterAdapter().ParseLegacyDownloadList(xml, Context));
}
[Fact]
public async Task FileSystemCatalog_ReturnsDescriptorOnly_ForMatchingRosterRequirement()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var packageRoot=Path.Combine(root,"P_A","1.0.0"); Directory.CreateDirectory(packageRoot);
try
{
var bytes=new byte[]{1}; await File.WriteAllBytesAsync(Path.Combine(packageRoot,"entry.dll"),bytes);
var hash=Convert.ToHexString(SHA256.HashData(bytes));
await File.WriteAllTextAsync(Path.Combine(packageRoot,"package.manifest.json"), $$"""{"schemaVersion":1,"packageId":"P_A","version":"1.0.0","product":"Plant3D","productYear":2026,"architecture":"x64","entryAssembly":"entry.dll","files":[{"path":"entry.dll","length":1,"sha256":"{{hash}}"}],"startup":{"type":"netload","command":"P_A"},"rolloutChannel":"stable"}""");
var result=await new FileSystemCatalogPackageAcquirer(CatalogRoot(root)).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None);
Assert.NotNull(result); Assert.Equal(packageRoot,result!.SourceRoot); Assert.False(result.SourceRoot.StartsWith(@"\\",StringComparison.Ordinal));
}
finally { Directory.Delete(root,true); }
}
[Fact]
public async Task FileSystemCatalog_RejectsTraversalInManifest()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var packageRoot=Path.Combine(root,"P_A","1.0.0"); Directory.CreateDirectory(packageRoot);
try
{
await File.WriteAllTextAsync(Path.Combine(packageRoot,"package.manifest.json"), """{"schemaVersion":1,"packageId":"P_A","version":"1.0.0","product":"Plant3D","productYear":2026,"architecture":"x64","entryAssembly":"entry.dll","files":[{"path":"../outside.dll","length":1,"sha256":"00"}],"startup":{"type":"netload","command":"P_A"},"rolloutChannel":"stable"}""");
Assert.Null(await new FileSystemCatalogPackageAcquirer(CatalogRoot(root)).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None));
}
finally { Directory.Delete(root,true); }
}
private static string Json() => """{"schemaVersion":1,"rosterId":"project-a","product":"Plant3D","year":2026,"architecture":"x64","offlinePolicy":"AllowVerifiedLastKnownGood","packages":[{"id":"P_B","version":"2.0.0","channel":"stable","loadOrder":200},{"id":"P_A","version":"1.0.0","channel":"stable","loadOrder":100}]}""";
private static ResolvedCatalogRoot CatalogRoot(string root) => new PathResolver().ResolveCatalogRoot(new(new Dictionary<string,string?> { [LauncherPathVariableNames.CorporateDfsRoot] = Path.GetDirectoryName(root)!, [LauncherPathVariableNames.CorporateCatalogRoot] = root }));
}
@@ -0,0 +1,25 @@
using Launcher.Next.Core;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class StartupRosterPreflightTests
{
[Fact]
public void ForeignVersionIsConflicting()
{
var root = Path.Combine(Path.GetTempPath(), "roster-" + Guid.NewGuid().ToString("N"));
var requestedRoot = Path.Combine(root, "requested");
Directory.CreateDirectory(requestedRoot);
File.WriteAllText(Path.Combine(requestedRoot, "PRecAnnot.Plant3D2026.dll"), "new");
var old = Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "0.1.0");
Directory.CreateDirectory(old);
File.WriteAllText(Path.Combine(old, "PRecAnnot.Plant3D2026.dll"), "old");
File.WriteAllText(Path.Combine(root, "Applications", "P_Rec_Annot", "current.json"), "{\"Current\":\"0.1.0\"}");
File.WriteAllText(Path.Combine(old, "package.manifest.json"), "{\"PackageId\":\"P_Rec_Annot\",\"Version\":\"0.1.0\",\"EntryAssembly\":\"PRecAnnot.Plant3D2026.dll\"}");
var requested = new PackageManifest(1, "P_Rec_Annot", "0.2.9", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", [], new("netload", "PHP_REC_ANNOT"));
var report = StartupRosterPreflight.Inspect(requestedRoot, requested, [Path.Combine(root, "Applications")]);
Assert.Equal(StartupRosterCandidateStatus.ConflictingCandidatePresent, report.Status);
Directory.Delete(root, true);
}
}