Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7cb3ac39 | ||
|
|
5a14658994 | ||
|
|
e12b8c549c | ||
|
|
6f7888b18b | ||
|
|
1f5853de86 | ||
|
|
94d35a12d9 | ||
|
|
cda89c359e | ||
|
|
8652325980 | ||
|
|
7025ce0a46 | ||
|
|
514302b966 | ||
|
|
a5a615f7f0 |
@@ -28,7 +28,16 @@ public partial class App : System.Windows.Application
|
||||
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) { Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray())); 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ internal sealed class LocalParityLaunchService
|
||||
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 };
|
||||
@@ -95,7 +98,8 @@ internal sealed class LocalParityLaunchService
|
||||
// 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(Path.GetDirectoryName(prepared.StarterPath)!, "markers");
|
||||
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;
|
||||
@@ -110,12 +114,17 @@ internal sealed class LocalParityLaunchService
|
||||
var newLog = await reader.ReadToEndAsync(ct);
|
||||
runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal);
|
||||
}
|
||||
var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal));
|
||||
var updateInitialized = updatePackage is null || File.Exists(Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "markers", "AssemblyInitializeCompleted.marker"));
|
||||
var loaded = runtimeConfirmed && updateInitialized;
|
||||
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 = 3, 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 => new { x.Package.PackageId, x.Package.Version, x.Manifest.Product, x.Manifest.ProductYear, x.Manifest.Architecture, x.Manifest.RolloutChannel, manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), payloads = x.Manifest.Files.Select(f => new { path = f.Path, f.Length, f.Sha256 }), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, 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, updateInitialized, processInteractiveAfterScript=alive }, terminalState = terminal, sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } };
|
||||
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);
|
||||
}
|
||||
@@ -123,10 +132,22 @@ internal sealed class LocalParityLaunchService
|
||||
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 => Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly)).ToArray();
|
||||
if (entries.Any(x => !File.Exists(x))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
|
||||
Directory.CreateDirectory(Under(starterRoot, "markers"));
|
||||
var content = "_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n" + string.Concat(entries.Select(entry => "_.NETLOAD\r\n\"" + entry.Replace("\"", "\\\"") + "\"\r\n"));
|
||||
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));
|
||||
@@ -143,9 +164,19 @@ internal sealed class LocalParityLaunchService
|
||||
}
|
||||
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)); }
|
||||
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)));
|
||||
private 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"; await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { await JsonSerializer.SerializeAsync(stream, value, cancellationToken: ct); await stream.FlushAsync(ct); } File.Move(temp, final); using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct)); if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 3) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID"); return final; }
|
||||
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");
|
||||
|
||||
@@ -32,6 +32,10 @@ internal static class PilotCli
|
||||
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");
|
||||
@@ -166,15 +170,20 @@ internal static class PilotCli
|
||||
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!);
|
||||
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
|
||||
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;
|
||||
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", 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 }));
|
||||
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);
|
||||
@@ -189,7 +198,7 @@ internal static class PilotCli
|
||||
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", 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(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);
|
||||
@@ -229,9 +238,11 @@ internal static class PilotCli
|
||||
// terminal evidence is durable. Killing an active AutoCAD
|
||||
// command before that boundary would turn ForceAfterEvidence
|
||||
// into an unsafe general process killer.
|
||||
var evidenceTimeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
||||
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath))
|
||||
if (await Task.WhenAny(exit, evidenceTimeout) == evidenceTimeout) break;
|
||||
// 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
|
||||
{
|
||||
@@ -289,6 +300,12 @@ internal static class PilotCli
|
||||
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; }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Launcher.Next.Tests")]
|
||||
@@ -28,13 +28,26 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
||||
var diagnostic = paths.Diagnostic
|
||||
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
|
||||
: string.Empty;
|
||||
var commands = paths.Apply
|
||||
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";
|
||||
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 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
|
||||
|
||||
@@ -48,7 +48,7 @@ public sealed record CachedPackageResolution(string PackageId, string Version, s
|
||||
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 sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply);
|
||||
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 ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||
|
||||
@@ -21,6 +21,10 @@ public sealed record PilotLocalConfiguration
|
||||
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; }
|
||||
@@ -34,6 +38,16 @@ public sealed record PilotLocalConfiguration
|
||||
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; }
|
||||
@@ -83,6 +97,7 @@ public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
||||
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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ public sealed class PilotExecutionService(
|
||||
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));
|
||||
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);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="..\..\Launcher.Next.Application\Launcher.Next.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
|
||||
<ProjectReference Include="..\..\Launcher.Next.App\Launcher.Next.App.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,24 @@ public sealed class PathAndLaunchTests
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user