Files
Genarrative/plugins/agc-unity-editor/dotnet/vendor/Host/BridgeClient.cs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

151 lines
7.8 KiB
C#

// Modified for AGC: deadline propagation and dispatch observation before the first request byte.
using System.Net.Sockets;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.CodeAnalysis.CSharp;
namespace DotCraft.Unity;
internal sealed record PreparedExecution(string ExecutionId, string AssemblyPath, string EntryType, string Generation);
internal static class BridgeClient
{
public static async Task<JsonObject> Call(
string connectionPath,
string command,
string? assembly = null,
JsonObject? args = null,
string? id = null,
string? expectedGeneration = null,
string? executionId = null,
string? entryType = null,
int? waitMs = null,
bool terminate = false,
string? clientId = null,
CancellationToken cancellationToken = default,
Action? onDispatch = null)
{
var connection = ReadConnection(connectionPath, expectedGeneration);
using var process = System.Diagnostics.Process.GetProcessById(connection["pid"]!.GetValue<int>());
if (process.HasExited || process.StartTime.ToUniversalTime() != connection["startUtc"]!.GetValue<DateTime>())
throw new InvalidOperationException("Target process changed.");
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromMilliseconds((waitMs ?? 0) + 15_000));
using var client = new TcpClient();
try
{
await client.ConnectAsync("127.0.0.1", connection["port"]!.GetValue<int>(), deadline.Token);
var request = new JsonObject {
["protocol"] = AttachProtocol.Version,
["clientId"] = clientId,
["hostPid"] = Environment.ProcessId,
["hostStartUtc"] = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime(),
["token"] = connection["token"]!.GetValue<string>(),
["generation"] = connection["generation"]!.GetValue<string>(),
["id"] = id ?? Guid.NewGuid().ToString("N"),
["command"] = command,
["assembly"] = assembly,
["args"] = args?.DeepClone(),
["executionId"] = executionId,
["entryType"] = entryType,
["waitMs"] = waitMs,
["terminate"] = terminate
};
var data = Encoding.UTF8.GetBytes(request.ToJsonString());
var stream = client.GetStream();
deadline.Token.ThrowIfCancellationRequested();
onDispatch?.Invoke();
await stream.WriteAsync(BitConverter.GetBytes(data.Length), deadline.Token);
await stream.WriteAsync(data, deadline.Token);
var header = new byte[4];
await stream.ReadExactlyAsync(header, deadline.Token);
int length = BitConverter.ToInt32(header);
if (length < 1 || length > 4 * 1024 * 1024) throw new InvalidDataException("Invalid response size.");
var response = new byte[length];
await stream.ReadExactlyAsync(response, deadline.Token);
var result = JsonNode.Parse(response)!.AsObject();
if (result["generation"]?.GetValue<string>() != connection["generation"]!.GetValue<string>())
throw new InvalidDataException("Unity script domain changed; reconnect before executing again.");
if (command == "metadata" && result["state"]?.GetValue<string>() == "completed"
&& !string.Equals(Path.GetFullPath(result["result"]!["project"]!.GetValue<string>()),
Path.GetFullPath(connection["project"]!.GetValue<string>()), StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Unity project identity changed.");
return result;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("Unity bridge request timed out.");
}
}
public static async Task<PreparedExecution> Prepare(string connectionPath, string code, string cacheRoot, AttachAttempt? attempt = null, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var metadata = await Call(connectionPath, "metadata", cancellationToken: cancellationToken);
if (metadata["state"]!.GetValue<string>() != "completed")
throw new InvalidOperationException(metadata.ToJsonString());
var bridge = metadata["result"]!["bridge"]!.GetValue<string>();
var references = metadata["result"]!["references"]!.AsArray().Select(n => n!.GetValue<string>())
.Where(p => !Path.GetFileName(p).StartsWith("Snippet_", StringComparison.Ordinal)
&& (!Path.GetFileName(p).StartsWith("Attach_", StringComparison.Ordinal)
|| string.Equals(p, bridge, StringComparison.OrdinalIgnoreCase)))
.ToArray();
var directory = Path.Combine(cacheRoot, "snippets");
Directory.CreateDirectory(directory);
var source = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".cs");
const string className = "Snippet";
const string generatedNamespace = "DotCraft.Unity.Execution.Generated";
File.WriteAllText(source, SnippetSourceBuilder.Build(
className, code, source, true, "DotCraft.Unity", generatedNamespace));
try
{
var generation = metadata["generation"]!.GetValue<string>();
cancellationToken.ThrowIfCancellationRequested();
var assembly = TargetCompiler.Compile(source, references, Path.Combine(cacheRoot, "cache"), "Snippet_", generation);
cancellationToken.ThrowIfCancellationRequested();
assembly = AttachStorage.ResolveFile(assembly, "snippet", attempt);
AttachStorage.ValidateMonoPath(assembly, "snippet");
return new PreparedExecution(
"unity_" + Guid.NewGuid().ToString("N"),
Path.GetFullPath(assembly),
generatedNamespace + "." + className,
generation);
}
finally { File.Delete(source); }
}
public static Task<JsonObject> Start(
string connectionPath,
PreparedExecution execution,
JsonObject? args,
CancellationToken cancellationToken = default,
Action? onDispatch = null) =>
Call(connectionPath, "execute_start", execution.AssemblyPath, args,
expectedGeneration: execution.Generation, executionId: execution.ExecutionId,
entryType: execution.EntryType,
cancellationToken: cancellationToken, onDispatch: onDispatch);
public static Task<JsonObject> Wait(
string connectionPath,
string executionId,
string generation,
int waitMs,
bool terminate,
CancellationToken cancellationToken = default) =>
Call(connectionPath, "execute_wait", expectedGeneration: generation, executionId: executionId,
waitMs: waitMs, terminate: terminate, cancellationToken: cancellationToken);
public static string ReadGeneration(string connectionPath) =>
ReadConnection(connectionPath, null)["generation"]!.GetValue<string>();
private static JsonObject ReadConnection(string connectionPath, string? expectedGeneration)
{
var connection = JsonNode.Parse(File.ReadAllText(connectionPath))!.AsObject();
if (connection["protocol"]?.GetValue<int>() != AttachProtocol.Version) throw new InvalidDataException("Unsupported Unity bridge protocol.");
if (expectedGeneration != null && connection["generation"]?.GetValue<string>() != expectedGeneration)
throw new InvalidDataException("Unity script domain changed during execution.");
return connection;
}
}