Files
launcher-next/Launcher.Next.App/PilotCli.cs
T

437 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
}