@atelierai/uco 1.0.9 → 1.0.10
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.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +16 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/BufferedFileLogStorage.cs +208 -207
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +3 -1
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/UnityLogCollector.cs +282 -280
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +1 -1
- package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.10 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
- Vendored plugin refreshed to **1.0.8** — fixes an editor-native crash on
|
|
6
|
+
domain reload (UmaViewer issue uco-domain-reload-crash-20260919, 2/2
|
|
7
|
+
reproducible during script recompile). The plugin no longer defines a
|
|
8
|
+
finalizer: the historical one ran the full teardown (token cancellation
|
|
9
|
+
with synchronous Task continuations and ExecutionContext restores) on the
|
|
10
|
+
GC finalizer thread during domain unload, which mono cannot execute.
|
|
11
|
+
Assembly-reload/unload/quit cleanup now disposes the plugin instance
|
|
12
|
+
deterministically on the safe background-thread path. Ten other
|
|
13
|
+
same-pattern finalizers (request DTOs, log storage/collector) removed with
|
|
14
|
+
it. Framework tests 831x2; 2022.3 gate unchanged.
|
|
15
|
+
|
|
16
|
+
|
|
3
17
|
## 1.0.9 - 2026-09-19
|
|
4
18
|
|
|
5
19
|
- Vendored plugin refreshed to **1.0.7**: the complete legacy-naming sweep —
|
package/package.json
CHANGED
|
@@ -84,6 +84,22 @@ namespace com.AtelierAI.Unity.Copilot.Editor
|
|
|
84
84
|
nameof(Startup), callerName, e.Message);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
// Deterministic teardown on reload/unload/quit: dispose the plugin
|
|
89
|
+
// instance on a background thread (the safe context for the token
|
|
90
|
+
// cancellation callbacks — same reasoning as
|
|
91
|
+
// DisposeUcoPluginInstance). Without this, the instance survived
|
|
92
|
+
// domain unload undisposed and relied on GC finalization — the
|
|
93
|
+
// crash path fixed in uco-domain-reload-crash-20260919.
|
|
94
|
+
try
|
|
95
|
+
{
|
|
96
|
+
plugin.DisposeUcoPluginInstance();
|
|
97
|
+
}
|
|
98
|
+
catch (System.Exception e)
|
|
99
|
+
{
|
|
100
|
+
_logger.LogWarning(e, "{class} {method}: Exception during plugin disposal (non-blocking): {message}",
|
|
101
|
+
nameof(Startup), callerName, e.Message);
|
|
102
|
+
}
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
try
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/BufferedFileLogStorage.cs
CHANGED
|
@@ -1,207 +1,208 @@
|
|
|
1
|
-
/*
|
|
2
|
-
┌──────────────────────────────────────────────────────────────────┐
|
|
3
|
-
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
|
|
4
|
-
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
|
|
5
|
-
│ Copyright (c) 2025 Ivan Murzak │
|
|
6
|
-
│ Licensed under the Apache License, Version 2.0. │
|
|
7
|
-
│ See the LICENSE file in the project root for more information. │
|
|
8
|
-
└──────────────────────────────────────────────────────────────────┘
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
#nullable enable
|
|
12
|
-
using System;
|
|
13
|
-
using System.Collections.Generic;
|
|
14
|
-
using System.IO;
|
|
15
|
-
using System.Linq;
|
|
16
|
-
using System.Text.Json;
|
|
17
|
-
using System.Threading.Tasks;
|
|
18
|
-
using Microsoft.Extensions.Logging;
|
|
19
|
-
using UnityEngine;
|
|
20
|
-
|
|
21
|
-
namespace com.AtelierAI.Unity.Copilot
|
|
22
|
-
{
|
|
23
|
-
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
|
24
|
-
public class BufferedFileLogStorage : FileLogStorage
|
|
25
|
-
{
|
|
26
|
-
protected readonly int _flushEntriesThreshold;
|
|
27
|
-
protected readonly LogEntry[] _logEntriesBuffer;
|
|
28
|
-
protected int _logEntriesBufferLength;
|
|
29
|
-
|
|
30
|
-
public BufferedFileLogStorage(
|
|
31
|
-
ILogger? logger = null,
|
|
32
|
-
int flushEntriesThreshold = 100,
|
|
33
|
-
string? cacheFilePath = null,
|
|
34
|
-
string? cacheFileName = null,
|
|
35
|
-
int fileBufferSize = 4096,
|
|
36
|
-
int maxFileSizeMB = DefaultMaxFileSizeMB,
|
|
37
|
-
JsonSerializerOptions? jsonOptions = null)
|
|
38
|
-
: base(logger, cacheFilePath, cacheFileName, fileBufferSize, maxFileSizeMB, jsonOptions)
|
|
39
|
-
{
|
|
40
|
-
if (flushEntriesThreshold <= 0)
|
|
41
|
-
throw new ArgumentOutOfRangeException(nameof(flushEntriesThreshold), "Flush entries threshold must be greater than zero.");
|
|
42
|
-
|
|
43
|
-
_flushEntriesThreshold = flushEntriesThreshold;
|
|
44
|
-
_logEntriesBuffer = new LogEntry[flushEntriesThreshold];
|
|
45
|
-
_logEntriesBufferLength = 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
public override void Flush()
|
|
49
|
-
{
|
|
50
|
-
if (_isDisposed.Value)
|
|
51
|
-
{
|
|
52
|
-
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
53
|
-
nameof(Flush));
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
lock (_fileMutex)
|
|
57
|
-
{
|
|
58
|
-
// Flush buffered entries to file
|
|
59
|
-
if (_logEntriesBufferLength > 0)
|
|
60
|
-
{
|
|
61
|
-
var entriesToFlush = new LogEntry[_logEntriesBufferLength];
|
|
62
|
-
Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
|
|
63
|
-
base.AppendInternal(entriesToFlush);
|
|
64
|
-
_logEntriesBufferLength = 0;
|
|
65
|
-
}
|
|
66
|
-
fileWriteStream?.Flush();
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
public override Task FlushAsync()
|
|
70
|
-
{
|
|
71
|
-
if (_isDisposed.Value)
|
|
72
|
-
{
|
|
73
|
-
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
74
|
-
nameof(FlushAsync));
|
|
75
|
-
return Task.CompletedTask;
|
|
76
|
-
}
|
|
77
|
-
lock (_fileMutex)
|
|
78
|
-
{
|
|
79
|
-
// Flush buffered entries to file
|
|
80
|
-
if (_logEntriesBufferLength > 0)
|
|
81
|
-
{
|
|
82
|
-
var entriesToFlush = new LogEntry[_logEntriesBufferLength];
|
|
83
|
-
Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
|
|
84
|
-
base.AppendInternal(entriesToFlush);
|
|
85
|
-
_logEntriesBufferLength = 0;
|
|
86
|
-
}
|
|
87
|
-
fileWriteStream?.Flush();
|
|
88
|
-
}
|
|
89
|
-
return Task.CompletedTask;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
protected override void AppendInternal(params LogEntry[] entries)
|
|
93
|
-
{
|
|
94
|
-
if (_isDisposed.Value)
|
|
95
|
-
{
|
|
96
|
-
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
97
|
-
nameof(AppendInternal));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
if (_logEntriesBufferLength >= _flushEntriesThreshold)
|
|
101
|
-
{
|
|
102
|
-
base.AppendInternal(_logEntriesBuffer);
|
|
103
|
-
_logEntriesBufferLength = 0;
|
|
104
|
-
}
|
|
105
|
-
foreach (var entry in entries)
|
|
106
|
-
{
|
|
107
|
-
_logEntriesBuffer[_logEntriesBufferLength] = entry;
|
|
108
|
-
_logEntriesBufferLength++;
|
|
109
|
-
|
|
110
|
-
if (_logEntriesBufferLength >= _flushEntriesThreshold)
|
|
111
|
-
{
|
|
112
|
-
base.AppendInternal(_logEntriesBuffer);
|
|
113
|
-
_logEntriesBufferLength = 0;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/// <summary>
|
|
119
|
-
/// Closes and disposes the current file stream if open. Clears the log cache file.
|
|
120
|
-
/// </summary>
|
|
121
|
-
public override LogClearResult Clear()
|
|
122
|
-
{
|
|
123
|
-
if (_isDisposed.Value)
|
|
124
|
-
{
|
|
125
|
-
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
126
|
-
nameof(Clear));
|
|
127
|
-
return new LogClearResult
|
|
128
|
-
{
|
|
129
|
-
Ok = false,
|
|
130
|
-
Strategy = "storage-disposed",
|
|
131
|
-
Path = filePath,
|
|
132
|
-
Error = "The log storage is disposed."
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
lock (_fileMutex)
|
|
136
|
-
{
|
|
137
|
-
_logEntriesBufferLength = 0;
|
|
138
|
-
return ClearFileLocked();
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
public override LogEntry[] Query(
|
|
143
|
-
int maxEntries = 100,
|
|
144
|
-
LogType? logTypeFilter = null,
|
|
145
|
-
bool includeStackTrace = false,
|
|
146
|
-
int lastMinutes = 0)
|
|
147
|
-
{
|
|
148
|
-
if (_isDisposed.Value)
|
|
149
|
-
{
|
|
150
|
-
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
151
|
-
nameof(Query));
|
|
152
|
-
return Array.Empty<LogEntry>();
|
|
153
|
-
}
|
|
154
|
-
lock (_fileMutex)
|
|
155
|
-
{
|
|
156
|
-
return QueryInternal(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
protected override LogEntry[] QueryInternal(
|
|
161
|
-
int maxEntries = 100,
|
|
162
|
-
LogType? logTypeFilter = null,
|
|
163
|
-
bool includeStackTrace = false,
|
|
164
|
-
int lastMinutes = 0)
|
|
165
|
-
{
|
|
166
|
-
var result = new List<LogEntry>();
|
|
167
|
-
var cutoffTime = lastMinutes > 0
|
|
168
|
-
? System.DateTime.Now.AddMinutes(-lastMinutes)
|
|
169
|
-
: System.DateTime.MinValue;
|
|
170
|
-
|
|
171
|
-
// 1. Get from buffer (Newest are at the end of buffer)
|
|
172
|
-
for (int i = _logEntriesBufferLength - 1; i >= 0; i--)
|
|
173
|
-
{
|
|
174
|
-
var entry = _logEntriesBuffer[i];
|
|
175
|
-
if (logTypeFilter.HasValue && entry.LogType != logTypeFilter.Value)
|
|
176
|
-
continue;
|
|
177
|
-
|
|
178
|
-
if (lastMinutes > 0)
|
|
179
|
-
{
|
|
180
|
-
if (entry.Timestamp < cutoffTime)
|
|
181
|
-
{
|
|
182
|
-
return result.AsEnumerable().Reverse().ToArray();
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
result.Add(entry);
|
|
187
|
-
if (result.Count >= maxEntries)
|
|
188
|
-
return result.AsEnumerable().Reverse().ToArray();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// 2. Exit if we already have enough entries
|
|
192
|
-
var neededLogsCount = maxEntries - result.Count;
|
|
193
|
-
if (neededLogsCount <= 0)
|
|
194
|
-
return result.AsEnumerable().Reverse().ToArray();
|
|
195
|
-
|
|
196
|
-
result.Reverse();
|
|
197
|
-
|
|
198
|
-
// 3. Get from file
|
|
199
|
-
var fileEntries = base.QueryInternal(neededLogsCount, logTypeFilter, includeStackTrace, lastMinutes);
|
|
200
|
-
result.AddRange(fileEntries);
|
|
201
|
-
|
|
202
|
-
return result.ToArray();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
3
|
+
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
|
|
4
|
+
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
|
|
5
|
+
│ Copyright (c) 2025 Ivan Murzak │
|
|
6
|
+
│ Licensed under the Apache License, Version 2.0. │
|
|
7
|
+
│ See the LICENSE file in the project root for more information. │
|
|
8
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
#nullable enable
|
|
12
|
+
using System;
|
|
13
|
+
using System.Collections.Generic;
|
|
14
|
+
using System.IO;
|
|
15
|
+
using System.Linq;
|
|
16
|
+
using System.Text.Json;
|
|
17
|
+
using System.Threading.Tasks;
|
|
18
|
+
using Microsoft.Extensions.Logging;
|
|
19
|
+
using UnityEngine;
|
|
20
|
+
|
|
21
|
+
namespace com.AtelierAI.Unity.Copilot
|
|
22
|
+
{
|
|
23
|
+
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
|
24
|
+
public class BufferedFileLogStorage : FileLogStorage
|
|
25
|
+
{
|
|
26
|
+
protected readonly int _flushEntriesThreshold;
|
|
27
|
+
protected readonly LogEntry[] _logEntriesBuffer;
|
|
28
|
+
protected int _logEntriesBufferLength;
|
|
29
|
+
|
|
30
|
+
public BufferedFileLogStorage(
|
|
31
|
+
ILogger? logger = null,
|
|
32
|
+
int flushEntriesThreshold = 100,
|
|
33
|
+
string? cacheFilePath = null,
|
|
34
|
+
string? cacheFileName = null,
|
|
35
|
+
int fileBufferSize = 4096,
|
|
36
|
+
int maxFileSizeMB = DefaultMaxFileSizeMB,
|
|
37
|
+
JsonSerializerOptions? jsonOptions = null)
|
|
38
|
+
: base(logger, cacheFilePath, cacheFileName, fileBufferSize, maxFileSizeMB, jsonOptions)
|
|
39
|
+
{
|
|
40
|
+
if (flushEntriesThreshold <= 0)
|
|
41
|
+
throw new ArgumentOutOfRangeException(nameof(flushEntriesThreshold), "Flush entries threshold must be greater than zero.");
|
|
42
|
+
|
|
43
|
+
_flushEntriesThreshold = flushEntriesThreshold;
|
|
44
|
+
_logEntriesBuffer = new LogEntry[flushEntriesThreshold];
|
|
45
|
+
_logEntriesBufferLength = 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public override void Flush()
|
|
49
|
+
{
|
|
50
|
+
if (_isDisposed.Value)
|
|
51
|
+
{
|
|
52
|
+
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
53
|
+
nameof(Flush));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
lock (_fileMutex)
|
|
57
|
+
{
|
|
58
|
+
// Flush buffered entries to file
|
|
59
|
+
if (_logEntriesBufferLength > 0)
|
|
60
|
+
{
|
|
61
|
+
var entriesToFlush = new LogEntry[_logEntriesBufferLength];
|
|
62
|
+
Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
|
|
63
|
+
base.AppendInternal(entriesToFlush);
|
|
64
|
+
_logEntriesBufferLength = 0;
|
|
65
|
+
}
|
|
66
|
+
fileWriteStream?.Flush();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
public override Task FlushAsync()
|
|
70
|
+
{
|
|
71
|
+
if (_isDisposed.Value)
|
|
72
|
+
{
|
|
73
|
+
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
74
|
+
nameof(FlushAsync));
|
|
75
|
+
return Task.CompletedTask;
|
|
76
|
+
}
|
|
77
|
+
lock (_fileMutex)
|
|
78
|
+
{
|
|
79
|
+
// Flush buffered entries to file
|
|
80
|
+
if (_logEntriesBufferLength > 0)
|
|
81
|
+
{
|
|
82
|
+
var entriesToFlush = new LogEntry[_logEntriesBufferLength];
|
|
83
|
+
Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
|
|
84
|
+
base.AppendInternal(entriesToFlush);
|
|
85
|
+
_logEntriesBufferLength = 0;
|
|
86
|
+
}
|
|
87
|
+
fileWriteStream?.Flush();
|
|
88
|
+
}
|
|
89
|
+
return Task.CompletedTask;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
protected override void AppendInternal(params LogEntry[] entries)
|
|
93
|
+
{
|
|
94
|
+
if (_isDisposed.Value)
|
|
95
|
+
{
|
|
96
|
+
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
97
|
+
nameof(AppendInternal));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (_logEntriesBufferLength >= _flushEntriesThreshold)
|
|
101
|
+
{
|
|
102
|
+
base.AppendInternal(_logEntriesBuffer);
|
|
103
|
+
_logEntriesBufferLength = 0;
|
|
104
|
+
}
|
|
105
|
+
foreach (var entry in entries)
|
|
106
|
+
{
|
|
107
|
+
_logEntriesBuffer[_logEntriesBufferLength] = entry;
|
|
108
|
+
_logEntriesBufferLength++;
|
|
109
|
+
|
|
110
|
+
if (_logEntriesBufferLength >= _flushEntriesThreshold)
|
|
111
|
+
{
|
|
112
|
+
base.AppendInternal(_logEntriesBuffer);
|
|
113
|
+
_logEntriesBufferLength = 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// <summary>
|
|
119
|
+
/// Closes and disposes the current file stream if open. Clears the log cache file.
|
|
120
|
+
/// </summary>
|
|
121
|
+
public override LogClearResult Clear()
|
|
122
|
+
{
|
|
123
|
+
if (_isDisposed.Value)
|
|
124
|
+
{
|
|
125
|
+
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
126
|
+
nameof(Clear));
|
|
127
|
+
return new LogClearResult
|
|
128
|
+
{
|
|
129
|
+
Ok = false,
|
|
130
|
+
Strategy = "storage-disposed",
|
|
131
|
+
Path = filePath,
|
|
132
|
+
Error = "The log storage is disposed."
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
lock (_fileMutex)
|
|
136
|
+
{
|
|
137
|
+
_logEntriesBufferLength = 0;
|
|
138
|
+
return ClearFileLocked();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public override LogEntry[] Query(
|
|
143
|
+
int maxEntries = 100,
|
|
144
|
+
LogType? logTypeFilter = null,
|
|
145
|
+
bool includeStackTrace = false,
|
|
146
|
+
int lastMinutes = 0)
|
|
147
|
+
{
|
|
148
|
+
if (_isDisposed.Value)
|
|
149
|
+
{
|
|
150
|
+
_logger.LogWarning("{method} called but already disposed, ignored.",
|
|
151
|
+
nameof(Query));
|
|
152
|
+
return Array.Empty<LogEntry>();
|
|
153
|
+
}
|
|
154
|
+
lock (_fileMutex)
|
|
155
|
+
{
|
|
156
|
+
return QueryInternal(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
protected override LogEntry[] QueryInternal(
|
|
161
|
+
int maxEntries = 100,
|
|
162
|
+
LogType? logTypeFilter = null,
|
|
163
|
+
bool includeStackTrace = false,
|
|
164
|
+
int lastMinutes = 0)
|
|
165
|
+
{
|
|
166
|
+
var result = new List<LogEntry>();
|
|
167
|
+
var cutoffTime = lastMinutes > 0
|
|
168
|
+
? System.DateTime.Now.AddMinutes(-lastMinutes)
|
|
169
|
+
: System.DateTime.MinValue;
|
|
170
|
+
|
|
171
|
+
// 1. Get from buffer (Newest are at the end of buffer)
|
|
172
|
+
for (int i = _logEntriesBufferLength - 1; i >= 0; i--)
|
|
173
|
+
{
|
|
174
|
+
var entry = _logEntriesBuffer[i];
|
|
175
|
+
if (logTypeFilter.HasValue && entry.LogType != logTypeFilter.Value)
|
|
176
|
+
continue;
|
|
177
|
+
|
|
178
|
+
if (lastMinutes > 0)
|
|
179
|
+
{
|
|
180
|
+
if (entry.Timestamp < cutoffTime)
|
|
181
|
+
{
|
|
182
|
+
return result.AsEnumerable().Reverse().ToArray();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
result.Add(entry);
|
|
187
|
+
if (result.Count >= maxEntries)
|
|
188
|
+
return result.AsEnumerable().Reverse().ToArray();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2. Exit if we already have enough entries
|
|
192
|
+
var neededLogsCount = maxEntries - result.Count;
|
|
193
|
+
if (neededLogsCount <= 0)
|
|
194
|
+
return result.AsEnumerable().Reverse().ToArray();
|
|
195
|
+
|
|
196
|
+
result.Reverse();
|
|
197
|
+
|
|
198
|
+
// 3. Get from file
|
|
199
|
+
var fileEntries = base.QueryInternal(neededLogsCount, logTypeFilter, includeStackTrace, lastMinutes);
|
|
200
|
+
result.AddRange(fileEntries);
|
|
201
|
+
|
|
202
|
+
return result.ToArray();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// No finalizer: Dispose is plain managed cleanup; running arbitrary code on
|
|
206
|
+
// the finalizer thread during domain unload is never safe.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -542,6 +542,8 @@ namespace com.AtelierAI.Unity.Copilot
|
|
|
542
542
|
GC.SuppressFinalize(this);
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
|
|
545
|
+
// No finalizer: Dispose performs file I/O / Unity static-event unsubscription,
|
|
546
|
+
// which must never run on the finalizer thread during domain unload
|
|
547
|
+
// (same crash class as uco-domain-reload-crash-20260919).
|
|
546
548
|
}
|
|
547
549
|
}
|
|
@@ -1,280 +1,282 @@
|
|
|
1
|
-
/*
|
|
2
|
-
┌──────────────────────────────────────────────────────────────────┐
|
|
3
|
-
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
|
|
4
|
-
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
|
|
5
|
-
│ Copyright (c) 2025 Ivan Murzak │
|
|
6
|
-
│ Licensed under the Apache License, Version 2.0. │
|
|
7
|
-
│ See the LICENSE file in the project root for more information. │
|
|
8
|
-
└──────────────────────────────────────────────────────────────────┘
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
#nullable enable
|
|
12
|
-
using System;
|
|
13
|
-
using System.Text.RegularExpressions;
|
|
14
|
-
using System.Threading;
|
|
15
|
-
using System.Threading.Tasks;
|
|
16
|
-
using com.AtelierAI.Unity.Copilot.Runtime.Utils;
|
|
17
|
-
using com.AtelierAI.Unity.Copilot.Utils;
|
|
18
|
-
using com.AtelierAI.Uco.Framework.Common;
|
|
19
|
-
using com.IvanMurzak.ReflectorNet;
|
|
20
|
-
using com.IvanMurzak.ReflectorNet.Utils;
|
|
21
|
-
using UnityEngine;
|
|
22
|
-
|
|
23
|
-
namespace com.AtelierAI.Unity.Copilot
|
|
24
|
-
{
|
|
25
|
-
/// <summary>
|
|
26
|
-
/// Result of a detailed console query: the filtered entries plus the
|
|
27
|
-
/// collector's loss accounting so "no errors" is distinguishable from
|
|
28
|
-
/// "errors were dropped" (COCli-07).
|
|
29
|
-
/// </summary>
|
|
30
|
-
public class ConsoleLogsQueryResult
|
|
31
|
-
{
|
|
32
|
-
public LogEntry[] Entries { get; set; } = Array.Empty<LogEntry>();
|
|
33
|
-
public int DroppedEntries { get; set; }
|
|
34
|
-
public int TruncatedEntries { get; set; }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/// <summary>
|
|
38
|
-
/// Collects Unity log messages and manages saving/loading them to/from a cache file.
|
|
39
|
-
/// </summary>
|
|
40
|
-
public class UnityLogCollector : IDisposable
|
|
41
|
-
{
|
|
42
|
-
private static readonly Regex RichTextRegex = new Regex(@"</?(b|i|size|color|material|quad|a)\b[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
43
|
-
|
|
44
|
-
const int MaxMessageCharacters = 16 * 1024;
|
|
45
|
-
|
|
46
|
-
readonly ILogStorage _logStorage;
|
|
47
|
-
readonly ThreadSafeBool _isDisposed = new(false);
|
|
48
|
-
// This collector's channel sink and the sink it displaced, so Dispose
|
|
49
|
-
// can restore the previous channel owner instead of clearing it.
|
|
50
|
-
readonly Action<PluginDiagnosticEntry> _channelSink;
|
|
51
|
-
Action<PluginDiagnosticEntry>? _previousChannelSink;
|
|
52
|
-
int _droppedEntries;
|
|
53
|
-
int _truncatedEntries;
|
|
54
|
-
|
|
55
|
-
public UnityLogCollector(ILogStorage logStorage)
|
|
56
|
-
{
|
|
57
|
-
if (!MainThread.Instance.IsMainThread)
|
|
58
|
-
throw new Exception($"{GetType().GetTypeShortName()} constructor must be initialized on the main thread.");
|
|
59
|
-
|
|
60
|
-
_logStorage = logStorage ?? throw new ArgumentNullException(nameof(logStorage));
|
|
61
|
-
|
|
62
|
-
_channelSink = AppendPluginDiagnostic;
|
|
63
|
-
_previousChannelSink = PluginDiagnostics.CurrentSink;
|
|
64
|
-
Application.logMessageReceivedThreaded += OnLogMessageReceived;
|
|
65
|
-
PluginDiagnostics.Install(_channelSink);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
public LogClearResult Clear()
|
|
69
|
-
{
|
|
70
|
-
if (_isDisposed.Value)
|
|
71
|
-
return new LogClearResult
|
|
72
|
-
{
|
|
73
|
-
Ok = false,
|
|
74
|
-
Strategy = "collector-disposed",
|
|
75
|
-
Error = "The Unity log collector is disposed."
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
return _logStorage.Clear();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/// <summary>
|
|
82
|
-
/// Synchronously saves all current log entries to the cache file.
|
|
83
|
-
/// </summary>
|
|
84
|
-
public void Save()
|
|
85
|
-
{
|
|
86
|
-
if (_isDisposed.Value)
|
|
87
|
-
return;
|
|
88
|
-
|
|
89
|
-
_logStorage.Flush();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/// <summary>
|
|
93
|
-
/// Asynchronously saves all current log entries to the cache file.
|
|
94
|
-
/// </summary>
|
|
95
|
-
/// <returns>A task that completes when the save operation is finished.</returns>
|
|
96
|
-
public Task SaveAsync()
|
|
97
|
-
{
|
|
98
|
-
if (_isDisposed.Value)
|
|
99
|
-
return Task.CompletedTask;
|
|
100
|
-
|
|
101
|
-
return _logStorage.FlushAsync();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
public Task<LogEntry[]> QueryAsync(
|
|
105
|
-
int maxEntries = 100,
|
|
106
|
-
LogType? logTypeFilter = null,
|
|
107
|
-
bool includeStackTrace = false,
|
|
108
|
-
int lastMinutes = 0)
|
|
109
|
-
{
|
|
110
|
-
return _logStorage.QueryAsync(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
public LogEntry[] Query(
|
|
114
|
-
int maxEntries = 100,
|
|
115
|
-
LogType? logTypeFilter = null,
|
|
116
|
-
bool includeStackTrace = false,
|
|
117
|
-
int lastMinutes = 0)
|
|
118
|
-
{
|
|
119
|
-
return _logStorage.Query(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/// <summary>
|
|
123
|
-
/// Detailed query with operation/correlation/source/time-boundary
|
|
124
|
-
/// filters and loss accounting (COCli-07). Existing filter names and
|
|
125
|
-
/// semantics are unchanged; the new filters compose with them.
|
|
126
|
-
/// </summary>
|
|
127
|
-
public ConsoleLogsQueryResult QueryDetailed(
|
|
128
|
-
int maxEntries = 100,
|
|
129
|
-
LogType? logTypeFilter = null,
|
|
130
|
-
bool includeStackTrace = false,
|
|
131
|
-
int lastMinutes = 0,
|
|
132
|
-
string? correlationId = null,
|
|
133
|
-
string? operationId = null,
|
|
134
|
-
string? source = null,
|
|
135
|
-
long? sinceUnixMs = null)
|
|
136
|
-
{
|
|
137
|
-
if (maxEntries < 1)
|
|
138
|
-
throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
|
139
|
-
if (!string.IsNullOrEmpty(source)
|
|
140
|
-
&& source != LogEntry.SourceProduct
|
|
141
|
-
&& source != LogEntry.SourceBridge
|
|
142
|
-
&& source != LogEntry.SourceTool
|
|
143
|
-
&& source != LogEntry.SourceUnity)
|
|
144
|
-
{
|
|
145
|
-
throw new ArgumentException(
|
|
146
|
-
$"source must be one of: {LogEntry.SourceProduct}, {LogEntry.SourceBridge}, {LogEntry.SourceTool}, {LogEntry.SourceUnity}.");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Oversubscribe the storage query so the id/source filters below
|
|
150
|
-
// can still fill maxEntries from a wider recent window.
|
|
151
|
-
var fetchLimit = string.IsNullOrEmpty(correlationId) && string.IsNullOrEmpty(operationId)
|
|
152
|
-
? maxEntries
|
|
153
|
-
: Math.Min(2_000, maxEntries * 10);
|
|
154
|
-
var candidates = _logStorage.Query(
|
|
155
|
-
fetchLimit, logTypeFilter, includeStackTrace, lastMinutes);
|
|
156
|
-
|
|
157
|
-
var results = new System.Collections.Generic.List<LogEntry>(candidates.Length);
|
|
158
|
-
var boundary = sinceUnixMs.HasValue
|
|
159
|
-
? DateTimeOffset.FromUnixTimeMilliseconds(sinceUnixMs.Value).LocalDateTime
|
|
160
|
-
: (DateTime?)null;
|
|
161
|
-
// Storage returns newest-first; keep the newest maxEntries after
|
|
162
|
-
// filtering, then present them oldest-first for readability.
|
|
163
|
-
for (var i = 0; i < candidates.Length && results.Count < maxEntries; i++)
|
|
164
|
-
{
|
|
165
|
-
var entry = candidates[i];
|
|
166
|
-
if (!string.IsNullOrEmpty(correlationId)
|
|
167
|
-
&& !string.Equals(entry.CorrelationId, correlationId, StringComparison.Ordinal))
|
|
168
|
-
continue;
|
|
169
|
-
if (!string.IsNullOrEmpty(operationId)
|
|
170
|
-
&& !string.Equals(entry.OperationId, operationId, StringComparison.Ordinal))
|
|
171
|
-
continue;
|
|
172
|
-
if (!string.IsNullOrEmpty(source)
|
|
173
|
-
&& !string.Equals(entry.Source, source, StringComparison.Ordinal))
|
|
174
|
-
continue;
|
|
175
|
-
if (boundary.HasValue && entry.Timestamp < boundary.Value)
|
|
176
|
-
continue;
|
|
177
|
-
results.Add(entry);
|
|
178
|
-
}
|
|
179
|
-
results.Reverse();
|
|
180
|
-
|
|
181
|
-
return new ConsoleLogsQueryResult
|
|
182
|
-
{
|
|
183
|
-
Entries = results.ToArray(),
|
|
184
|
-
DroppedEntries = Volatile.Read(ref _droppedEntries) + PluginDiagnostics.DroppedEntries,
|
|
185
|
-
TruncatedEntries = Volatile.Read(ref _truncatedEntries),
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/// <summary>Sink for the plugin/bridge/tool diagnostics channel.</summary>
|
|
190
|
-
void AppendPluginDiagnostic(PluginDiagnosticEntry entry)
|
|
191
|
-
{
|
|
192
|
-
if (_isDisposed.Value || entry == null) return;
|
|
193
|
-
try
|
|
194
|
-
{
|
|
195
|
-
_logStorage.Append(new LogEntry(
|
|
196
|
-
logType: entry.LogType,
|
|
197
|
-
message: BoundMessage(entry.Message, out var truncated),
|
|
198
|
-
source: entry.Source,
|
|
199
|
-
correlationId: entry.CorrelationId,
|
|
200
|
-
operationId: entry.OperationId,
|
|
201
|
-
timestamp: DateTime.Now,
|
|
202
|
-
stackTrace: entry.StackTrace));
|
|
203
|
-
if (truncated)
|
|
204
|
-
Interlocked.Increment(ref _truncatedEntries);
|
|
205
|
-
}
|
|
206
|
-
catch
|
|
207
|
-
{
|
|
208
|
-
Interlocked.Increment(ref _droppedEntries);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
void OnLogMessageReceived(string message, string stackTrace, LogType type)
|
|
213
|
-
{
|
|
214
|
-
try
|
|
215
|
-
{
|
|
216
|
-
// Strip rich text tags
|
|
217
|
-
var cleanMessage = BoundMessage(RichTextRegex.Replace(message, string.Empty), out var truncated);
|
|
218
|
-
|
|
219
|
-
// COCli-07 attribution: entries produced on the main thread
|
|
220
|
-
// inside an owned execution window carry that call's ids and
|
|
221
|
-
// classify as product; everything Unity forwards otherwise
|
|
222
|
-
// stays source=unity with empty correlation fields.
|
|
223
|
-
string? correlationId = null;
|
|
224
|
-
string? operationId = null;
|
|
225
|
-
var source = LogEntry.SourceUnity;
|
|
226
|
-
if (MainThread.Instance.IsMainThread && LogCallScope.Current is { } frame)
|
|
227
|
-
{
|
|
228
|
-
correlationId = frame.CorrelationId;
|
|
229
|
-
operationId = frame.OperationId;
|
|
230
|
-
source = LogEntry.SourceProduct;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
_logStorage.Append(new LogEntry(
|
|
234
|
-
logType: type,
|
|
235
|
-
message: cleanMessage,
|
|
236
|
-
source: source,
|
|
237
|
-
correlationId: correlationId,
|
|
238
|
-
operationId: operationId,
|
|
239
|
-
timestamp: DateTime.Now,
|
|
240
|
-
stackTrace: string.IsNullOrEmpty(stackTrace) ? null : stackTrace));
|
|
241
|
-
if (truncated)
|
|
242
|
-
Interlocked.Increment(ref _truncatedEntries);
|
|
243
|
-
}
|
|
244
|
-
catch
|
|
245
|
-
{
|
|
246
|
-
Interlocked.Increment(ref _droppedEntries);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
string BoundMessage(string message, out bool truncated)
|
|
251
|
-
{
|
|
252
|
-
if (message.Length <= MaxMessageCharacters)
|
|
253
|
-
{
|
|
254
|
-
truncated = false;
|
|
255
|
-
return message;
|
|
256
|
-
}
|
|
257
|
-
truncated = true;
|
|
258
|
-
return message.Substring(0, MaxMessageCharacters);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
public void Dispose()
|
|
262
|
-
{
|
|
263
|
-
if (!_isDisposed.TrySetTrue())
|
|
264
|
-
return; // already disposed
|
|
265
|
-
|
|
266
|
-
Application.logMessageReceivedThreaded -= OnLogMessageReceived;
|
|
267
|
-
// Hand the channel back to whatever sink was installed before this
|
|
268
|
-
// collector (e.g. the plugin's own collector) instead of clearing
|
|
269
|
-
// it: a temporary collector must not disable console separation for
|
|
270
|
-
// the rest of the session. A newer install is left untouched.
|
|
271
|
-
PluginDiagnostics.RestoreIfCurrent(_channelSink, _previousChannelSink);
|
|
272
|
-
_previousChannelSink = null;
|
|
273
|
-
_logStorage.Dispose();
|
|
274
|
-
|
|
275
|
-
GC.SuppressFinalize(this);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
1
|
+
/*
|
|
2
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
3
|
+
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
|
|
4
|
+
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
|
|
5
|
+
│ Copyright (c) 2025 Ivan Murzak │
|
|
6
|
+
│ Licensed under the Apache License, Version 2.0. │
|
|
7
|
+
│ See the LICENSE file in the project root for more information. │
|
|
8
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
#nullable enable
|
|
12
|
+
using System;
|
|
13
|
+
using System.Text.RegularExpressions;
|
|
14
|
+
using System.Threading;
|
|
15
|
+
using System.Threading.Tasks;
|
|
16
|
+
using com.AtelierAI.Unity.Copilot.Runtime.Utils;
|
|
17
|
+
using com.AtelierAI.Unity.Copilot.Utils;
|
|
18
|
+
using com.AtelierAI.Uco.Framework.Common;
|
|
19
|
+
using com.IvanMurzak.ReflectorNet;
|
|
20
|
+
using com.IvanMurzak.ReflectorNet.Utils;
|
|
21
|
+
using UnityEngine;
|
|
22
|
+
|
|
23
|
+
namespace com.AtelierAI.Unity.Copilot
|
|
24
|
+
{
|
|
25
|
+
/// <summary>
|
|
26
|
+
/// Result of a detailed console query: the filtered entries plus the
|
|
27
|
+
/// collector's loss accounting so "no errors" is distinguishable from
|
|
28
|
+
/// "errors were dropped" (COCli-07).
|
|
29
|
+
/// </summary>
|
|
30
|
+
public class ConsoleLogsQueryResult
|
|
31
|
+
{
|
|
32
|
+
public LogEntry[] Entries { get; set; } = Array.Empty<LogEntry>();
|
|
33
|
+
public int DroppedEntries { get; set; }
|
|
34
|
+
public int TruncatedEntries { get; set; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// <summary>
|
|
38
|
+
/// Collects Unity log messages and manages saving/loading them to/from a cache file.
|
|
39
|
+
/// </summary>
|
|
40
|
+
public class UnityLogCollector : IDisposable
|
|
41
|
+
{
|
|
42
|
+
private static readonly Regex RichTextRegex = new Regex(@"</?(b|i|size|color|material|quad|a)\b[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
43
|
+
|
|
44
|
+
const int MaxMessageCharacters = 16 * 1024;
|
|
45
|
+
|
|
46
|
+
readonly ILogStorage _logStorage;
|
|
47
|
+
readonly ThreadSafeBool _isDisposed = new(false);
|
|
48
|
+
// This collector's channel sink and the sink it displaced, so Dispose
|
|
49
|
+
// can restore the previous channel owner instead of clearing it.
|
|
50
|
+
readonly Action<PluginDiagnosticEntry> _channelSink;
|
|
51
|
+
Action<PluginDiagnosticEntry>? _previousChannelSink;
|
|
52
|
+
int _droppedEntries;
|
|
53
|
+
int _truncatedEntries;
|
|
54
|
+
|
|
55
|
+
public UnityLogCollector(ILogStorage logStorage)
|
|
56
|
+
{
|
|
57
|
+
if (!MainThread.Instance.IsMainThread)
|
|
58
|
+
throw new Exception($"{GetType().GetTypeShortName()} constructor must be initialized on the main thread.");
|
|
59
|
+
|
|
60
|
+
_logStorage = logStorage ?? throw new ArgumentNullException(nameof(logStorage));
|
|
61
|
+
|
|
62
|
+
_channelSink = AppendPluginDiagnostic;
|
|
63
|
+
_previousChannelSink = PluginDiagnostics.CurrentSink;
|
|
64
|
+
Application.logMessageReceivedThreaded += OnLogMessageReceived;
|
|
65
|
+
PluginDiagnostics.Install(_channelSink);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public LogClearResult Clear()
|
|
69
|
+
{
|
|
70
|
+
if (_isDisposed.Value)
|
|
71
|
+
return new LogClearResult
|
|
72
|
+
{
|
|
73
|
+
Ok = false,
|
|
74
|
+
Strategy = "collector-disposed",
|
|
75
|
+
Error = "The Unity log collector is disposed."
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return _logStorage.Clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// <summary>
|
|
82
|
+
/// Synchronously saves all current log entries to the cache file.
|
|
83
|
+
/// </summary>
|
|
84
|
+
public void Save()
|
|
85
|
+
{
|
|
86
|
+
if (_isDisposed.Value)
|
|
87
|
+
return;
|
|
88
|
+
|
|
89
|
+
_logStorage.Flush();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// <summary>
|
|
93
|
+
/// Asynchronously saves all current log entries to the cache file.
|
|
94
|
+
/// </summary>
|
|
95
|
+
/// <returns>A task that completes when the save operation is finished.</returns>
|
|
96
|
+
public Task SaveAsync()
|
|
97
|
+
{
|
|
98
|
+
if (_isDisposed.Value)
|
|
99
|
+
return Task.CompletedTask;
|
|
100
|
+
|
|
101
|
+
return _logStorage.FlushAsync();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public Task<LogEntry[]> QueryAsync(
|
|
105
|
+
int maxEntries = 100,
|
|
106
|
+
LogType? logTypeFilter = null,
|
|
107
|
+
bool includeStackTrace = false,
|
|
108
|
+
int lastMinutes = 0)
|
|
109
|
+
{
|
|
110
|
+
return _logStorage.QueryAsync(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public LogEntry[] Query(
|
|
114
|
+
int maxEntries = 100,
|
|
115
|
+
LogType? logTypeFilter = null,
|
|
116
|
+
bool includeStackTrace = false,
|
|
117
|
+
int lastMinutes = 0)
|
|
118
|
+
{
|
|
119
|
+
return _logStorage.Query(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// <summary>
|
|
123
|
+
/// Detailed query with operation/correlation/source/time-boundary
|
|
124
|
+
/// filters and loss accounting (COCli-07). Existing filter names and
|
|
125
|
+
/// semantics are unchanged; the new filters compose with them.
|
|
126
|
+
/// </summary>
|
|
127
|
+
public ConsoleLogsQueryResult QueryDetailed(
|
|
128
|
+
int maxEntries = 100,
|
|
129
|
+
LogType? logTypeFilter = null,
|
|
130
|
+
bool includeStackTrace = false,
|
|
131
|
+
int lastMinutes = 0,
|
|
132
|
+
string? correlationId = null,
|
|
133
|
+
string? operationId = null,
|
|
134
|
+
string? source = null,
|
|
135
|
+
long? sinceUnixMs = null)
|
|
136
|
+
{
|
|
137
|
+
if (maxEntries < 1)
|
|
138
|
+
throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
|
139
|
+
if (!string.IsNullOrEmpty(source)
|
|
140
|
+
&& source != LogEntry.SourceProduct
|
|
141
|
+
&& source != LogEntry.SourceBridge
|
|
142
|
+
&& source != LogEntry.SourceTool
|
|
143
|
+
&& source != LogEntry.SourceUnity)
|
|
144
|
+
{
|
|
145
|
+
throw new ArgumentException(
|
|
146
|
+
$"source must be one of: {LogEntry.SourceProduct}, {LogEntry.SourceBridge}, {LogEntry.SourceTool}, {LogEntry.SourceUnity}.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Oversubscribe the storage query so the id/source filters below
|
|
150
|
+
// can still fill maxEntries from a wider recent window.
|
|
151
|
+
var fetchLimit = string.IsNullOrEmpty(correlationId) && string.IsNullOrEmpty(operationId)
|
|
152
|
+
? maxEntries
|
|
153
|
+
: Math.Min(2_000, maxEntries * 10);
|
|
154
|
+
var candidates = _logStorage.Query(
|
|
155
|
+
fetchLimit, logTypeFilter, includeStackTrace, lastMinutes);
|
|
156
|
+
|
|
157
|
+
var results = new System.Collections.Generic.List<LogEntry>(candidates.Length);
|
|
158
|
+
var boundary = sinceUnixMs.HasValue
|
|
159
|
+
? DateTimeOffset.FromUnixTimeMilliseconds(sinceUnixMs.Value).LocalDateTime
|
|
160
|
+
: (DateTime?)null;
|
|
161
|
+
// Storage returns newest-first; keep the newest maxEntries after
|
|
162
|
+
// filtering, then present them oldest-first for readability.
|
|
163
|
+
for (var i = 0; i < candidates.Length && results.Count < maxEntries; i++)
|
|
164
|
+
{
|
|
165
|
+
var entry = candidates[i];
|
|
166
|
+
if (!string.IsNullOrEmpty(correlationId)
|
|
167
|
+
&& !string.Equals(entry.CorrelationId, correlationId, StringComparison.Ordinal))
|
|
168
|
+
continue;
|
|
169
|
+
if (!string.IsNullOrEmpty(operationId)
|
|
170
|
+
&& !string.Equals(entry.OperationId, operationId, StringComparison.Ordinal))
|
|
171
|
+
continue;
|
|
172
|
+
if (!string.IsNullOrEmpty(source)
|
|
173
|
+
&& !string.Equals(entry.Source, source, StringComparison.Ordinal))
|
|
174
|
+
continue;
|
|
175
|
+
if (boundary.HasValue && entry.Timestamp < boundary.Value)
|
|
176
|
+
continue;
|
|
177
|
+
results.Add(entry);
|
|
178
|
+
}
|
|
179
|
+
results.Reverse();
|
|
180
|
+
|
|
181
|
+
return new ConsoleLogsQueryResult
|
|
182
|
+
{
|
|
183
|
+
Entries = results.ToArray(),
|
|
184
|
+
DroppedEntries = Volatile.Read(ref _droppedEntries) + PluginDiagnostics.DroppedEntries,
|
|
185
|
+
TruncatedEntries = Volatile.Read(ref _truncatedEntries),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// <summary>Sink for the plugin/bridge/tool diagnostics channel.</summary>
|
|
190
|
+
void AppendPluginDiagnostic(PluginDiagnosticEntry entry)
|
|
191
|
+
{
|
|
192
|
+
if (_isDisposed.Value || entry == null) return;
|
|
193
|
+
try
|
|
194
|
+
{
|
|
195
|
+
_logStorage.Append(new LogEntry(
|
|
196
|
+
logType: entry.LogType,
|
|
197
|
+
message: BoundMessage(entry.Message, out var truncated),
|
|
198
|
+
source: entry.Source,
|
|
199
|
+
correlationId: entry.CorrelationId,
|
|
200
|
+
operationId: entry.OperationId,
|
|
201
|
+
timestamp: DateTime.Now,
|
|
202
|
+
stackTrace: entry.StackTrace));
|
|
203
|
+
if (truncated)
|
|
204
|
+
Interlocked.Increment(ref _truncatedEntries);
|
|
205
|
+
}
|
|
206
|
+
catch
|
|
207
|
+
{
|
|
208
|
+
Interlocked.Increment(ref _droppedEntries);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
void OnLogMessageReceived(string message, string stackTrace, LogType type)
|
|
213
|
+
{
|
|
214
|
+
try
|
|
215
|
+
{
|
|
216
|
+
// Strip rich text tags
|
|
217
|
+
var cleanMessage = BoundMessage(RichTextRegex.Replace(message, string.Empty), out var truncated);
|
|
218
|
+
|
|
219
|
+
// COCli-07 attribution: entries produced on the main thread
|
|
220
|
+
// inside an owned execution window carry that call's ids and
|
|
221
|
+
// classify as product; everything Unity forwards otherwise
|
|
222
|
+
// stays source=unity with empty correlation fields.
|
|
223
|
+
string? correlationId = null;
|
|
224
|
+
string? operationId = null;
|
|
225
|
+
var source = LogEntry.SourceUnity;
|
|
226
|
+
if (MainThread.Instance.IsMainThread && LogCallScope.Current is { } frame)
|
|
227
|
+
{
|
|
228
|
+
correlationId = frame.CorrelationId;
|
|
229
|
+
operationId = frame.OperationId;
|
|
230
|
+
source = LogEntry.SourceProduct;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_logStorage.Append(new LogEntry(
|
|
234
|
+
logType: type,
|
|
235
|
+
message: cleanMessage,
|
|
236
|
+
source: source,
|
|
237
|
+
correlationId: correlationId,
|
|
238
|
+
operationId: operationId,
|
|
239
|
+
timestamp: DateTime.Now,
|
|
240
|
+
stackTrace: string.IsNullOrEmpty(stackTrace) ? null : stackTrace));
|
|
241
|
+
if (truncated)
|
|
242
|
+
Interlocked.Increment(ref _truncatedEntries);
|
|
243
|
+
}
|
|
244
|
+
catch
|
|
245
|
+
{
|
|
246
|
+
Interlocked.Increment(ref _droppedEntries);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
string BoundMessage(string message, out bool truncated)
|
|
251
|
+
{
|
|
252
|
+
if (message.Length <= MaxMessageCharacters)
|
|
253
|
+
{
|
|
254
|
+
truncated = false;
|
|
255
|
+
return message;
|
|
256
|
+
}
|
|
257
|
+
truncated = true;
|
|
258
|
+
return message.Substring(0, MaxMessageCharacters);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public void Dispose()
|
|
262
|
+
{
|
|
263
|
+
if (!_isDisposed.TrySetTrue())
|
|
264
|
+
return; // already disposed
|
|
265
|
+
|
|
266
|
+
Application.logMessageReceivedThreaded -= OnLogMessageReceived;
|
|
267
|
+
// Hand the channel back to whatever sink was installed before this
|
|
268
|
+
// collector (e.g. the plugin's own collector) instead of clearing
|
|
269
|
+
// it: a temporary collector must not disable console separation for
|
|
270
|
+
// the rest of the session. A newer install is left untouched.
|
|
271
|
+
PluginDiagnostics.RestoreIfCurrent(_channelSink, _previousChannelSink);
|
|
272
|
+
_previousChannelSink = null;
|
|
273
|
+
_logStorage.Dispose();
|
|
274
|
+
|
|
275
|
+
GC.SuppressFinalize(this);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// No finalizer: Dispose performs file I/O / Unity static-event unsubscription,
|
|
279
|
+
// which must never run on the finalizer thread during domain unload
|
|
280
|
+
// (same crash class as uco-domain-reload-crash-20260919).
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -26,7 +26,7 @@ namespace com.AtelierAI.Unity.Copilot
|
|
|
26
26
|
|
|
27
27
|
public partial class UnityCopilotPlugin : IDisposable
|
|
28
28
|
{
|
|
29
|
-
public const string Version = "1.0.
|
|
29
|
+
public const string Version = "1.0.8";
|
|
30
30
|
|
|
31
31
|
private static int _singletonCount = 0;
|
|
32
32
|
public static bool HasAnyInstance => _singletonCount > 0;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"Unity Skills",
|
|
16
16
|
"uco"
|
|
17
17
|
],
|
|
18
|
-
"version": "1.0.
|
|
18
|
+
"version": "1.0.8",
|
|
19
19
|
"unity": "2022.3",
|
|
20
20
|
"description": "uco — Unity Copilot: AI Skills, Tools, and CLI for the Unity Engine, driven over plain REST + WebSocket. Any C# method may be turned into a tool by a single line. Derived from IvanMurzak/Unity-MCP (Apache-2.0).",
|
|
21
21
|
"documentationUrl": "https://github.com/DumoeDss/uco-plugin/tree/main/uco-unity-project/Packages/com.atelierai.unity.copilot#readme",
|