@aefree/pi-unity 0.9.0
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 +173 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/index.ts +1724 -0
- package/package.json +75 -0
- package/skills/auditing-unity-agent-guidance/SKILL.md +41 -0
- package/skills/auditing-unity-agent-guidance/assets/mixed-workflow-template.md +30 -0
- package/skills/auditing-unity-agent-guidance/references/detection-catalog.md +28 -0
- package/skills/auditing-unity-agent-guidance/references/migration-policy.md +48 -0
- package/skills/unity-batchmode-tests/SKILL.md +145 -0
- package/skills/unity-debugging/SKILL.md +35 -0
- package/skills/unity-interactive-playmode-authoring/SKILL.md +91 -0
- package/skills/unity-pipeline-workflows/SKILL.md +52 -0
- package/src/optional-integration-rendezvous.ts +124 -0
- package/src/pi-unity-settings.ts +88 -0
- package/src/unity-artifact-profile.ts +110 -0
- package/src/unity-batchmode.ts +355 -0
- package/src/unity-cli.ts +635 -0
- package/src/unity-core.ts +218 -0
- package/src/unity-file-discovery-filter.ts +89 -0
- package/src/unity-guidance-audit.ts +424 -0
- package/src/unity-launch.ts +85 -0
- package/src/unity-pipeline.ts +487 -0
- package/src/unity-processes.ts +260 -0
- package/src/unity-project-lock.ts +381 -0
- package/src/unity-projects.ts +174 -0
- package/src/unity-test-batch.ts +82 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { projectPathsMatch } from "./unity-core";
|
|
3
|
+
import { resolveUnityCliCommand, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
|
|
4
|
+
|
|
5
|
+
/** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
|
|
6
|
+
export const UNITY_PIPELINE_COMPILE_TIMEOUT_SECONDS = 180;
|
|
7
|
+
export const UNITY_PIPELINE_TEST_TIMEOUT_SECONDS = 600;
|
|
8
|
+
export const UNITY_PIPELINE_MAX_TIMEOUT_SECONDS = 3600;
|
|
9
|
+
export const UNITY_PIPELINE_BACKOFF_SECONDS = Object.freeze([1, 2, 3, 5, 8]);
|
|
10
|
+
export const UNITY_PIPELINE_MAX_DIAGNOSTICS = 8;
|
|
11
|
+
export const UNITY_PIPELINE_MAX_STACK_CHARS = 600;
|
|
12
|
+
|
|
13
|
+
export type UnityPipelineCompileRequest = { projectRoot: string; unityVersion: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
|
|
14
|
+
export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
|
|
15
|
+
export type UnityPipelineProgress = (message: string) => void;
|
|
16
|
+
/** Unity's EditorSettings.ScriptChangesWhilePlaying values when a future editor_status payload supplies one. */
|
|
17
|
+
export type UnityScriptChangesWhilePlayingPolicy = "recompile_and_continue" | "stop_and_recompile" | "defer" | "unknown";
|
|
18
|
+
export type UnityPipelinePlayModeHandling = "not_playing" | "agent_exited" | "unity_policy_continue" | "unity_policy_may_exit" | "unity_policy_defer" | "policy_unknown";
|
|
19
|
+
export type UnityPipelineOperationDetails = {
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
operation: "recompile" | "tests";
|
|
22
|
+
terminalState: "up_to_date" | "completed";
|
|
23
|
+
elapsedSeconds: number;
|
|
24
|
+
compilationTriggered?: boolean;
|
|
25
|
+
/** True only when pi-unity explicitly sent editor_stop and verified Edit Mode. */
|
|
26
|
+
exitedPlayMode?: boolean;
|
|
27
|
+
/** Distinguishes an explicit agent exit from Unity-policy-driven or unavailable-policy behavior. */
|
|
28
|
+
playModeHandling?: UnityPipelinePlayModeHandling;
|
|
29
|
+
/** Present for a recompile started while Play Mode was active; unknown means Pipeline did not expose the preference. */
|
|
30
|
+
scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy;
|
|
31
|
+
testPlatform?: "EditMode" | "PlayMode";
|
|
32
|
+
testFilter?: string;
|
|
33
|
+
counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
|
|
34
|
+
};
|
|
35
|
+
export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails };
|
|
36
|
+
|
|
37
|
+
type RecordValue = Record<string, unknown>;
|
|
38
|
+
type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
|
|
39
|
+
type NormalizedCompile = { state: "up_to_date" | "triggered" | "compiling" | "completed" | "failed" | "uncertain"; diagnostics: string[]; failed: boolean };
|
|
40
|
+
type NormalizedTest = {
|
|
41
|
+
state: "inactive" | "starting" | "running" | "completed" | "failed" | "cancelled" | "uncertain";
|
|
42
|
+
total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
|
|
43
|
+
correlation: Record<string, string>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type PipelineDependencies = {
|
|
47
|
+
execute: UnityCliExecutor;
|
|
48
|
+
inspect: (projectRoot: string, unityVersion: string, signal?: AbortSignal) => Promise<UnityCliProjectCapabilities>;
|
|
49
|
+
canonicalize?: (projectRoot: string) => Promise<string>;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
52
|
+
cliCommand?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function record(value: unknown): RecordValue | undefined {
|
|
56
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : undefined;
|
|
57
|
+
}
|
|
58
|
+
function string(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; }
|
|
59
|
+
function number(value: unknown): number | undefined {
|
|
60
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
61
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) return Number(value);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function field(value: RecordValue, ...names: string[]): unknown {
|
|
65
|
+
for (const [key, item] of Object.entries(value)) if (names.includes(key.toLowerCase())) return item;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function bounded(value: string, limit = UNITY_PIPELINE_MAX_STACK_CHARS): string {
|
|
69
|
+
const oneLine = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
70
|
+
return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
|
|
71
|
+
}
|
|
72
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
73
|
+
if (signal?.aborted) throw new Error("Unity Pipeline operation aborted; its Editor operation may still be running.");
|
|
74
|
+
}
|
|
75
|
+
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
76
|
+
throwIfAborted(signal);
|
|
77
|
+
await new Promise<void>((resolve, reject) => {
|
|
78
|
+
const cleanup = () => signal?.removeEventListener("abort", abort);
|
|
79
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, milliseconds);
|
|
80
|
+
const abort = () => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(new Error("Unity Pipeline operation aborted; its Editor operation may still be running."));
|
|
84
|
+
};
|
|
85
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Only package-owned argument arrays are produced; callers cannot select arbitrary Pipeline commands. */
|
|
90
|
+
export function createUnityPipelineCommand(projectRoot: string, command: "editor_status" | "editor_stop" | "recompile" | "recompile_status" | "run_tests" | "test_status", args: string[] = [], options: { timeoutSeconds?: number; cliCommand?: string } = {}) {
|
|
91
|
+
return {
|
|
92
|
+
command: resolveUnityCliCommand({ cliCommand: options.cliCommand }),
|
|
93
|
+
args: ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--timeout", String(options.timeoutSeconds ?? 12), command, ...args],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parses the CLI envelope and its documented object-or-JSON-string data.result form. */
|
|
98
|
+
export function parseUnityPipelineEnvelope(output: string): ParsedEnvelope {
|
|
99
|
+
let outer: RecordValue | undefined;
|
|
100
|
+
try { outer = record(JSON.parse(output)); } catch { return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned malformed JSON." }; }
|
|
101
|
+
if (!outer) return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned a non-object JSON envelope." };
|
|
102
|
+
const data = record(outer.data);
|
|
103
|
+
const rawResult = data?.result ?? data;
|
|
104
|
+
if (typeof rawResult === "string") {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = record(JSON.parse(rawResult));
|
|
107
|
+
if (!parsed) return { result: {}, outerSuccess: outer.success === true, malformed: "Unity Pipeline returned a non-object nested result." };
|
|
108
|
+
return { result: parsed, outerSuccess: outer.success === true };
|
|
109
|
+
} catch { return { result: {}, outerSuccess: outer.success === true, malformed: "Unity Pipeline returned malformed nested JSON." }; }
|
|
110
|
+
}
|
|
111
|
+
const result = record(rawResult);
|
|
112
|
+
return result ? { result, outerSuccess: outer.success === true } : { result: {}, outerSuccess: outer.success === true, malformed: "Unity Pipeline response omitted data.result." };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function walk(value: unknown, visitor: (item: RecordValue) => void, depth = 0): void {
|
|
116
|
+
if (depth > 5) return;
|
|
117
|
+
const item = record(value);
|
|
118
|
+
if (item) {
|
|
119
|
+
visitor(item);
|
|
120
|
+
for (const child of Object.values(item)) walk(child, visitor, depth + 1);
|
|
121
|
+
} else if (Array.isArray(value)) for (const child of value.slice(0, 200)) walk(child, visitor, depth + 1);
|
|
122
|
+
}
|
|
123
|
+
function statusOf(result: RecordValue): string | undefined {
|
|
124
|
+
const value = field(result, "status", "state", "phase", "result");
|
|
125
|
+
return string(value)?.toLowerCase().replace(/[\s-]+/g, "_");
|
|
126
|
+
}
|
|
127
|
+
function hasSemanticFailure(result: RecordValue): boolean {
|
|
128
|
+
let failed = field(result, "success") === false || field(result, "failed") === true;
|
|
129
|
+
// `success: false` is failure; `failed: false` is not.
|
|
130
|
+
walk(result, item => { if (field(item, "success") === false || field(item, "failed") === true) failed = true; });
|
|
131
|
+
return failed;
|
|
132
|
+
}
|
|
133
|
+
function diagnostics(result: RecordValue): string[] {
|
|
134
|
+
const values: string[] = [];
|
|
135
|
+
walk(result, item => {
|
|
136
|
+
for (const key of ["compilererrors", "diagnostics", "errors"]) {
|
|
137
|
+
const raw = field(item, key);
|
|
138
|
+
if (!Array.isArray(raw)) continue;
|
|
139
|
+
for (const entry of raw) {
|
|
140
|
+
const itemEntry = record(entry);
|
|
141
|
+
const message = itemEntry ? string(field(itemEntry, "message", "error", "text")) : string(entry);
|
|
142
|
+
if (message) values.push(bounded(message));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
147
|
+
}
|
|
148
|
+
export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
|
|
149
|
+
const parsed = parseUnityPipelineEnvelope(output);
|
|
150
|
+
if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
|
|
151
|
+
const compilerDiagnostics = diagnostics(parsed.result);
|
|
152
|
+
const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilerDiagnostics.length > 0;
|
|
153
|
+
const raw = statusOf(parsed.result);
|
|
154
|
+
const state = failed || raw === "failed" || raw === "error" ? "failed" : raw === "up_to_date" || raw === "uptodate" ? "up_to_date"
|
|
155
|
+
: raw === "triggered" ? "triggered" : raw === "compiling" || raw === "running" ? "compiling"
|
|
156
|
+
: raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
|
|
157
|
+
return { state, diagnostics: compilerDiagnostics, failed };
|
|
158
|
+
}
|
|
159
|
+
function summary(result: RecordValue): RecordValue | undefined {
|
|
160
|
+
let found: RecordValue | undefined;
|
|
161
|
+
walk(result, item => { if (!found && record(field(item, "summary"))) found = record(field(item, "summary")); });
|
|
162
|
+
return found;
|
|
163
|
+
}
|
|
164
|
+
function testFailures(result: RecordValue): string[] {
|
|
165
|
+
const values: string[] = [];
|
|
166
|
+
walk(result, item => {
|
|
167
|
+
for (const key of ["tests", "results", "testresults"]) {
|
|
168
|
+
const entries = field(item, key);
|
|
169
|
+
if (!Array.isArray(entries)) continue;
|
|
170
|
+
for (const entry of entries.slice(0, 200)) {
|
|
171
|
+
const test = record(entry); if (!test) continue;
|
|
172
|
+
const outcome = string(field(test, "result", "status", "outcome"))?.toLowerCase();
|
|
173
|
+
if (!outcome || /pass|success/.test(outcome)) continue;
|
|
174
|
+
const name = string(field(test, "name", "fullname", "testname")) ?? "Unnamed test";
|
|
175
|
+
const message = string(field(test, "message", "error", "failuremessage"));
|
|
176
|
+
const stack = string(field(test, "stacktrace", "stack", "trace"));
|
|
177
|
+
values.push(bounded(`${name}${message ? `: ${message}` : ""}${stack ? ` (${bounded(stack, UNITY_PIPELINE_MAX_STACK_CHARS)})` : ""}`));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
182
|
+
}
|
|
183
|
+
function normalizedMode(value: string): string {
|
|
184
|
+
const normalized = value.toLowerCase().replace(/[\s_-]+/g, "");
|
|
185
|
+
return normalized === "editor" || normalized === "editmode" ? "EditMode" : normalized === "play" || normalized === "playmode" ? "PlayMode" : value;
|
|
186
|
+
}
|
|
187
|
+
function correlation(result: RecordValue): Record<string, string> {
|
|
188
|
+
const found: Record<string, string> = {};
|
|
189
|
+
walk(result, item => {
|
|
190
|
+
for (const [name, keys] of Object.entries({ mode: ["mode", "testplatform", "platform"], filter: ["filter", "testfilter", "filterapplied"], runId: ["runid", "id", "testrunid"], statusPath: ["statuspath", "status_path"] })) {
|
|
191
|
+
if (found[name]) continue;
|
|
192
|
+
const value = string(field(item, ...keys));
|
|
193
|
+
if (!value) continue;
|
|
194
|
+
found[name] = name === "mode" ? normalizedMode(value)
|
|
195
|
+
: name === "filter" ? bounded(value.replace(/^testname\s*:\s*/i, ""), 200)
|
|
196
|
+
: bounded(value, 200);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
export function normalizeUnityPipelineTest(output: string): NormalizedTest {
|
|
202
|
+
const parsed = parseUnityPipelineEnvelope(output);
|
|
203
|
+
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {} };
|
|
204
|
+
const sum = summary(parsed.result);
|
|
205
|
+
const total = number(field(sum ?? parsed.result, "total"));
|
|
206
|
+
const passed = number(field(sum ?? parsed.result, "passed", "pass"));
|
|
207
|
+
const failedCount = number(field(sum ?? parsed.result, "failed", "fail"));
|
|
208
|
+
const inconclusive = number(field(sum ?? parsed.result, "inconclusive", "skipped"));
|
|
209
|
+
const raw = statusOf(parsed.result);
|
|
210
|
+
const semanticFailed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || (failedCount ?? 0) > 0;
|
|
211
|
+
const state = semanticFailed || raw === "failed" || raw === "error" ? "failed" : raw === "cancelled" || raw === "canceled" ? "cancelled"
|
|
212
|
+
: raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
|
|
213
|
+
: raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
|
|
214
|
+
: raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
|
|
215
|
+
return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function editorStopSucceeded(output: string): boolean {
|
|
219
|
+
try {
|
|
220
|
+
const outer = record(JSON.parse(output));
|
|
221
|
+
const data = record(outer?.data);
|
|
222
|
+
if (outer?.success !== true || data?.success === false) return false;
|
|
223
|
+
const rawResult = data?.result;
|
|
224
|
+
if (typeof rawResult === "string") return /^(?:exited play mode|already in edit mode)$/i.test(rawResult.trim());
|
|
225
|
+
const result = record(rawResult);
|
|
226
|
+
return Boolean(result && !hasSemanticFailure(result));
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function editorLifecycleState(result: RecordValue): "compatible" | "playing" | "paused" {
|
|
233
|
+
const state = statusOf(result);
|
|
234
|
+
const playMode = string(field(result, "playmode", "play_mode"))?.toLowerCase();
|
|
235
|
+
if (state === "paused" || playMode === "paused" || field(result, "ispaused") === true) return "paused";
|
|
236
|
+
if (state === "playmode" || state === "playing" || playMode === "playing" || playMode === "started" || field(result, "isplaying") === true) return "playing";
|
|
237
|
+
return "compatible";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Normalizes Unity's documented ScriptChangesWhilePlaying enum labels and serialized enum values. */
|
|
241
|
+
export function normalizeUnityScriptChangesWhilePlaying(value: unknown): UnityScriptChangesWhilePlayingPolicy {
|
|
242
|
+
if (value === 0 || value === "0") return "recompile_and_continue";
|
|
243
|
+
if (value === 1 || value === "1") return "defer";
|
|
244
|
+
if (value === 2 || value === "2") return "stop_and_recompile";
|
|
245
|
+
if (typeof value !== "string") return "unknown";
|
|
246
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
247
|
+
if (normalized === "recompileandcontinue" || normalized.endsWith("recompileandcontinueplaying")) return "recompile_and_continue";
|
|
248
|
+
if (normalized === "stopandrecompile" || normalized.endsWith("stopplayingandrecompile")) return "stop_and_recompile";
|
|
249
|
+
if (normalized === "recompileafterplaymode" || normalized === "defer" || normalized === "deferred" || normalized.endsWith("recompileafterfinishedplaying")) return "defer";
|
|
250
|
+
return "unknown";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function editorStatus(output: string): { lifecycle: "compatible" | "playing" | "paused"; scriptChangesWhilePlaying: UnityScriptChangesWhilePlayingPolicy } {
|
|
254
|
+
const parsed = parseUnityPipelineEnvelope(output);
|
|
255
|
+
if (parsed.malformed || !parsed.outerSuccess) throw new Error("Unity Pipeline editor_status evidence is malformed or unavailable; operation not started.");
|
|
256
|
+
let policy: UnityScriptChangesWhilePlayingPolicy = "unknown";
|
|
257
|
+
walk(parsed.result, item => {
|
|
258
|
+
if (policy !== "unknown") return;
|
|
259
|
+
const candidate = field(item, "scriptchangeswhileplaying", "script_changes_while_playing", "scriptchangepolicy", "script_change_policy");
|
|
260
|
+
policy = normalizeUnityScriptChangesWhilePlaying(candidate);
|
|
261
|
+
});
|
|
262
|
+
return { lifecycle: editorLifecycleState(parsed.result), scriptChangesWhilePlaying: policy };
|
|
263
|
+
}
|
|
264
|
+
function capabilityError(capabilities: UnityCliProjectCapabilities, required: string[]): string | undefined {
|
|
265
|
+
if (!capabilities.cliAvailable) return "Unity CLI is unavailable; operation not started.";
|
|
266
|
+
if (capabilities.pipelineDiscovery !== "available" || !capabilities.matchingInstances.some(instance => instance.reachable === true)) return "No reachable Pipeline instance exists for the exact Unity project copy; operation not started.";
|
|
267
|
+
if (!capabilities.commandDiscoverySucceeded) return "Pipeline command availability is uncertain; operation not started.";
|
|
268
|
+
const missing = required.filter(command => !capabilities.advertisedCommands.includes(command));
|
|
269
|
+
return missing.length ? `The exact Pipeline copy does not advertise ${missing.join(", ")}; operation not started.` : undefined;
|
|
270
|
+
}
|
|
271
|
+
function knownPids(capabilities: UnityCliProjectCapabilities): number[] { return capabilities.matchingInstances.flatMap(instance => Number.isInteger(instance.pid) && (instance.pid ?? 0) > 0 ? [instance.pid!] : []).sort((a, b) => a - b); }
|
|
272
|
+
function sameIdentity(initial: UnityCliProjectCapabilities, current: UnityCliProjectCapabilities, projectRoot: string): boolean {
|
|
273
|
+
const pathsMatch = current.matchingInstances.some(instance => projectPathsMatch(instance.projectPath, projectRoot));
|
|
274
|
+
const before = knownPids(initial); const after = knownPids(current);
|
|
275
|
+
return pathsMatch && (before.length === 0 || after.length === 0 || before.join(",") === after.join(","));
|
|
276
|
+
}
|
|
277
|
+
/** Domain reload can temporarily remove the exact-copy Pipeline; a different path or changed known PID is never retried. */
|
|
278
|
+
function pollingIdentity(initial: UnityCliProjectCapabilities, current: UnityCliProjectCapabilities, projectRoot: string): "same" | "temporary_disconnect" | "changed" {
|
|
279
|
+
if (current.matchingInstances.some(instance => !projectPathsMatch(instance.projectPath, projectRoot))) return "changed";
|
|
280
|
+
const before = knownPids(initial); const after = knownPids(current);
|
|
281
|
+
if (before.length > 0 && after.length > 0 && before.join(",") !== after.join(",")) return "changed";
|
|
282
|
+
if (current.matchingInstances.length === 0 || current.matchingInstances.every(instance => instance.reachable !== true)) return "temporary_disconnect";
|
|
283
|
+
return sameIdentity(initial, current, projectRoot) ? "same" : "changed";
|
|
284
|
+
}
|
|
285
|
+
async function inspectWithDeadline(deps: PipelineDependencies, projectRoot: string, unityVersion: string, signal: AbortSignal | undefined, deadline: number, now: () => number, operation: string): Promise<UnityCliProjectCapabilities> {
|
|
286
|
+
throwIfAborted(signal); ensureBeforeDeadline(deadline, now, operation);
|
|
287
|
+
const remaining = deadline - now();
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
let deadlineElapsed = false;
|
|
290
|
+
const forwardAbort = () => controller.abort();
|
|
291
|
+
const timer = setTimeout(() => { deadlineElapsed = true; controller.abort(); }, remaining);
|
|
292
|
+
signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
293
|
+
try {
|
|
294
|
+
const result = await deps.inspect(projectRoot, unityVersion, controller.signal);
|
|
295
|
+
throwIfAborted(signal);
|
|
296
|
+
if (deadlineElapsed) throw timeoutMessage(operation);
|
|
297
|
+
ensureBeforeDeadline(deadline, now, operation);
|
|
298
|
+
return result;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throwIfAborted(signal);
|
|
301
|
+
if (deadlineElapsed || now() >= deadline) throw timeoutMessage(operation);
|
|
302
|
+
throw error;
|
|
303
|
+
} finally {
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function executeCommand(deps: PipelineDependencies, projectRoot: string, command: Parameters<typeof createUnityPipelineCommand>[1], args: string[], signal: AbortSignal | undefined, deadline?: number, now: () => number = Date.now): Promise<UnityCliExecResult> {
|
|
310
|
+
throwIfAborted(signal);
|
|
311
|
+
const remaining = deadline === undefined ? 15_000 : deadline - now();
|
|
312
|
+
if (remaining <= 0) throw timeoutMessage(command);
|
|
313
|
+
const request = createUnityPipelineCommand(projectRoot, command, args, { timeoutSeconds: Math.max(1, Math.ceil(Math.min(15_000, remaining) / 1000)), cliCommand: deps.cliCommand });
|
|
314
|
+
const result = await deps.execute(request.command, request.args, { timeout: Math.max(1, Math.min(15_000, remaining)), signal });
|
|
315
|
+
throwIfAborted(signal);
|
|
316
|
+
if (deadline !== undefined) ensureBeforeDeadline(deadline, now, command);
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
async function requirePreflight(deps: PipelineDependencies, projectRoot: string, unityVersion: string, commands: string[], operation: "recompile" | "tests", signal: AbortSignal | undefined, deadline: number, now: () => number, allowAutonomousExitPlayMode = false): Promise<{ capabilities: UnityCliProjectCapabilities; exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }> {
|
|
320
|
+
const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
|
|
321
|
+
const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
|
|
322
|
+
let editor = await executeCommand(deps, projectRoot, "editor_status", [], signal, deadline, now);
|
|
323
|
+
if (editor.error) throw new Error("Unity Pipeline editor_status failed; operation not started.");
|
|
324
|
+
let status = editorStatus(editor.stdout);
|
|
325
|
+
let exitedPlayMode = false;
|
|
326
|
+
let playModeHandling: UnityPipelinePlayModeHandling = "not_playing";
|
|
327
|
+
|
|
328
|
+
if (status.lifecycle !== "compatible" && operation === "recompile") {
|
|
329
|
+
const policy = status.scriptChangesWhilePlaying;
|
|
330
|
+
if (policy === "recompile_and_continue") {
|
|
331
|
+
playModeHandling = "unity_policy_continue";
|
|
332
|
+
} else if (policy === "defer") {
|
|
333
|
+
playModeHandling = "unity_policy_defer";
|
|
334
|
+
} else {
|
|
335
|
+
const policyDescription = policy === "stop_and_recompile"
|
|
336
|
+
? "Unity's Script Changes While Playing policy may stop Play Mode to recompile"
|
|
337
|
+
: "Pipeline editor_status does not expose Unity's Script Changes While Playing policy, so recompilation may continue, defer, or stop Play Mode";
|
|
338
|
+
if (!allowAutonomousExitPlayMode) throw new Error(`${policyDescription}; autonomous Play Mode exit is disallowed for this session, so recompile was not started.`);
|
|
339
|
+
// RecompileCommand owns AssetDatabase.Refresh. Do not preempt it with editor_stop or override Unity's policy.
|
|
340
|
+
playModeHandling = policy === "stop_and_recompile" ? "unity_policy_may_exit" : "policy_unknown";
|
|
341
|
+
}
|
|
342
|
+
} else if (status.lifecycle !== "compatible") {
|
|
343
|
+
// Test execution has separate lifecycle semantics: stop explicitly only with session authorization.
|
|
344
|
+
if (!allowAutonomousExitPlayMode) throw new Error("Unity Editor is in Play Mode or paused; autonomous Play Mode exit is disallowed for this session, so tests were not started.");
|
|
345
|
+
const stopError = capabilityError(capabilities, ["editor_stop"]); if (stopError) throw new Error(stopError);
|
|
346
|
+
const stopped = await executeCommand(deps, projectRoot, "editor_stop", [], signal, deadline, now);
|
|
347
|
+
if (stopped.error) throw new Error("Unity Pipeline editor_stop failed; tests were not started and Play Mode state is uncertain.");
|
|
348
|
+
if (!editorStopSucceeded(stopped.stdout)) throw new Error("Unity Pipeline editor_stop returned failing or malformed evidence; tests were not started and Play Mode state is uncertain.");
|
|
349
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
350
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
351
|
+
const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[attempt]! * 1000, deadline - now());
|
|
352
|
+
if (delay <= 0) break;
|
|
353
|
+
await sleep(delay, signal); throwIfAborted(signal); ensureBeforeDeadline(deadline, now, "Play Mode exit");
|
|
354
|
+
editor = await executeCommand(deps, projectRoot, "editor_status", [], signal, deadline, now);
|
|
355
|
+
if (editor.error) continue;
|
|
356
|
+
status = editorStatus(editor.stdout);
|
|
357
|
+
if (status.lifecycle === "compatible") { exitedPlayMode = true; break; }
|
|
358
|
+
}
|
|
359
|
+
if (!exitedPlayMode) throw new Error("Unity Play Mode exit did not reach a verified stopped state; tests were not started.");
|
|
360
|
+
playModeHandling = "agent_exited";
|
|
361
|
+
}
|
|
362
|
+
const refreshed = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
|
|
363
|
+
if (capabilityError(refreshed, commands) || !sameIdentity(capabilities, refreshed, projectRoot)) throw new Error("Unity Pipeline identity changed before dispatch; operation not started.");
|
|
364
|
+
return { capabilities: refreshed, exitedPlayMode, playModeHandling, ...(operation === "recompile" && status.lifecycle !== "compatible" ? { scriptChangesWhilePlaying: status.scriptChangesWhilePlaying } : {}) };
|
|
365
|
+
}
|
|
366
|
+
function playModeOutcomeText(preflight: { playModeHandling: UnityPipelinePlayModeHandling }): string {
|
|
367
|
+
switch (preflight.playModeHandling) {
|
|
368
|
+
case "agent_exited": return "Pi-unity explicitly exited Play Mode under current session authorization.\n";
|
|
369
|
+
case "unity_policy_continue": return "Unity's Script Changes While Playing policy is recompile and continue; pi-unity did not send editor_stop.\n";
|
|
370
|
+
case "unity_policy_may_exit": return "Unity's Script Changes While Playing policy may stop Play Mode during recompile; pi-unity did not send editor_stop.\n";
|
|
371
|
+
case "unity_policy_defer": return "Unity's Script Changes While Playing policy defers recompilation until Play Mode ends; pi-unity did not send editor_stop.\n";
|
|
372
|
+
case "policy_unknown": return "Unity's Script Changes While Playing policy was unavailable; recompile was authorized because it may affect Play Mode, and pi-unity did not send editor_stop.\n";
|
|
373
|
+
default: return "";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function playModeDetails(preflight: { exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }) {
|
|
377
|
+
return {
|
|
378
|
+
exitedPlayMode: preflight.exitedPlayMode,
|
|
379
|
+
playModeHandling: preflight.playModeHandling,
|
|
380
|
+
...(preflight.scriptChangesWhilePlaying === undefined ? {} : { scriptChangesWhilePlaying: preflight.scriptChangesWhilePlaying }),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function checkCorrelation(expected: Record<string, string>, actual: Record<string, string>): boolean {
|
|
384
|
+
return Object.entries(expected).every(([key, value]) => !actual[key] || actual[key] === value);
|
|
385
|
+
}
|
|
386
|
+
function passingCounts(state: NormalizedTest): { total: number; passed: number; failed: number; inconclusive?: number } | undefined {
|
|
387
|
+
if (state.total === undefined || state.total <= 0 || state.passed === undefined || state.failed !== 0 || (state.inconclusive ?? 0) > 0) return undefined;
|
|
388
|
+
if (state.passed + state.failed + (state.inconclusive ?? 0) !== state.total) return undefined;
|
|
389
|
+
return { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
390
|
+
}
|
|
391
|
+
function elapsed(start: number, now: () => number): number { return Math.max(0, (now() - start) / 1000); }
|
|
392
|
+
function timeoutMessage(operation: string): Error { return new Error(`Unity Pipeline ${operation} timed out; result is uncertain and may still be running. No cancellation, retry, or route switch was performed.`); }
|
|
393
|
+
function ensureBeforeDeadline(deadline: number, now: () => number, operation: string): void {
|
|
394
|
+
if (now() >= deadline) throw timeoutMessage(operation);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function runUnityPipelineRecompile(request: UnityPipelineCompileRequest, deps: PipelineDependencies, options: { signal?: AbortSignal; onUpdate?: UnityPipelineProgress } = {}): Promise<UnityPipelineOperationResult> {
|
|
398
|
+
const now = deps.now ?? Date.now; const sleep = deps.sleep ?? defaultSleep; const signal = options.signal;
|
|
399
|
+
const timeoutSeconds = request.timeoutSeconds ?? UNITY_PIPELINE_COMPILE_TIMEOUT_SECONDS;
|
|
400
|
+
if (timeoutSeconds < 1 || timeoutSeconds > UNITY_PIPELINE_MAX_TIMEOUT_SECONDS) throw new Error(`timeoutSeconds must be between 1 and ${UNITY_PIPELINE_MAX_TIMEOUT_SECONDS}.`);
|
|
401
|
+
const projectRoot = await (deps.canonicalize ?? realpath)(request.projectRoot); const start = now(); const deadline = start + timeoutSeconds * 1000;
|
|
402
|
+
const preflight = await requirePreflight(deps, projectRoot, request.unityVersion, ["editor_status", "recompile", "recompile_status"], "recompile", signal, deadline, now, request.allowAutonomousExitPlayMode);
|
|
403
|
+
const identity = preflight.capabilities;
|
|
404
|
+
const lifecyclePrefix = playModeOutcomeText(preflight);
|
|
405
|
+
ensureBeforeDeadline(deadline, now, "recompile before dispatch");
|
|
406
|
+
throwIfAborted(signal);
|
|
407
|
+
const dispatched = await executeCommand(deps, projectRoot, "recompile", [], signal, deadline, now);
|
|
408
|
+
if (dispatched.error) throw new Error("Unity Pipeline recompile dispatch failed; operation may not have started.");
|
|
409
|
+
let state = normalizeUnityPipelineCompile(dispatched.stdout);
|
|
410
|
+
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
|
|
411
|
+
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
412
|
+
if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered.`, details: { projectRoot, operation: "recompile", terminalState: "up_to_date", elapsedSeconds: elapsed(start, now), compilationTriggered: false, ...playModeDetails(preflight) } };
|
|
413
|
+
for (let poll = 0; now() < deadline; poll += 1) {
|
|
414
|
+
options.onUpdate?.(`Unity recompile ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
415
|
+
const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(poll, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, deadline - now());
|
|
416
|
+
if (delay <= 0) break;
|
|
417
|
+
await sleep(delay, signal); throwIfAborted(signal); ensureBeforeDeadline(deadline, now, "recompile");
|
|
418
|
+
const current = await inspectWithDeadline(deps, projectRoot, request.unityVersion, signal, deadline, now, "recompile");
|
|
419
|
+
const identityState = pollingIdentity(identity, current, projectRoot);
|
|
420
|
+
if (identityState === "changed") throw new Error("Unity Pipeline identity changed during recompile; operation state is uncertain.");
|
|
421
|
+
if (identityState === "temporary_disconnect") continue;
|
|
422
|
+
const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
|
|
423
|
+
if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
|
|
424
|
+
state = normalizeUnityPipelineCompile(response.stdout);
|
|
425
|
+
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
426
|
+
if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors.`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...playModeDetails(preflight) } };
|
|
427
|
+
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile status is malformed or uncertain; operation may still be running.");
|
|
428
|
+
}
|
|
429
|
+
throw timeoutMessage("recompile");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function runUnityPipelineTests(request: UnityPipelineTestRequest, deps: PipelineDependencies, options: { signal?: AbortSignal; onUpdate?: UnityPipelineProgress } = {}): Promise<UnityPipelineOperationResult> {
|
|
433
|
+
const now = deps.now ?? Date.now; const sleep = deps.sleep ?? defaultSleep; const signal = options.signal;
|
|
434
|
+
const timeoutSeconds = request.timeoutSeconds ?? UNITY_PIPELINE_TEST_TIMEOUT_SECONDS;
|
|
435
|
+
if (timeoutSeconds < 1 || timeoutSeconds > UNITY_PIPELINE_MAX_TIMEOUT_SECONDS) throw new Error(`timeoutSeconds must be between 1 and ${UNITY_PIPELINE_MAX_TIMEOUT_SECONDS}.`);
|
|
436
|
+
const projectRoot = await (deps.canonicalize ?? realpath)(request.projectRoot); const start = now(); const deadline = start + timeoutSeconds * 1000;
|
|
437
|
+
const preflight = await requirePreflight(deps, projectRoot, request.unityVersion, ["editor_status", "run_tests", "test_status"], "tests", signal, deadline, now, request.allowAutonomousExitPlayMode);
|
|
438
|
+
const identity = preflight.capabilities;
|
|
439
|
+
const lifecyclePrefix = playModeOutcomeText(preflight);
|
|
440
|
+
ensureBeforeDeadline(deadline, now, "tests before dispatch");
|
|
441
|
+
const before = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
|
|
442
|
+
if (before.error) throw new Error("Unity Pipeline test status is unavailable; test run not started.");
|
|
443
|
+
const existing = normalizeUnityPipelineTest(before.stdout);
|
|
444
|
+
if (existing.state === "starting" || existing.state === "running") throw new Error("A pre-existing connected Unity test run is active; test run not started.");
|
|
445
|
+
if (existing.state === "uncertain") {
|
|
446
|
+
const observed = statusOf(parseUnityPipelineEnvelope(before.stdout).result) ?? "unknown";
|
|
447
|
+
throw new Error(`Unity Pipeline returned unsupported preflight test status '${bounded(observed, 80)}'; test run not started.`);
|
|
448
|
+
}
|
|
449
|
+
const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...(request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"] : []), "--async_tests", "true"];
|
|
450
|
+
ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
|
|
451
|
+
const dispatched = await executeCommand(deps, projectRoot, "run_tests", args, signal, deadline, now);
|
|
452
|
+
if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
|
|
453
|
+
let state = normalizeUnityPipelineTest(dispatched.stdout);
|
|
454
|
+
if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
|
|
455
|
+
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
456
|
+
const requestedCorrelation = { mode: request.testPlatform, ...(request.testFilter ? { filter: request.testFilter } : {}) };
|
|
457
|
+
if (!checkCorrelation(requestedCorrelation, state.correlation)) throw new Error("Unity Pipeline test dispatch reported a different mode or filter; operation state is uncertain.");
|
|
458
|
+
const expected = { ...requestedCorrelation, ...state.correlation };
|
|
459
|
+
// Some Pipeline versions return a complete result directly from asynchronous dispatch.
|
|
460
|
+
if (state.state === "completed") {
|
|
461
|
+
const counts = passingCounts(state);
|
|
462
|
+
if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
|
|
463
|
+
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
|
|
464
|
+
}
|
|
465
|
+
for (let poll = 0; now() < deadline; poll += 1) {
|
|
466
|
+
options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
467
|
+
const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(poll, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, deadline - now());
|
|
468
|
+
if (delay <= 0) break;
|
|
469
|
+
await sleep(delay, signal); throwIfAborted(signal); ensureBeforeDeadline(deadline, now, "tests");
|
|
470
|
+
const current = await inspectWithDeadline(deps, projectRoot, request.unityVersion, signal, deadline, now, "tests");
|
|
471
|
+
const identityState = pollingIdentity(identity, current, projectRoot);
|
|
472
|
+
if (identityState === "changed") throw new Error("Unity Pipeline identity changed during tests; operation state is uncertain.");
|
|
473
|
+
if (identityState === "temporary_disconnect") continue;
|
|
474
|
+
const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
|
|
475
|
+
if (response.error) continue;
|
|
476
|
+
state = normalizeUnityPipelineTest(response.stdout);
|
|
477
|
+
if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
|
|
478
|
+
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
479
|
+
if (state.state === "uncertain") throw new Error("Unity Pipeline test status is malformed or uncertain; operation may still be running.");
|
|
480
|
+
if (state.state === "inactive") throw new Error("Unity Pipeline test status became inactive before a terminal result; operation state is uncertain.");
|
|
481
|
+
if (state.state !== "completed") continue;
|
|
482
|
+
const counts = passingCounts(state);
|
|
483
|
+
if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
|
|
484
|
+
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
|
|
485
|
+
}
|
|
486
|
+
throw timeoutMessage("tests");
|
|
487
|
+
}
|