@alpsckr/unitycli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +111 -299
  2. package/contracts/init-contract.json +64 -0
  3. package/contracts/instances-contract.json +49 -0
  4. package/contracts/package-assembly-closure.json +33 -0
  5. package/contracts/package-files.json +1953 -0
  6. package/dist/bridge/session-supervisor.d.ts +2 -0
  7. package/dist/bridge/session-supervisor.js +52 -19
  8. package/dist/bridge/session-supervisor.js.map +1 -1
  9. package/dist/cli-commands/command.js +33 -15
  10. package/dist/cli-commands/command.js.map +1 -1
  11. package/dist/cli-commands/init-discovery.d.ts +11 -0
  12. package/dist/cli-commands/init-discovery.js +185 -0
  13. package/dist/cli-commands/init-discovery.js.map +1 -0
  14. package/dist/cli-commands/init-help.d.ts +3 -0
  15. package/dist/cli-commands/init-help.js +41 -0
  16. package/dist/cli-commands/init-help.js.map +1 -0
  17. package/dist/cli-commands/init-skill.d.ts +10 -0
  18. package/dist/cli-commands/init-skill.js +171 -0
  19. package/dist/cli-commands/init-skill.js.map +1 -0
  20. package/dist/cli-commands/instances.d.ts +4 -0
  21. package/dist/cli-commands/instances.js +90 -0
  22. package/dist/cli-commands/instances.js.map +1 -0
  23. package/dist/cli-commands/package.d.ts +2 -2
  24. package/dist/cli-commands/package.js +97 -130
  25. package/dist/cli-commands/package.js.map +1 -1
  26. package/dist/cli-commands/skill-installer.d.ts +29 -13
  27. package/dist/cli-commands/skill-installer.js +127 -88
  28. package/dist/cli-commands/skill-installer.js.map +1 -1
  29. package/dist/cli-commands/types.d.ts +4 -0
  30. package/dist/cli.d.ts +1 -1
  31. package/dist/cli.js +46 -37
  32. package/dist/cli.js.map +1 -1
  33. package/dist/contracts/command-contract.js +1 -0
  34. package/dist/contracts/command-contract.js.map +1 -1
  35. package/dist/doctor.js +1 -1
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/instances.d.ts +1 -1
  38. package/dist/instances.js +63 -38
  39. package/dist/instances.js.map +1 -1
  40. package/dist/output.d.ts +4 -4
  41. package/dist/output.js +3 -4
  42. package/dist/output.js.map +1 -1
  43. package/dist/package-manager.d.ts +47 -2
  44. package/dist/package-manager.js +267 -16
  45. package/dist/package-manager.js.map +1 -1
  46. package/dist/tools/.generated-schemas.js +2 -2
  47. package/dist/tools/.generated-schemas.js.map +1 -1
  48. package/dist/tools/commands.js +11 -4
  49. package/dist/tools/commands.js.map +1 -1
  50. package/dist/tools/dispatcher.js +1 -1
  51. package/dist/tools/dispatcher.js.map +1 -1
  52. package/dist/tools/tools/group-list.d.ts +4 -8
  53. package/dist/tools/tools/group-list.js +12 -13
  54. package/dist/tools/tools/group-list.js.map +1 -1
  55. package/dist/tools/tools/pagination.d.ts +17 -0
  56. package/dist/tools/tools/pagination.js +28 -0
  57. package/dist/tools/tools/pagination.js.map +1 -0
  58. package/dist/tools/tools/search.d.ts +3 -5
  59. package/dist/tools/tools/search.js +12 -12
  60. package/dist/tools/tools/search.js.map +1 -1
  61. package/dist/unity-project-processes.d.ts +7 -0
  62. package/dist/unity-project-processes.js +54 -0
  63. package/dist/unity-project-processes.js.map +1 -1
  64. package/docs/README.md +7 -4
  65. package/docs/architecture.md +103 -0
  66. package/docs/availability-differences.md +1 -1
  67. package/docs/command-execution.md +6 -1
  68. package/docs/development.md +91 -0
  69. package/docs/error-codes.md +21 -5
  70. package/docs/extensions.md +13 -13
  71. package/docs/target-and-instance.md +6 -4
  72. package/docs/tool-discovery.md +12 -10
  73. package/docs/tool-execution.md +3 -2
  74. package/package.json +10 -4
  75. package/schemas/init.schema.json +209 -0
  76. package/schemas/instances.schema.json +104 -0
  77. package/skills/unitycli/SKILL.md +18 -6
  78. package/skills/unitycli/manifest.json +2 -2
  79. package/skills/unitycli/references/custom-tools.md +3 -3
  80. package/skills/unitycli/references/operation-protocol.md +6 -4
  81. package/skills/unitycli/reports/output_quality_scorecard.md +1 -1
  82. package/skills/unitycli/reports/trust-report.md +1 -1
  83. package/unitypkg/Editor/Bridge/BridgeCommandRegistry.cs +1 -0
  84. package/unitypkg/Editor/Bridge/BridgeWorkQueue.cs +18 -6
  85. package/unitypkg/Editor/Infrastructure/CodeExecSupport.cs +8 -77
  86. package/unitypkg/Editor/Infrastructure/UnityCliInstanceCleanup.cs +295 -0
  87. package/unitypkg/Editor/Infrastructure/UnityCliInstanceRegistry.cs +166 -10
  88. package/unitypkg/Editor/Infrastructure/UnityCliOperationCoordinator.cs +1 -0
  89. package/unitypkg/Editor/UnityCliBridge.cs +5 -1
  90. package/unitypkg/Tests/Editor/UnityCliInstanceCleanupTests.cs +232 -0
  91. package/unitypkg/Tests/Editor/UnityCliInstancePublicationRetryTests.cs +143 -0
  92. package/unitypkg/UnityCli.Editor.asmdef +1 -5
  93. package/unitypkg/package.json +1 -1
  94. package/unitypkg/Editor/Roslyn/LICENSE.txt +0 -23
  95. package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.CSharp.dll +0 -0
  96. package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.dll +0 -0
  97. package/unitypkg/Editor/Roslyn/System.Collections.Immutable.dll +0 -0
  98. package/unitypkg/Editor/Roslyn/System.Reflection.Metadata.dll +0 -0
