230 lines
20 KiB
C#
230 lines
20 KiB
C#
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 "automate-first-apply")) return Blocked;
|
|
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 (!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!);
|
|
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
|
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
|
|
if (!File.Exists(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe")) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
|
|
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
|
|
var service = CreateService();
|
|
var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "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 AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string EvidenceRoot);
|
|
private sealed record PreviewEvidence(bool Completed, string? PlanIdRun1, string? PlanIdRun2, bool? PlanIdsEqual, int? EligibleCandidateCount, string? SelectedCandidateHash);
|
|
private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState);
|
|
|
|
private static async Task<int> AutomateFirstApplyAsync(string[] args)
|
|
{
|
|
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
|
|
if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!);
|
|
if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute) 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 before = ContentHashes(options.FixtureMarker);
|
|
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
|
|
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
|
|
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker }));
|
|
var previewConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath };
|
|
if (!await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview")) 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.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, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
|
|
var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json");
|
|
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "Visible")));
|
|
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, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker }));
|
|
var applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath };
|
|
var started = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply");
|
|
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
|
|
if (File.Exists(permitPath)) File.Delete(permitPath);
|
|
return started && hashesEqual ? Success : Fail(ProcessFailed, started ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_SESSION_FAILED");
|
|
}
|
|
|
|
private static async Task<bool> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase)
|
|
{
|
|
var service = CreateService(); var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "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) return 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) return false;
|
|
try { await WaitForExitAsync(execution.ProcessId.Value); }
|
|
finally { await service.RollbackAsync(new(options.PilotRoot, manifest.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); }
|
|
return await IsRolledBack(options.PilotRoot);
|
|
}
|
|
|
|
private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error)
|
|
{
|
|
options = null!; error = null; var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
for (var i = 0; i < args.Length; i++) { 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", "--evidence-root" }).Any() || new[] { V("--package-source"), V("--entry-assembly"), V("--pilot-root"), V("--fixture-marker"), V("--ortho-dwg"), V("--model-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("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; }
|
|
options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")!); return true;
|
|
}
|
|
private static Dictionary<string,string> ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); }
|
|
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() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker());
|
|
private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
|
|
private static async Task<int> RollbackAsync(string root)
|
|
{
|
|
var result = await CreateService().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 path = Path.Combine(root, "config", "pilot.local.json");
|
|
if (!File.Exists(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
|
var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration;
|
|
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
|
|
return true;
|
|
}
|
|
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(); }
|
|
}
|