@@ -11,8 +11,6 @@ using System.Text;
11
11
  using System.Text.RegularExpressions;
12
12
  using System.Threading;
13
13
  using System.Threading.Tasks;
14
- using Microsoft.CodeAnalysis;
15
- using Microsoft.CodeAnalysis.CSharp;
16
14
  using UnityEditor;
17
15
  using UnityEditor.SceneManagement;
18
16
  using UnityEngine;
@@ -34,9 +32,6 @@ namespace UnityCli
34
32
  private const int MaxRoslynOutputBytes = 512 * 1024;
35
33
  private const int MaxRoslynDiagnostics = 100;
36
34
 
37
- // code.exec 编译核心开关:默认 in-process Roslyn(官方方案);置 false 回退到 csc.exe 子进程。
38
- private const bool CodeExecUseInProcessRoslyn = true;
39
-
40
35
  // 注册运行时代码执行命令。
41
36
  private static void RegisterCodeExecCommand(BridgeCommandRegistry registry)
42
37
  {
@@ -90,9 +85,7 @@ namespace UnityCli
90
85
  byte[] assemblyBytes;
91
86
  try
92
87
  {
93
- var compilation = CodeExecUseInProcessRoslyn
94
- ? CompileInProcess(source, references, request.StringArg("defines"), request, cancellationToken)
95
- : CompileWithCscFallback(source, references, request.StringArg("defines"), request, cancellationToken);
88
+ var compilation = CompileWithUnityCompiler(source, references, request.StringArg("defines"), request, cancellationToken);
96
89
  if (compilation.TimedOut)
97
90
  return CodeExecutionResult.Fail("E_EXECUTION_TIMEOUT", "Runtime Code compilation exceeded the immutable deadline.", new Dictionary<string, object>(), "Inspect the Editor state before submitting a new request.");
98
91
  if (!compilation.Success)
@@ -215,8 +208,8 @@ namespace UnityCli
215
208
  }
216
209
 
217
210
  /// <summary>
218
- /// 汇总 in-process Roslyn 所需引用;跟随官方 Editor 模式全包含 AppDomain 已加载程序集,
219
- /// 不排除 mscorlib/System*/netstandard,避免针对 mscorlib 编译的项目程序集缺核心库引用。
211
+ /// 汇总 Unity Editor 编译器所需引用;全包含 AppDomain 已加载程序集,
212
+ /// 不排除 mscorlib/System*/netstandard,避免项目程序集缺少核心库引用。
220
213
  /// </summary>
221
214
  private static IEnumerable<string> DefaultCodeReferences(string refsCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
222
215
  {
@@ -245,7 +238,7 @@ namespace UnityCli
245
238
  if (!references.Contains(reference)) references.Add(reference);
246
239
  }
247
240
 
248
- // in-process csc.exe 回退共用的编译结果;成功时携带程序集字节,失败时携带错误诊断。
241
+ // Unity Editor 编译器结果;成功时携带程序集字节,失败时携带结构化诊断。
249
242
  private sealed class CodeCompilationResult
250
243
  {
251
244
  public bool Success;
@@ -255,71 +248,9 @@ namespace UnityCli
255
248
  }
256
249
 
257
250
  /// <summary>
258
- /// in-process Roslyn(Microsoft.CodeAnalysis.CSharp)编译源码。
259
- /// 引用集全包含 AppDomain 已加载程序集,与官方 Editor 模式一致;
260
- /// 编译无法中途终止,只能在编译前后重验 immutable deadline。
261
- /// </summary>
262
- private static CodeCompilationResult CompileInProcess(string source, IEnumerable<string> references, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
263
- {
264
- var result = new CodeCompilationResult();
265
- try
266
- {
267
- EnsureCodePreEffectDeadline(request, cancellationToken);
268
- var parseOptions = new CSharpParseOptions(preprocessorSymbols: SplitCsv(definesCsv));
269
- var tree = CSharpSyntaxTree.ParseText(WrapCodeSource(source), parseOptions);
270
- var metadataReferences = new List<MetadataReference>();
271
- foreach (var reference in references)
272
- {
273
- if (string.IsNullOrEmpty(reference)) continue;
274
- try { metadataReferences.Add(MetadataReference.CreateFromFile(reference)); }
275
- catch { /* 跳过不可读引用,避免个别文件缺失中断整次编译 */ }
276
- }
277
- var compilation = CSharpCompilation.Create(
278
- "UnityCli.RuntimeCode",
279
- new[] { tree },
280
- metadataReferences,
281
- new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
282
- using (var ms = new MemoryStream())
283
- {
284
- var emitResult = compilation.Emit(ms);
285
- foreach (var diagnostic in emitResult.Diagnostics)
286
- {
287
- if (diagnostic.Severity != DiagnosticSeverity.Error) continue;
288
- if (result.Diagnostics.Count >= MaxRoslynDiagnostics) continue;
289
- var lineSpan = diagnostic.Location.GetLineSpan();
290
- result.Diagnostics.Add(new Dictionary<string, object>
291
- {
292
- { "severity", "error" }, { "id", BoundedText(diagnostic.Id, 64) },
293
- { "message", BoundedText(diagnostic.GetMessage(), 4096) },
294
- { "file", BoundedText(lineSpan.Path ?? string.Empty, 2048) },
295
- { "line", (long)(lineSpan.StartLinePosition.Line + 1) },
296
- { "column", (long)(lineSpan.StartLinePosition.Character + 1) },
297
- { "span", null }
298
- });
299
- }
300
- result.Success = emitResult.Success;
301
- if (emitResult.Success) result.AssemblyBytes = ms.ToArray();
302
- }
303
- // in-process 无法中途终止;编译完成后重验 deadline,拒绝超时结果。
304
- int remainingMs;
305
- if (cancellationToken.IsCancellationRequested || request == null || !request.TryDeadlineRemainingMs(out remainingMs))
306
- {
307
- result.TimedOut = true;
308
- result.Success = false;
309
- result.AssemblyBytes = null;
310
- }
311
- }
312
- catch (OperationCanceledException)
313
- {
314
- result.TimedOut = true;
315
- }
316
- return result;
317
- }
318
-
319
- /// <summary>
320
- /// csc.exe 子进程编译回退;保留原临时文件与子进程超时终止逻辑,仅在 in-process 编译不可用时切换回来。
251
+ /// 使用当前 Unity Editor 自带的 csc.exe 编译源码;子进程受请求 deadline 约束并可终止。
321
252
  /// </summary>
322
- private static CodeCompilationResult CompileWithCscFallback(string source, IEnumerable<string> references, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
253
+ private static CodeCompilationResult CompileWithUnityCompiler(string source, IEnumerable<string> references, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
323
254
  {
324
255
  var result = new CodeCompilationResult();
325
256
  var editorDataPath = EditorDataPath();
@@ -339,7 +270,7 @@ namespace UnityCli
339
270
  {
340
271
  File.WriteAllText(sourceFilePath, WrapCodeSource(source), new UTF8Encoding(true));
341
272
  WriteReferenceResponseFile(responseFilePath, references);
342
- var compilation = CompileWithRoslyn(editorDataPath, sourceFilePath, responseFilePath, assemblyFilePath, definesCsv, request, cancellationToken);
273
+ var compilation = RunUnityCompiler(editorDataPath, sourceFilePath, responseFilePath, assemblyFilePath, definesCsv, request, cancellationToken);
343
274
  if (compilation.TimedOut)
344
275
  {
345
276
  result.TimedOut = true;
@@ -394,7 +325,7 @@ namespace UnityCli
394
325
  /// <summary>
395
326
  /// 使用 Unity 内置 Roslyn 编译临时源码;进程最多运行 60 秒,输出仅用于诊断,成功程序集由调用方读取到内存。
396
327
  /// </summary>
397
- private static RoslynCompilation CompileWithRoslyn(string editorDataPath, string sourceFilePath, string responseFilePath, string assemblyFilePath, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
328
+ private static RoslynCompilation RunUnityCompiler(string editorDataPath, string sourceFilePath, string responseFilePath, string assemblyFilePath, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
398
329
  {
399
330
  var monoPath = Path.Combine(editorDataPath, "MonoBleedingEdge", "bin", "mono.exe");
400
331
  var cscPath = Path.Combine(editorDataPath, "MonoBleedingEdge", "lib", "mono", "4.5", "csc.exe");
@@ -0,0 +1,295 @@
1
+ #if UNITY_EDITOR
2
+ using System;
3
+ using System.Collections.Generic;
4
+ using System.Globalization;
5
+ using System.IO;
6
+ using System.Linq;
7
+ using System.Text;
8
+
9
+ namespace UnityCli
10
+ {
11
+ public static partial class UnityCliBridge
12
+ {
13
+ private const int InstanceCleanupMaxScanned = 2000;
14
+ private const int InstanceCleanupMaxDeleted = 20;
15
+ private const int InstanceCleanupRetainedOffline = 100;
16
+ private const int InstanceCleanupRetainedDamaged = 20;
17
+ private const int InstanceCleanupRetentionDays = 30;
18
+ private static bool instanceCleanupAttempted;
19
+ private static readonly UnityCliToolCompletionContract InstanceCleanupCompletion = new UnityCliToolCompletionContract(
20
+ "internal.instances.prune", "control-plane", "direct-terminal", "atomic", "bounded-cleanup-summary",
21
+ "writer-owned-registry-reinspection", "caller-responsibility", "E_EXECUTION_TIMEOUT", "caller-responsibility",
22
+ "atomic-typed-result-v1", "required", true);
23
+
24
+ private sealed class InstanceCleanupCandidate
25
+ {
26
+ public string Path;
27
+ public string InstanceId;
28
+ public DateTimeOffset LastActivity;
29
+ public bool Damaged;
30
+ }
31
+
32
+ private sealed class InstanceCleanupSummary
33
+ {
34
+ public int Scanned;
35
+ public int Offline;
36
+ public int Damaged;
37
+ public int Preserved;
38
+ public int Deleted;
39
+ public int Deferred;
40
+ public int Remaining;
41
+ public bool Truncated;
42
+ }
43
+
44
+ /// <summary>每个domain只尝试一次自动清理;其他Unity持有全局锁时直接让其负责。</summary>
45
+ private static void TryRunAutomaticInstanceCleanup()
46
+ {
47
+ if (instanceCleanupAttempted) return;
48
+ instanceCleanupAttempted = true;
49
+ try
50
+ {
51
+ InstanceCleanupSummary summary;
52
+ var acquired = TryExecuteInstanceCleanup(null, out summary);
53
+ if (!acquired)
54
+ {
55
+ WriteLog("instance_cleanup_deferred reason=global-lock");
56
+ return;
57
+ }
58
+ WriteLog("instance_cleanup_completed scanned=" + summary.Scanned.ToString(CultureInfo.InvariantCulture)
59
+ + " offline=" + summary.Offline.ToString(CultureInfo.InvariantCulture)
60
+ + " damaged=" + summary.Damaged.ToString(CultureInfo.InvariantCulture)
61
+ + " preserved=" + summary.Preserved.ToString(CultureInfo.InvariantCulture)
62
+ + " deleted=" + summary.Deleted.ToString(CultureInfo.InvariantCulture)
63
+ + " deferred=" + summary.Deferred.ToString(CultureInfo.InvariantCulture)
64
+ + " truncated=" + (summary.Truncated ? "true" : "false"));
65
+ }
66
+ catch (Exception exception)
67
+ {
68
+ WriteLog("instance_cleanup_failed kind=" + exception.GetType().Name);
69
+ }
70
+ }
71
+
72
+ /// <summary>跨Unity只允许一个清理owner;零等待失败由调用方延期或返回busy。</summary>
73
+ private static bool TryExecuteInstanceCleanup(Action beginEffect, out InstanceCleanupSummary summary)
74
+ {
75
+ InstanceCleanupSummary value = null;
76
+ var acquired = TryWithCurrentUserMutex("Local\\UnityCli.Registry.Cleanup", 0, delegate
77
+ {
78
+ if (beginEffect != null) beginEffect();
79
+ value = RunInstanceCleanup();
80
+ });
81
+ summary = value;
82
+ return acquired;
83
+ }
84
+
85
+ /// <summary>扫描和删除均有硬上限;活跃、不可确认、链接或锁竞争记录只保留。</summary>
86
+ private static InstanceCleanupSummary RunInstanceCleanup()
87
+ {
88
+ var summary = new InstanceCleanupSummary();
89
+ var directory = InstancesDir();
90
+ if (!Directory.Exists(directory)) return summary;
91
+ var root = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
92
+ var candidates = new List<InstanceCleanupCandidate>();
93
+ using (var enumerator = Directory.EnumerateFiles(root, "*.json", SearchOption.TopDirectoryOnly).GetEnumerator())
94
+ {
95
+ while (summary.Scanned < InstanceCleanupMaxScanned && enumerator.MoveNext())
96
+ {
97
+ var fullPath = Path.GetFullPath(enumerator.Current);
98
+ var parent = Path.GetDirectoryName(fullPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
99
+ var fileName = Path.GetFileName(fullPath);
100
+ string candidateInstanceId;
101
+ if (!string.Equals(parent, root, StringComparison.OrdinalIgnoreCase)
102
+ || !TryInstanceIdFromFileName(fileName, out candidateInstanceId)) continue;
103
+ summary.Scanned += 1;
104
+ if (string.Equals(candidateInstanceId, InstanceId(), StringComparison.Ordinal))
105
+ {
106
+ summary.Preserved += 1;
107
+ continue;
108
+ }
109
+ InstanceCleanupCandidate candidate;
110
+ if (!TryInspectCleanupCandidate(fullPath, candidateInstanceId, out candidate))
111
+ {
112
+ summary.Preserved += 1;
113
+ continue;
114
+ }
115
+ if (candidate.Damaged) summary.Damaged += 1;
116
+ else summary.Offline += 1;
117
+ candidates.Add(candidate);
118
+ }
119
+ if (enumerator.MoveNext()) summary.Truncated = true;
120
+ }
121
+
122
+ var cutoff = DateTimeOffset.UtcNow.AddDays(-InstanceCleanupRetentionDays);
123
+ var offline = candidates.Where(value => !value.Damaged).OrderBy(value => value.LastActivity).ThenBy(value => value.InstanceId, StringComparer.Ordinal).ToList();
124
+ var damaged = candidates.Where(value => value.Damaged).OrderBy(value => value.LastActivity).ThenBy(value => value.InstanceId, StringComparer.Ordinal).ToList();
125
+ var planned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
126
+ PlanCleanup(offline, InstanceCleanupRetainedOffline, cutoff, planned);
127
+ PlanCleanup(damaged, InstanceCleanupRetainedDamaged, cutoff, planned);
128
+ summary.Deferred += Math.Max(0, planned.Count - InstanceCleanupMaxDeleted);
129
+
130
+ foreach (var candidate in candidates.Where(value => planned.Contains(value.Path)).OrderBy(value => value.LastActivity).ThenBy(value => value.InstanceId, StringComparer.Ordinal).Take(InstanceCleanupMaxDeleted))
131
+ {
132
+ var deleted = false;
133
+ var locked = false;
134
+ try
135
+ {
136
+ locked = TryWithCurrentUserMutex("Local\\UnityCli.Registry." + candidate.InstanceId, 0, delegate
137
+ {
138
+ InstanceCleanupCandidate current;
139
+ if (!TryInspectCleanupCandidate(candidate.Path, candidate.InstanceId, out current)) return;
140
+ if (current.Damaged != candidate.Damaged || current.LastActivity > candidate.LastActivity) return;
141
+ File.Delete(candidate.Path);
142
+ deleted = true;
143
+ });
144
+ }
145
+ catch
146
+ {
147
+ deleted = false;
148
+ }
149
+ if (deleted) summary.Deleted += 1;
150
+ else if (!locked || File.Exists(candidate.Path)) summary.Deferred += 1;
151
+ }
152
+ summary.Remaining = Math.Max(0, summary.Scanned - summary.Deleted);
153
+ return summary;
154
+ }
155
+
156
+ private static void PlanCleanup(List<InstanceCleanupCandidate> ordered, int retained, DateTimeOffset cutoff, HashSet<string> planned)
157
+ {
158
+ var excess = Math.Max(0, ordered.Count - retained);
159
+ for (var index = 0; index < ordered.Count; index++)
160
+ {
161
+ if (index < excess || ordered[index].LastActivity <= cutoff) planned.Add(ordered[index].Path);
162
+ }
163
+ }
164
+
165
+ /// <summary>返回false表示活跃、不可确认、链接、占用或不安全路径,调用方必须保留。</summary>
166
+ private static bool TryInspectCleanupCandidate(string path, string expectedInstanceId, out InstanceCleanupCandidate candidate)
167
+ {
168
+ candidate = null;
169
+ try
170
+ {
171
+ var info = new FileInfo(path);
172
+ if (!info.Exists || (info.Attributes & FileAttributes.ReparsePoint) != 0) return false;
173
+ var fallbackActivity = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero);
174
+ if (info.Length > RegistryMaxBytes)
175
+ {
176
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
177
+ return true;
178
+ }
179
+
180
+ Dictionary<string, object> record;
181
+ try
182
+ {
183
+ record = UnityCliSerializer.Parse(File.ReadAllText(path, Encoding.UTF8)) as Dictionary<string, object>;
184
+ }
185
+ catch (FormatException)
186
+ {
187
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
188
+ return true;
189
+ }
190
+ if (record == null || ReadLong(record, "version") != 3L || ReadLong(record, "canonicalizationVersion") != 1L)
191
+ {
192
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
193
+ return true;
194
+ }
195
+ var identity = ReadDictionary(record, "identity");
196
+ var canonicalPath = ReadString(identity, "canonicalProjectPath");
197
+ var actualInstanceId = ReadString(identity, "instanceId");
198
+ if (string.IsNullOrEmpty(canonicalPath)
199
+ || !string.Equals(actualInstanceId, expectedInstanceId, StringComparison.Ordinal)
200
+ || !string.Equals("unity-" + Sha256Utf8(canonicalPath), expectedInstanceId, StringComparison.Ordinal))
201
+ {
202
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
203
+ return true;
204
+ }
205
+ object rawSessions;
206
+ var sessionValues = record.TryGetValue("sessions", out rawSessions) ? rawSessions as List<object> : null;
207
+ if (sessionValues == null)
208
+ {
209
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
210
+ return true;
211
+ }
212
+ var sessions = sessionValues.OfType<Dictionary<string, object>>().ToList();
213
+ if (sessions.Count != sessionValues.Count || sessions.Any(IsProcessClaimAliveOrUnverifiable)) return false;
214
+ DateTimeOffset publishedAt;
215
+ if (!DateTimeOffset.TryParse(ReadString(record, "publishedAt"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out publishedAt))
216
+ {
217
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
218
+ return true;
219
+ }
220
+ candidate = CleanupCandidate(path, expectedInstanceId, publishedAt, false);
221
+ return true;
222
+ }
223
+ catch (IOException) { return false; }
224
+ catch (UnauthorizedAccessException) { return false; }
225
+ catch { return false; }
226
+ }
227
+
228
+ private static InstanceCleanupCandidate CleanupCandidate(string path, string instanceId, DateTimeOffset lastActivity, bool damaged)
229
+ {
230
+ return new InstanceCleanupCandidate { Path = path, InstanceId = instanceId, LastActivity = lastActivity, Damaged = damaged };
231
+ }
232
+
233
+ /// <summary>私有Bridge命令让公开instances prune只触发Unity writer,不把删除权交给CLI。</summary>
234
+ private static void RegisterInstanceCleanupCommand(BridgeCommandRegistry registry)
235
+ {
236
+ registry.Register<UnityCli.Protocol.BridgeRequest>(
237
+ "internal.instances.prune",
238
+ "internal",
239
+ true,
240
+ 30000,
241
+ (request, cancellationToken) =>
242
+ {
243
+ cancellationToken.ThrowIfCancellationRequested();
244
+ InstanceCleanupSummary summary;
245
+ if (!TryExecuteInstanceCleanup(delegate { BeginDirectEffect(request.RequestId); }, out summary))
246
+ throw new BridgeCommandException("E_UNITY_BUSY", "Another Unity instance is cleaning historical instance records.", null, new[] { "Retry unitycli instances prune after the current cleanup finishes." }, true, 100);
247
+ var result = new Dictionary<string, object>
248
+ {
249
+ { "removed", (long)summary.Deleted },
250
+ { "remaining", (long)summary.Remaining },
251
+ { "deferred", (long)summary.Deferred },
252
+ { "scanned", (long)summary.Scanned },
253
+ { "truncated", summary.Truncated },
254
+ { "retention", new Dictionary<string, object>
255
+ {
256
+ { "offlineMax", (long)InstanceCleanupRetainedOffline },
257
+ { "damagedMax", (long)InstanceCleanupRetainedDamaged },
258
+ { "maxAgeDays", (long)InstanceCleanupRetentionDays },
259
+ { "scanLimit", (long)InstanceCleanupMaxScanned },
260
+ { "deleteLimit", (long)InstanceCleanupMaxDeleted }
261
+ }
262
+ }
263
+ };
264
+ RecordDirectCompletionEvidence(request.RequestId, "internal.instances.prune", InstanceCleanupCompletion, result);
265
+ return new System.Threading.Tasks.ValueTask<object>(result);
266
+ });
267
+ }
268
+
269
+ private static bool IsInstanceCleanupCommand(string command)
270
+ {
271
+ return string.Equals(command, "internal.instances.prune", StringComparison.Ordinal);
272
+ }
273
+
274
+ private static UnityCliToolCompletionContract InstanceCleanupCompletionContract()
275
+ {
276
+ return InstanceCleanupCompletion;
277
+ }
278
+
279
+ private static bool TryInstanceIdFromFileName(string fileName, out string instanceId)
280
+ {
281
+ instanceId = null;
282
+ if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) return false;
283
+ var value = fileName.Substring(0, fileName.Length - 5);
284
+ if (!value.StartsWith("unity-", StringComparison.Ordinal) || value.Length != 70) return false;
285
+ for (var index = 6; index < value.Length; index++)
286
+ {
287
+ var ch = value[index];
288
+ if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) return false;
289
+ }
290
+ instanceId = value;
291
+ return true;
292
+ }
293
+ }
294
+ }
295
+ #endif