@aefree/pi-unity 0.12.1 → 0.14.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 +20 -0
- package/README.md +15 -5
- package/index.ts +116 -23
- package/package.json +2 -2
- package/skills/unity-pipeline-workflows/SKILL.md +2 -2
- package/src/unity-artifact-inspection.ts +13 -9
- package/src/unity-cli.ts +225 -34
- package/src/unity-pipeline.ts +94 -29
- package/src/unity-tests.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.14.0 - 2026-09-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserve durable normalized connected-test evidence for terminal failed, cancelled, runner-error, and incomplete Pipeline results without replaying dispatch, overriding active Pipeline state from partial records, or misreporting inconsistent counts/records as passing.
|
|
15
|
+
- Select one primary latest normalized result and only its validated project-contained backend links when inspecting historical artifacts, including canonical contained aliases.
|
|
16
|
+
- Forward optional `unity_pipeline_eval.handlerTimeoutMilliseconds` only when the exact reachable Pipeline descriptor confirms raw argv support and the documented `code`/`timeout` signature. Preserve `timeoutSeconds` as the separate host/CLI wait; a dispatcher expiry cannot cancel eval code already started on Unity's main thread, so effects remain uncertain and are never retried or rerouted.
|
|
17
|
+
- Treat Pipeline 0.7's explicit `compilationFailed: true` response as a failed recompile even when its status is `up_to_date` or `completed` and no compiler-error array is present.
|
|
18
|
+
|
|
19
|
+
## 0.13.0 - 2026-09-10
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Added advertised `get_runtime_pipeline_settings` inspection and the bounded `unity_pipeline_run_script` tool for Pipeline 0.6 ephemeral in-memory compilation/execution of one existing project C# file. Hotpatch is intentionally out of scope.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Preserve bounded Unity CLI discovery warnings/info and treat warning-bearing discovery as uncertainty rather than a confirmed absent Pipeline or launch-safe signal. Force human CLI version formatting so inherited `UNITY_FORMAT` cannot corrupt version discovery.
|
|
28
|
+
- Accept Pipeline 0.6 compact response envelopes and warnings without treating warnings as compiler errors. Surface ambiguous busy responses rather than blindly redispatching commands that may be blocked by a modal dialog.
|
|
29
|
+
|
|
10
30
|
## 0.12.1 - 2026-09-10
|
|
11
31
|
|
|
12
32
|
### Security
|
package/README.md
CHANGED
|
@@ -34,12 +34,13 @@ Use these tools with an already-open exact Unity project copy that has a reachab
|
|
|
34
34
|
- `unity_project_status` — inspect lockfiles, matching Unity processes, Pipeline reachability, package version, and advertised commands without launching Unity.
|
|
35
35
|
- `unity_pipeline_recompile` — recompile through Pipeline with exact-copy preflight, bounded polling, and compact compiler evidence.
|
|
36
36
|
- `unity_run_tests` — one intent-oriented EditMode or PlayMode workflow. It reuses compatible connected Pipeline execution or selects isolated `unity test` when the exact project copy is closed.
|
|
37
|
-
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL.
|
|
38
|
-
- `unity_pipeline_inspect` — dispatch supported package-owned inspection commands and return structured evidence.
|
|
37
|
+
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. `timeoutSeconds` bounds pi-unity and Unity CLI waits (1–86,400 seconds). Optional `handlerTimeoutMilliseconds` is forwarded only when the exact reachable Pipeline advertises raw argv plus the verified eval timeout signature; it bounds that Pipeline dispatcher wait, not code already running on Unity's main thread. A timeout is uncertain and does not cancel or retry Editor work.
|
|
38
|
+
- `unity_pipeline_inspect` — dispatch supported package-owned inspection commands (including read-only `get_runtime_pipeline_settings`) and return structured evidence. Runtime settings are refused by Pipeline in Play Mode; pi-unity never exits Play Mode to read them.
|
|
39
|
+
- `unity_pipeline_run_script` — compile one existing project `.cs` file in Pipeline's ephemeral in-memory mode and invoke a named static entry point; supports bounded JSON arguments and compile-only `dryRun`. It deliberately does not expose hotpatch.
|
|
39
40
|
|
|
40
41
|
Connected recompilation follows Unity's Script Changes While Playing policy and never preemptively sends `editor_stop`. Connected tests may exit Play Mode through advertised `editor_stop` when necessary, then verify Edit Mode before dispatch. Play Mode exit is allowed by default; `/unity-playmode-exit allow|disallow|status` controls the current session.
|
|
41
42
|
|
|
42
|
-
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode.
|
|
43
|
+
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. Pipeline 0.6 can report busy for a modal dialog as well as startup settling, so ambiguous busy responses are surfaced and never blindly retried. Pipeline 0.7 improves recovery of standing compile errors and console output. For non-development Player builds, Pipeline also requires the `ENABLE_RUNTIME_PIPELINE` scripting define; enabling its runtime setting alone is insufficient.
|
|
43
44
|
|
|
44
45
|
### Editor and batchmode
|
|
45
46
|
|
|
@@ -47,7 +48,7 @@ A timeout is uncertain: work may still be running. The tools do not silently can
|
|
|
47
48
|
- `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
|
|
48
49
|
- `unity_inspect_artifacts` — validate existing normalized JSON test artifacts, Unity Test Framework XML and Unity logs without launching Unity. `details.status` describes inspection; `details.testOutcome` describes the tests, including failures and uncertainty. A valid failed-test artifact is a successful inspection, not a passing run.
|
|
49
50
|
|
|
50
|
-
Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection; missing requested files fail inspection. With all paths omitted, `latestFromLogs`
|
|
51
|
+
Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection and link expansion; missing requested files fail inspection. With all paths omitted, `latestFromLogs` selects one newest top-level JSON (mtime then filename) and only its contained declared NUnit/log links. Without JSON it selects XML alone, then log context. Historical selection cannot establish current-run identity. Mixed JSON/XML evidence must agree; without an explicit native artifact link, matching counts alone leave correlation uncertain.
|
|
51
52
|
|
|
52
53
|
Use `unity_run_tests` for all ordinary Unity Test Framework work. It writes a durable normalized JSON result under `Logs/`; isolated `unity test` runs retain requested native reports. Connected Pipeline is selected only for compatible requests. A reachable Editor is never closed automatically to obtain isolated-only features.
|
|
53
54
|
|
|
@@ -84,6 +85,7 @@ Operation-specific recovery belongs to the operational skill. `unity-debugging`
|
|
|
84
85
|
| Existing failed-run artifacts | `unity_inspect_artifacts` |
|
|
85
86
|
| Project-specific C# query or operation | `unity_pipeline_eval` |
|
|
86
87
|
| Supported structured project inspection | `unity_pipeline_inspect` |
|
|
88
|
+
| Explicitly requested existing C# builder script | `unity_pipeline_run_script` |
|
|
87
89
|
| Open the GUI explicitly | `unity_open_editor` or `/unity-open` |
|
|
88
90
|
|
|
89
91
|
Pass an explicit project `path` when multiple copies may be discovered. Pipeline routing compares canonical paths so similarly named copies are not treated as interchangeable.
|
|
@@ -111,8 +113,14 @@ Another connected client is not a project lock. When Pipeline returns stable cor
|
|
|
111
113
|
{ code: "var s = UnityEngine.Application.dataPath; return s.Length;" }
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
`timeoutSeconds` remains the host/CLI wait. On exact copies that advertise raw argv and the verified eval `code`/integer-`timeout` signature, `handlerTimeoutMilliseconds` (1–86,400,000) also sets Pipeline's dispatcher wait through its positional command argument. A shorter host timeout may still win. This server wait can prevent queued work from starting, but cannot cancel eval code that already began on Unity's main thread; treat expiry as uncertain and never retry or fall back.
|
|
117
|
+
|
|
114
118
|
Use `unity_pipeline_inspect` when a purpose-built structured command fits. Use eval for bounded project-specific work that matches the user's intent. Prefer typed tools when they provide stronger lifecycle, polling, validation, or recovery semantics.
|
|
115
119
|
|
|
120
|
+
### Pipeline run_script
|
|
121
|
+
|
|
122
|
+
`unity_pipeline_run_script` is arbitrary code execution, not a sandbox or read-only inspection. Use it only when the user explicitly asks to run that existing file; obtain explicit authorization for lifecycle, settings, asset, build, test, or destructive mutations. It validates the exact project copy and advertised command twice, accepts a single existing `.cs` file inside the project, uses only `mode: ephemeral`, and never launches, saves, cancels, retries, falls back, hotpatches, or exits Play Mode. `dryRun: true` compiles without loading or executing the assembly.
|
|
123
|
+
|
|
116
124
|
Rejected eval and inspection results are native Pi tool failures (`isError: true`) via the documented `tool_result` middleware, with structured rejection codes and bounded diagnostics retained. Pre-dispatch rejection does not execute the command; a timeout or dispatch failure can leave effects uncertain and never triggers retry or fallback.
|
|
117
125
|
|
|
118
126
|
## Launch and process safeguards
|
|
@@ -230,7 +238,9 @@ The registry-clean `package-lock.json` is committed. Optional development packag
|
|
|
230
238
|
|
|
231
239
|
## Unity Pipeline project side effect
|
|
232
240
|
|
|
233
|
-
|
|
241
|
+
Legacy Pipeline releases (including `0.3.1-exp.1`) assigned `Application.runInBackground = true`, which Unity persisted as `PlayerSettings.runInBackground` in `ProjectSettings/ProjectSettings.asset`. Pipeline 0.6 restores the original value around server start/stop. Review tracked settings changes when installing older releases; Pipeline 0.6 reports non-automated Editor state as descriptor `info` rather than a console warning.
|
|
242
|
+
|
|
243
|
+
Pipeline 0.6 records local editor eval usage in `Library/Pipeline/eval-usage.jsonl`; raw source is not stored unless the Pipeline **Store Eval Source** setting is enabled. `UNITY_NO_CLI_INVOKED_TELEMETRY=1` opts out only of Unity CLI's per-invocation telemetry event; analytics and crash-reporting controls are separate and unchanged. Pipeline compact HTTP responses are accepted whether returned directly or inside the CLI's documented `data` wrapper; this does not assert that CLI wrapping has changed. For installed CLI/skill information, use read-only `unity skill show --list` or `unity skill show --path <path>`.
|
|
234
244
|
|
|
235
245
|
## License
|
|
236
246
|
|
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { mkdir, readFile, readdir, realpath, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import {
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type UnityParsedTestResults,
|
|
21
21
|
} from "./src/unity-batchmode";
|
|
22
22
|
import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
|
|
23
|
-
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
23
|
+
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPipelineRunScript, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
24
24
|
import { launchUnityCliOpenDetached } from "./src/unity-launch";
|
|
25
25
|
import { createUnityBatchmodeCommand, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-editor-fallback";
|
|
26
26
|
import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
|
|
@@ -32,7 +32,7 @@ import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestRep
|
|
|
32
32
|
import { validateNormalizedUnityTestArtifact } from "./src/unity-artifact-inspection";
|
|
33
33
|
import { UNITY_TEST_MAX_ARTIFACT_BYTES } from "./src/unity-tests";
|
|
34
34
|
import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
|
|
35
|
-
import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
35
|
+
import { runUnityPipelineRecompile, runUnityPipelineTests, UnityPipelineTerminalTestEvidenceError, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
36
36
|
import {
|
|
37
37
|
createOptionalIntegrationRegistryV1,
|
|
38
38
|
isOptionalIntegrationActive,
|
|
@@ -70,7 +70,7 @@ const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same
|
|
|
70
70
|
const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
|
|
71
71
|
|
|
72
72
|
type UnityToolDetails = {
|
|
73
|
-
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline" | "tests";
|
|
73
|
+
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline_run_script" | "pipeline" | "tests";
|
|
74
74
|
projectRoot: string;
|
|
75
75
|
unityVersion: string;
|
|
76
76
|
editorPath: string;
|
|
@@ -101,7 +101,11 @@ type UnityToolDetails = {
|
|
|
101
101
|
cliCapabilities?: UnityCliProjectCapabilities;
|
|
102
102
|
pipelineInspection?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
103
103
|
pipelineEval?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
104
|
+
pipelineRunScript?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
|
|
104
105
|
pipeline?: UnityPipelineOperationDetails;
|
|
106
|
+
testResult?: NormalizedUnityTestResult;
|
|
107
|
+
artifactPath?: string;
|
|
108
|
+
route?: "connected" | "isolated";
|
|
105
109
|
};
|
|
106
110
|
|
|
107
111
|
const LAUNCHER_SCHEMA = Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { description: "Launch backend. Defaults to auto, which prefers the Unity CLI and falls back to direct editor executable launch when the CLI is unavailable." }));
|
|
@@ -164,7 +168,17 @@ const PIPELINE_TEST_PARAMS = Type.Object({
|
|
|
164
168
|
const PIPELINE_EVAL_PARAMS = Type.Object({
|
|
165
169
|
path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
166
170
|
code: Type.String({ minLength: 1, maxLength: 4000, description: "Bounded C# source for advertised Pipeline eval. Roslyn compiles it on the connected Editor main thread; include an explicit return value when evidence is needed." }),
|
|
167
|
-
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
|
|
171
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval host/CLI deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
|
|
172
|
+
handlerTimeoutMilliseconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400000, description: "Optional advertised Pipeline eval dispatcher wait in milliseconds. Forwarded only when the exact copy advertises raw argv and the verified code/timeout signature; it cannot cancel code already started on Unity's main thread." })),
|
|
173
|
+
}, { additionalProperties: false });
|
|
174
|
+
|
|
175
|
+
const PIPELINE_RUN_SCRIPT_PARAMS = Type.Object({
|
|
176
|
+
path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
177
|
+
file: Type.String({ minLength: 1, maxLength: 1000, description: "Existing .cs file under the selected project root. The file is compiled in memory; it is not written or imported." }),
|
|
178
|
+
entry: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Named static entry point. Omit only when Pipeline can select Main or an unambiguous public static method." })),
|
|
179
|
+
args: Type.Optional(Type.Array(Type.Any(), { maxItems: 32, description: "Bounded JSON arguments coerced by Pipeline to the static entry-point parameter types." })),
|
|
180
|
+
dryRun: Type.Optional(Type.Boolean({ default: false, description: "Compile only; do not load the assembly or invoke the entry point." })),
|
|
181
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 30, description: "Connected run_script deadline. A timeout is uncertain and does not cancel the script." })),
|
|
168
182
|
}, { additionalProperties: false });
|
|
169
183
|
|
|
170
184
|
const PIPELINE_INSPECTION_PARAMS = Type.Object({
|
|
@@ -186,7 +200,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
|
186
200
|
testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
187
201
|
normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
|
|
188
202
|
logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
189
|
-
latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted,
|
|
203
|
+
latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted, select one newest top-level Logs JSON (mtime then filename) and only its declared contained NUnit/log links. Without JSON, select newest XML alone, otherwise newest log context. Latest files are not proof of a shared run." })),
|
|
190
204
|
maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
|
|
191
205
|
maxChars: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, default: 6000, description: "Maximum log/output characters to include." })),
|
|
192
206
|
});
|
|
@@ -676,8 +690,9 @@ async function findNewestFile(root: string, suffixes: string[]): Promise<string
|
|
|
676
690
|
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
677
691
|
try {
|
|
678
692
|
entries = await readdir(root, { withFileTypes: true });
|
|
679
|
-
} catch {
|
|
680
|
-
return undefined;
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
695
|
+
throw error;
|
|
681
696
|
}
|
|
682
697
|
|
|
683
698
|
const files = await Promise.all(entries
|
|
@@ -687,7 +702,16 @@ async function findNewestFile(root: string, suffixes: string[]): Promise<string
|
|
|
687
702
|
const stats = await stat(fullPath);
|
|
688
703
|
return { fullPath, mtimeMs: stats.mtimeMs };
|
|
689
704
|
}));
|
|
690
|
-
return files.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.fullPath;
|
|
705
|
+
return files.sort((left, right) => right.mtimeMs - left.mtimeMs || left.fullPath.localeCompare(right.fullPath))[0]?.fullPath;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function resolveLinkedArtifact(projectRoot: string, linkPath: string): Promise<string> {
|
|
709
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
710
|
+
const candidate = resolve(projectRoot, linkPath);
|
|
711
|
+
const canonicalCandidate = await realpath(candidate);
|
|
712
|
+
const relativePath = relative(canonicalRoot, canonicalCandidate);
|
|
713
|
+
if (!relativePath || isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) throw new Error(`Linked artifact escapes the project root: ${linkPath}`);
|
|
714
|
+
return canonicalCandidate;
|
|
691
715
|
}
|
|
692
716
|
|
|
693
717
|
function resolveArtifactPath(cwd: string, projectRoot: string, value: string | undefined): string | undefined {
|
|
@@ -716,12 +740,14 @@ async function buildArtifactInspectionReport(
|
|
|
716
740
|
// An exact artifact request must not silently recruit unrelated latest evidence.
|
|
717
741
|
const useLatest = params.latestFromLogs !== false && ![params.testResultsPath, params.logFilePath, params.normalizedResultPath].some(value => value?.trim());
|
|
718
742
|
const logsRoot = join(candidate.projectRoot, "Logs");
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
|
|
722
|
-
?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
|
|
743
|
+
let testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath);
|
|
744
|
+
let logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath);
|
|
723
745
|
const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
|
|
724
746
|
?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
|
|
747
|
+
if (useLatest && !normalizedResultPath) {
|
|
748
|
+
testResultsPath = await findNewestFile(logsRoot, [".xml"]);
|
|
749
|
+
if (!testResultsPath) logFilePath = await findNewestFile(logsRoot, [".log", ".txt"]);
|
|
750
|
+
}
|
|
725
751
|
let normalized: NormalizedUnityTestResult | undefined;
|
|
726
752
|
const evidenceErrors: string[] = [];
|
|
727
753
|
const evidenceWarnings: string[] = [];
|
|
@@ -729,6 +755,11 @@ async function buildArtifactInspectionReport(
|
|
|
729
755
|
try {
|
|
730
756
|
if ((await stat(normalizedResultPath)).size > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized artifact exceeds its size limit.");
|
|
731
757
|
normalized = validateNormalizedUnityTestArtifact(JSON.parse(await readFile(normalizedResultPath, "utf8")));
|
|
758
|
+
// Stored backend links, not unrelated directory recency, are the only automatic companions.
|
|
759
|
+
if (useLatest) {
|
|
760
|
+
if (normalized.backendArtifacts?.nunit) testResultsPath = await resolveLinkedArtifact(candidate.projectRoot, normalized.backendArtifacts.nunit);
|
|
761
|
+
if (normalized.backendArtifacts?.log) logFilePath = await resolveLinkedArtifact(candidate.projectRoot, normalized.backendArtifacts.log);
|
|
762
|
+
}
|
|
732
763
|
} catch (error) {
|
|
733
764
|
evidenceErrors.push(`Normalized test result could not be loaded/validated: ${normalizedResultPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
734
765
|
}
|
|
@@ -759,7 +790,13 @@ async function buildArtifactInspectionReport(
|
|
|
759
790
|
}
|
|
760
791
|
if ((normalized.outcome === "passed" || normalized.outcome === "passed_with_flakes") && parsedTestResults.failedTests.length > 0) evidenceErrors.push("Conflicting normalized/XML evidence: XML contains failed tests.");
|
|
761
792
|
const linkedXml = normalized.backendArtifacts?.nunit;
|
|
762
|
-
if (linkedXml &&
|
|
793
|
+
if (linkedXml && testResultsPath) {
|
|
794
|
+
try {
|
|
795
|
+
if (await resolveLinkedArtifact(candidate.projectRoot, linkedXml) !== await realpath(testResultsPath)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
|
|
796
|
+
} catch (error) {
|
|
797
|
+
evidenceErrors.push(`Normalized NUnit link could not be resolved: ${linkedXml}: ${error instanceof Error ? error.message : String(error)}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
763
800
|
if (!linkedXml) {
|
|
764
801
|
evidenceWarnings.push("Normalized JSON and XML have no shared run identity; matching counts alone do not correlate these files.");
|
|
765
802
|
testOutcome = "uncertain";
|
|
@@ -1220,7 +1257,25 @@ async function runUnifiedUnityTests(
|
|
|
1220
1257
|
}
|
|
1221
1258
|
const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
|
|
1222
1259
|
if (route === "connected") {
|
|
1223
|
-
|
|
1260
|
+
let result;
|
|
1261
|
+
try {
|
|
1262
|
+
result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
if (!(error instanceof UnityPipelineTerminalTestEvidenceError)) throw error;
|
|
1265
|
+
const outcome = error.evidence.outcome;
|
|
1266
|
+
const normalized: NormalizedUnityTestResult = {
|
|
1267
|
+
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
1268
|
+
selection: { testFilters: request.testFilters, testCategories: request.testCategories },
|
|
1269
|
+
durationSeconds: error.evidence.elapsedSeconds, outcome, summary: {}, tests: error.evidence.testRecords,
|
|
1270
|
+
diagnostics: [error.evidence.reason, ...error.evidence.observations, ...error.evidence.warnings].slice(0, 8),
|
|
1271
|
+
};
|
|
1272
|
+
let artifactPath: string;
|
|
1273
|
+
try { artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized); } catch (persistenceError) {
|
|
1274
|
+
throw new Error(`${error.evidence.reason} Durable terminal evidence could not be persisted: ${persistenceError instanceof Error ? persistenceError.message : String(persistenceError)}`);
|
|
1275
|
+
}
|
|
1276
|
+
const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Terminal evidence was incomplete or non-passing; no retry, fallback, or replay was performed. Normalized artifact: ${artifactPath}`;
|
|
1277
|
+
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "failed", testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1278
|
+
}
|
|
1224
1279
|
const counts = result.details.counts!;
|
|
1225
1280
|
const normalized: NormalizedUnityTestResult = {
|
|
1226
1281
|
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
@@ -1294,9 +1349,9 @@ function renderUnityPipelineResult(result: any, options: { expanded: boolean; is
|
|
|
1294
1349
|
const counts = pipeline.counts;
|
|
1295
1350
|
const passed = counts?.passed === undefined || counts?.total === undefined ? "tests completed" : `${counts.passed}/${counts.total} passed`;
|
|
1296
1351
|
text = `${icon} ${theme.fg("toolTitle", theme.bold(`Unity ${pipeline.testPlatform ?? ""} tests`.trim()))} ${theme.fg("accent", passed)}${theme.fg("muted", ` • ${pipeline.elapsedSeconds.toFixed(1)}s`)}`;
|
|
1297
|
-
} else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection") {
|
|
1298
|
-
const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.pipelineInspection;
|
|
1299
|
-
const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : "Unity Pipeline Inspection";
|
|
1352
|
+
} else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection" || details.mode === "pipeline_run_script") {
|
|
1353
|
+
const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.mode === "pipeline_run_script" ? details.pipelineRunScript : details.pipelineInspection;
|
|
1354
|
+
const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : details.mode === "pipeline_run_script" ? "Unity Pipeline Run Script" : "Unity Pipeline Inspection";
|
|
1300
1355
|
const summary = output?.outcome === "dispatched" ? output.output || "(no bounded output returned)" : output?.message || primaryText;
|
|
1301
1356
|
text = `${icon} ${theme.fg("toolTitle", theme.bold(label))}\n ${theme.fg("toolOutput", compactUnityRendererValue(summary, 240))}`;
|
|
1302
1357
|
} else {
|
|
@@ -1337,7 +1392,9 @@ function renderUnityToolResult(result: any, expanded: boolean, theme: any): Text
|
|
|
1337
1392
|
? "Unity Pipeline Inspection"
|
|
1338
1393
|
: details.mode === "pipeline_eval"
|
|
1339
1394
|
? "Unity Pipeline Eval"
|
|
1340
|
-
: details.mode === "
|
|
1395
|
+
: details.mode === "pipeline_run_script"
|
|
1396
|
+
? "Unity Pipeline Run Script"
|
|
1397
|
+
: details.mode === "pipeline"
|
|
1341
1398
|
? "Unity Pipeline"
|
|
1342
1399
|
: getBatchmodeVariantLabel(details.args);
|
|
1343
1400
|
const projectLabel = details.projectRoot ?? "(unknown project)";
|
|
@@ -1391,7 +1448,9 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1391
1448
|
pi.on("tool_result", (event) => {
|
|
1392
1449
|
const details = event.details as UnityToolDetails | undefined;
|
|
1393
1450
|
if ((event.toolName === "unity_pipeline_eval" && details?.mode === "pipeline_eval" && details.pipelineEval?.outcome === "rejected")
|
|
1394
|
-
|| (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")
|
|
1451
|
+
|| (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")
|
|
1452
|
+
|| (event.toolName === "unity_pipeline_run_script" && details?.mode === "pipeline_run_script" && details.pipelineRunScript?.outcome === "rejected")
|
|
1453
|
+
|| (event.toolName === "unity_run_tests" && details?.mode === "tests" && details.testResult?.outcome !== "passed" && details.testResult?.outcome !== "passed_with_flakes" && details.testResult?.outcome !== "empty_selection")) {
|
|
1395
1454
|
return { isError: true };
|
|
1396
1455
|
}
|
|
1397
1456
|
});
|
|
@@ -1654,12 +1713,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1654
1713
|
pi.registerTool({
|
|
1655
1714
|
name: "unity_pipeline_eval",
|
|
1656
1715
|
label: "Unity Pipeline Eval",
|
|
1657
|
-
description: "Execute one bounded C# snippet through advertised eval in an already-open exact Unity Pipeline Editor.",
|
|
1716
|
+
description: "Execute one bounded C# snippet through advertised eval in an already-open exact Unity Pipeline Editor. timeoutSeconds bounds pi-unity and Unity CLI waits; optional handlerTimeoutMilliseconds is forwarded only through a verified Pipeline raw-argv timeout contract and cannot cancel code already started on Unity's main thread.",
|
|
1658
1717
|
promptSnippet: "Query or operate on an already-open exact Unity project through Pipeline's Roslyn C# REPL.",
|
|
1659
1718
|
promptGuidelines: [
|
|
1660
1719
|
"Use unity_pipeline_eval for project-specific properties, APIs, and operations that advertised typed commands do not cover. It revalidates exact-copy identity and advertised eval immediately before dispatch.",
|
|
1661
1720
|
"Pipeline eval compiles arbitrary C# with Roslyn on the Editor main thread. Include an explicit return value for observable evidence; normal property reads and local-variable snippets are supported.",
|
|
1662
1721
|
"Eval is not statically read-only. Follow user intent and project guidance, and obtain explicit authorization before lifecycle, persistent-setting, destructive, asset, scene-save, package, build, or test mutations.",
|
|
1722
|
+
"timeoutSeconds bounds the host and Unity CLI wait. When supplied, handlerTimeoutMilliseconds bounds only the verified Pipeline dispatcher wait; a shorter host deadline may still win, and a server wait expiry cannot cancel code already started on Unity's main thread.",
|
|
1663
1723
|
"A rejected, malformed, failing, or timed-out eval is not success; do not silently retry it through another route.",
|
|
1664
1724
|
],
|
|
1665
1725
|
parameters: PIPELINE_EVAL_PARAMS,
|
|
@@ -1672,6 +1732,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1672
1732
|
unityVersion: await requireManualUnityVersion(candidate),
|
|
1673
1733
|
command: "eval",
|
|
1674
1734
|
evalSnippet: params.code,
|
|
1735
|
+
handlerTimeoutMilliseconds: params.handlerTimeoutMilliseconds,
|
|
1675
1736
|
}, {
|
|
1676
1737
|
execute: createPlanningUnityCliExecutor(pi),
|
|
1677
1738
|
signal,
|
|
@@ -1701,6 +1762,38 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1701
1762
|
},
|
|
1702
1763
|
});
|
|
1703
1764
|
|
|
1765
|
+
pi.registerTool({
|
|
1766
|
+
name: "unity_pipeline_run_script",
|
|
1767
|
+
label: "Unity Pipeline Run Script",
|
|
1768
|
+
description: "Compile one existing project C# file in memory and invoke its advertised static entry point through Pipeline 0.6 run_script.",
|
|
1769
|
+
promptSnippet: "Run one explicitly requested existing C# builder script through an already-open exact Unity Pipeline Editor.",
|
|
1770
|
+
promptGuidelines: [
|
|
1771
|
+
"unity_pipeline_run_script is arbitrary code execution, not read-only and not a sandbox. Use it only for explicit user intent; obtain explicit authorization for lifecycle, settings, asset, build, test, or destructive mutations.",
|
|
1772
|
+
"It uses Pipeline's ephemeral mode only: no hotpatch, no source upload, no asset import, no domain reload, no retry, fallback, cancellation, launch, save, or Play Mode exit.",
|
|
1773
|
+
"Use dryRun=true to compile without loading or executing the script. A failure, malformed result, or timeout never establishes success; a timeout may still have running script effects.",
|
|
1774
|
+
],
|
|
1775
|
+
parameters: PIPELINE_RUN_SCRIPT_PARAMS,
|
|
1776
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1777
|
+
throwIfAborted(signal);
|
|
1778
|
+
const { candidate } = await resolveProjectCandidate(ctx, params.path);
|
|
1779
|
+
const file = isAbsolute(params.file) ? params.file : resolve(candidate.projectRoot, params.file);
|
|
1780
|
+
const result = await dispatchUnityPipelineRunScript({
|
|
1781
|
+
projectRoot: candidate.projectRoot,
|
|
1782
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1783
|
+
file,
|
|
1784
|
+
entry: params.entry,
|
|
1785
|
+
args: params.args,
|
|
1786
|
+
dryRun: params.dryRun,
|
|
1787
|
+
}, { execute: createPlanningUnityCliExecutor(pi), signal, timeout: (params.timeoutSeconds ?? 30) * 1000 });
|
|
1788
|
+
const text = result.outcome === "dispatched"
|
|
1789
|
+
? `Unity Pipeline run_script ${params.dryRun ? "compile-only completed" : "completed"}.\n${result.output || "(no bounded output returned)"}`
|
|
1790
|
+
: `Unity Pipeline run_script rejected: ${result.code}\n${result.message}`;
|
|
1791
|
+
return { content: [{ type: "text", text }], details: { mode: "pipeline_run_script", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: result.outcome === "dispatched" ? "passed" : "failed", pipelineRunScript: result } satisfies UnityToolDetails };
|
|
1792
|
+
},
|
|
1793
|
+
renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_script", args, theme, context); },
|
|
1794
|
+
renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1704
1797
|
pi.registerTool({
|
|
1705
1798
|
name: "unity_pipeline_inspect",
|
|
1706
1799
|
label: "Unity Pipeline Inspect",
|
|
@@ -1759,7 +1852,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1759
1852
|
"Use unity_inspect_artifacts after Unity failures when existing -testResults or -logFile artifacts need concise parsing without another Unity launch.",
|
|
1760
1853
|
"Prefer unity_inspect_artifacts over ad hoc bash parsing of Unity XML/log files when paths are known or Logs/ contains recent artifacts.",
|
|
1761
1854
|
"unity_inspect_artifacts does not launch Unity and is safe to use even when the Unity project is busy.",
|
|
1762
|
-
"Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. Latest files are not current-run identity.",
|
|
1855
|
+
"Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. With all paths omitted, latest selection uses one JSON and its contained declared links, otherwise XML alone then log context; explicit paths disable selection/link expansion. Latest files are not current-run identity.",
|
|
1763
1856
|
],
|
|
1764
1857
|
parameters: INSPECT_ARTIFACTS_PARAMS,
|
|
1765
1858
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-unity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.ts"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
|
|
21
|
+
"test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/unity-package-validation.test.ts",
|
|
22
22
|
"eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -16,7 +16,7 @@ Use one typed tool call for each supported connected operation:
|
|
|
16
16
|
|
|
17
17
|
These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
|
|
18
18
|
|
|
19
|
-
A timeout or
|
|
19
|
+
A timeout, malformed response, or busy response is uncertain: the Unity operation may still be running or blocked by a modal dialog. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. Pipeline 0.6 uses busy for modal blocks and no source-verified discriminator is available through this transport, so pi-unity does not blindly retry it.
|
|
20
20
|
|
|
21
21
|
## Preconditions and boundaries
|
|
22
22
|
|
|
@@ -67,4 +67,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
|
|
|
67
67
|
|
|
68
68
|
Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors in one run, retries, sharding, coverage, or required NUnit/JUnit evidence. Multiple independent fixtures alone do not require closing a reachable Editor: use the serial recipe above. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
|
|
69
69
|
|
|
70
|
-
Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows
|
|
70
|
+
Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows. Its `timeoutSeconds` range is 1–86,400 seconds and bounds pi-unity/Unity CLI waits. Optional `handlerTimeoutMilliseconds` (1–86,400,000) is forwarded only when the exact reachable Pipeline advertises raw argv and the verified eval `code`/integer-`timeout` signature; it bounds Pipeline's dispatcher wait, while a shorter host wait can still win. A dispatcher expiry cannot cancel code already started on Unity's main thread, so the effect remains uncertain and no retry or fallback is allowed. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
|
|
@@ -9,6 +9,16 @@ const strings = (value: unknown): value is string[] => Array.isArray(value) && v
|
|
|
9
9
|
const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
|
|
10
10
|
&& !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
|
|
11
11
|
|
|
12
|
+
/** Shared count/retained-record invariant for durable validation and Pipeline pass acceptance. */
|
|
13
|
+
export function hasConsistentUnityTestCounts(summary: Record<string, unknown>, tests: Array<{ status: string }>): boolean {
|
|
14
|
+
const total = summary.total; const passed = summary.passed; const failed = summary.failed;
|
|
15
|
+
const skipped = summary.skipped; const inconclusive = summary.inconclusive;
|
|
16
|
+
if (![total, passed, failed, skipped, inconclusive].every(value => value === undefined || count(value))) return false;
|
|
17
|
+
if (count(total) && [passed, failed, skipped, inconclusive].reduce<number>((sum, value) => sum + (count(value) ? value : 0), 0) > total) return false;
|
|
18
|
+
if (count(total) && tests.length > total) return false;
|
|
19
|
+
return [["passed", passed], ["failed", failed], ["skipped", skipped], ["inconclusive", inconclusive]].every(([status, limit]) => !count(limit) || tests.filter(test => status === "passed" ? /^(?:passed|success)$/i.test(test.status) : test.status.toLowerCase() === status).length <= limit);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/** Read the durable schema, not a transport response. Missing optional counts remain unknown.
|
|
13
23
|
* Test records may be bounded or absent: never require tests.length === summary.total.
|
|
14
24
|
*/
|
|
@@ -25,10 +35,7 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
|
|
|
25
35
|
for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
|
|
26
36
|
if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
|
|
27
37
|
}
|
|
28
|
-
if (
|
|
29
|
-
const accounted = [summary.passed, summary.failed, summary.skipped, summary.inconclusive].reduce<number>((sum, item) => sum + (count(item) ? item : 0), 0);
|
|
30
|
-
if (accounted > summary.total) invalid("summary counts exceed total");
|
|
31
|
-
}
|
|
38
|
+
if (!hasConsistentUnityTestCounts(summary, [])) invalid("summary counts are inconsistent");
|
|
32
39
|
if (!Array.isArray(result.tests)) invalid("tests must be an array");
|
|
33
40
|
const tests = result.tests as unknown[];
|
|
34
41
|
for (const test of tests) {
|
|
@@ -38,14 +45,11 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
|
|
|
38
45
|
if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
|
|
39
46
|
if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
|
|
40
47
|
}
|
|
41
|
-
if (
|
|
48
|
+
if (!hasConsistentUnityTestCounts(summary, tests as Array<{ status: string }>)) invalid("test records conflict with summary counts");
|
|
42
49
|
const typed = result as unknown as NormalizedUnityTestResult;
|
|
43
|
-
for (const [status, key] of [["passed", "passed"], ["failed", "failed"], ["skipped", "skipped"], ["inconclusive", "inconclusive"]] as const) {
|
|
44
|
-
const observed = typed.tests.filter(test => test.status.toLowerCase() === status).length;
|
|
45
|
-
if (count(summary[key]) && observed > summary[key]) invalid(`test records conflict with summary.${key}`);
|
|
46
|
-
}
|
|
47
50
|
if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
|
|
48
51
|
if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
|
|
52
|
+
if (result.diagnostics !== undefined && (!Array.isArray(result.diagnostics) || result.diagnostics.length > 8 || !result.diagnostics.every(item => typeof item === "string" && !!item.trim() && item.length <= 1_000))) invalid("diagnostics must contain at most eight bounded strings");
|
|
49
53
|
for (const key of ["startedAt", "completedAt"]) if (result[key] !== undefined && (typeof result[key] !== "string" || !Number.isFinite(Date.parse(result[key] as string)))) invalid(`${key} must be a timestamp`);
|
|
50
54
|
if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
|
|
51
55
|
if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
|
package/src/unity-cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { applyDefaultUnityBatchmodeArgs, buildUnityBatchmodeArgs, projectPathsMatch } from "./unity-core";
|
|
5
5
|
import type { RunningUnityProcess } from "./unity-processes";
|
|
6
6
|
|
|
@@ -46,6 +46,13 @@ export type UnityCliPipelineInstance = {
|
|
|
46
46
|
|
|
47
47
|
export type UnityCliDiscoveryState = "not_attempted" | "available" | "absent" | "timeout" | "unavailable";
|
|
48
48
|
|
|
49
|
+
export type UnityCliCommandParameter = {
|
|
50
|
+
name: string;
|
|
51
|
+
type: string;
|
|
52
|
+
required: boolean;
|
|
53
|
+
defaultValue?: unknown;
|
|
54
|
+
};
|
|
55
|
+
|
|
49
56
|
export type UnityCliProjectCapabilities = {
|
|
50
57
|
cliAvailable: boolean;
|
|
51
58
|
cliVersion?: string;
|
|
@@ -56,6 +63,12 @@ export type UnityCliProjectCapabilities = {
|
|
|
56
63
|
advertisedCommands: string[];
|
|
57
64
|
advertisedCommandCount: number;
|
|
58
65
|
advertisedCommandsTruncated: boolean;
|
|
66
|
+
/** Display-oriented command descriptors. They are never capability evidence. */
|
|
67
|
+
advertisedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
|
|
68
|
+
/** Complete, bounded, unambiguous descriptors eligible for exact capability gates. */
|
|
69
|
+
verifiedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
|
|
70
|
+
/** True only when the exact live Pipeline descriptor advertises raw argv support. */
|
|
71
|
+
pipelineSupportsExecArgv?: boolean;
|
|
59
72
|
commandDiscoveryAttempted: boolean;
|
|
60
73
|
commandDiscoverySucceeded: boolean;
|
|
61
74
|
latestPipelineVersion?: string;
|
|
@@ -265,10 +278,10 @@ export function parseUnityCliStatusOutput(output: string, projectRoot: string):
|
|
|
265
278
|
|
|
266
279
|
export async function listRunningUnityCliEditorsForProject(
|
|
267
280
|
projectRoot: string,
|
|
268
|
-
options: { cliCommand?: string; timeout?: number } = {},
|
|
281
|
+
options: { cliCommand?: string; timeout?: number; execute?: UnityCliExecutor } = {},
|
|
269
282
|
): Promise<{ processes: RunningUnityProcess[]; warning?: string }> {
|
|
270
283
|
const command = resolveUnityCliCommand(options);
|
|
271
|
-
const result = await execFileCollect(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
|
|
284
|
+
const result = await (options.execute ?? execFileCollect)(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
|
|
272
285
|
timeout: options.timeout ?? 5000,
|
|
273
286
|
});
|
|
274
287
|
|
|
@@ -277,11 +290,14 @@ export async function listRunningUnityCliEditorsForProject(
|
|
|
277
290
|
}
|
|
278
291
|
|
|
279
292
|
const processes = parseUnityCliStatusOutput(result.stdout, projectRoot);
|
|
280
|
-
|
|
281
|
-
|
|
293
|
+
const payload = parseJsonObject(result.stdout);
|
|
294
|
+
const statusWarnings = envelopeMessages(payload, "warnings");
|
|
295
|
+
if (statusWarnings.length > 0) {
|
|
296
|
+
return { processes, warning: `Unity CLI status response is incomplete; process absence is uncertain: ${statusWarnings.join("; ")}` };
|
|
282
297
|
}
|
|
298
|
+
if (processes.length > 0) return { processes };
|
|
299
|
+
|
|
283
300
|
|
|
284
|
-
const payload = parseJsonObject(result.stdout);
|
|
285
301
|
const errors = Array.isArray(payload?.errors) ? payload.errors : [];
|
|
286
302
|
const onlyNoInstances = errors.some((entry) => getRecord(entry)?.code === "STATUS_NO_INSTANCES");
|
|
287
303
|
if (result.error && !onlyNoInstances) {
|
|
@@ -343,10 +359,16 @@ export function parseUnityCliPipelineListOutput(output: string, projectRoot: str
|
|
|
343
359
|
type UnityCliCommandCatalog = {
|
|
344
360
|
valid: boolean;
|
|
345
361
|
commands: string[];
|
|
362
|
+
parametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
|
|
363
|
+
verifiedParametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
|
|
346
364
|
total: number;
|
|
347
365
|
truncated: boolean;
|
|
348
366
|
};
|
|
349
367
|
|
|
368
|
+
function isBoundedDescriptorString(value: unknown): value is string {
|
|
369
|
+
return typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
370
|
+
}
|
|
371
|
+
|
|
350
372
|
function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
|
|
351
373
|
const payload = parseJsonObject(output);
|
|
352
374
|
const data = getRecord(payload?.data);
|
|
@@ -363,21 +385,53 @@ function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
|
|
|
363
385
|
candidates.push(...value);
|
|
364
386
|
}
|
|
365
387
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
388
|
+
|
|
389
|
+
const parametersByCommand: Record<string, readonly UnityCliCommandParameter[]> = {};
|
|
390
|
+
const verifiedCandidates = new Map<string, Array<readonly UnityCliCommandParameter[] | undefined>>();
|
|
391
|
+
const names: string[] = [];
|
|
392
|
+
for (const entry of candidates) {
|
|
393
|
+
const record = getRecord(entry);
|
|
394
|
+
const rawName = typeof entry === "string" ? entry : optionalString(record?.name, record?.command, record?.id);
|
|
395
|
+
if (!isBoundedDescriptorString(rawName)) continue;
|
|
371
396
|
const name = rawName.trim();
|
|
372
|
-
if (!
|
|
373
|
-
|
|
374
|
-
|
|
397
|
+
if (!isBoundedDescriptorString(name)) continue;
|
|
398
|
+
names.push(name);
|
|
399
|
+
|
|
400
|
+
// Preserve a best-effort descriptor for status display, but retain authoritative
|
|
401
|
+
// evidence only when every declared parameter is valid and the array was not cut.
|
|
402
|
+
const rawParameters = record?.parameters;
|
|
403
|
+
let parsed: UnityCliCommandParameter[] | undefined;
|
|
404
|
+
if (Array.isArray(rawParameters) && rawParameters.length <= 32) {
|
|
405
|
+
parsed = [];
|
|
406
|
+
for (const item of rawParameters) {
|
|
407
|
+
const parameter = getRecord(item);
|
|
408
|
+
const parameterName = parameter?.name;
|
|
409
|
+
const type = parameter?.type;
|
|
410
|
+
if (!isBoundedDescriptorString(parameterName) || !isBoundedDescriptorString(type) || typeof parameter?.required !== "boolean") {
|
|
411
|
+
parsed = undefined;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
parsed.push({ name: parameterName, type, required: parameter.required, ...(Object.prototype.hasOwnProperty.call(parameter, "defaultValue") ? { defaultValue: parameter.defaultValue } : {}) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!(name in parametersByCommand) && parsed) parametersByCommand[name] = parsed;
|
|
418
|
+
const entries = verifiedCandidates.get(name) ?? [];
|
|
419
|
+
entries.push(parsed);
|
|
420
|
+
verifiedCandidates.set(name, entries);
|
|
421
|
+
}
|
|
422
|
+
|
|
375
423
|
const unique = [...new Set(names)].sort((left, right) => left.localeCompare(right));
|
|
424
|
+
const commands = unique.slice(0, 256);
|
|
425
|
+
const verifiedParametersByCommand = Object.fromEntries([...verifiedCandidates].flatMap(([name, descriptors]) =>
|
|
426
|
+
descriptors.length === 1 && descriptors[0] ? [[name, descriptors[0]]] : [],
|
|
427
|
+
));
|
|
376
428
|
return {
|
|
377
429
|
valid: Boolean(payload?.success === true && valid),
|
|
378
|
-
commands
|
|
430
|
+
commands,
|
|
431
|
+
parametersByCommand: Object.fromEntries(commands.flatMap(name => parametersByCommand[name] ? [[name, parametersByCommand[name]]] : [])),
|
|
432
|
+
verifiedParametersByCommand,
|
|
379
433
|
total: unique.length,
|
|
380
|
-
truncated: unique.length > 256
|
|
434
|
+
truncated: unique.length > 256,
|
|
381
435
|
};
|
|
382
436
|
}
|
|
383
437
|
|
|
@@ -425,19 +479,40 @@ export async function readDeclaredUnityPipelineVersion(projectRoot: string): Pro
|
|
|
425
479
|
return optionalString(manifestDependencies?.["com.unity.pipeline"]);
|
|
426
480
|
}
|
|
427
481
|
|
|
482
|
+
/** Read only the public capability names; the descriptor's authentication token is never retained or surfaced. */
|
|
483
|
+
async function readPipelineDescriptorCapabilities(projectRoot: string): Promise<string[] | undefined> {
|
|
484
|
+
const descriptor = await readJsonFile(join(projectRoot, "Library", "Pipeline", ".unity-pipeline-port"));
|
|
485
|
+
if (!descriptor) return undefined;
|
|
486
|
+
const values = Array.isArray(descriptor.capabilities) ? descriptor.capabilities : [];
|
|
487
|
+
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value)))];
|
|
488
|
+
}
|
|
489
|
+
|
|
428
490
|
export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
|
|
429
491
|
const error = result.error as (NodeJS.ErrnoException & { killed?: boolean }) | undefined;
|
|
430
492
|
return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
|
|
431
493
|
}
|
|
432
494
|
|
|
495
|
+
const UNITY_CLI_MAX_DIAGNOSTICS = 8;
|
|
496
|
+
function envelopeMessages(payload: Record<string, unknown> | null, fieldName: "errors" | "warnings" | "info"): string[] {
|
|
497
|
+
const entries = Array.isArray(payload?.[fieldName]) ? payload[fieldName] : [];
|
|
498
|
+
return entries.slice(0, UNITY_CLI_MAX_DIAGNOSTICS).flatMap((entry): string[] => {
|
|
499
|
+
const item = getRecord(entry);
|
|
500
|
+
const message = optionalString(item?.message, item?.detail, typeof entry === "string" ? entry : undefined);
|
|
501
|
+
return message ? [redactUnityPlanningOutput(summarizeUnityCliText(message, 1_000, 10))] : [];
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Warnings make discovery incomplete; informational descriptor/envelope notes do not. */
|
|
506
|
+
function cliEnvelopeDiagnostics(payload: Record<string, unknown> | null): string[] {
|
|
507
|
+
return envelopeMessages(payload, "warnings").map(message => `warning: ${message}`);
|
|
508
|
+
}
|
|
509
|
+
function cliEnvelopeInfo(payload: Record<string, unknown> | null): string[] {
|
|
510
|
+
return envelopeMessages(payload, "info").map(message => `info: ${message}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
433
513
|
function cliFailureMessage(result: UnityCliExecResult): string | undefined {
|
|
434
514
|
const payload = parseJsonObject(result.stdout);
|
|
435
|
-
const
|
|
436
|
-
const messages = errors.flatMap((entry): string[] => {
|
|
437
|
-
const message = optionalString(getRecord(entry)?.message);
|
|
438
|
-
return message ? [message] : [];
|
|
439
|
-
});
|
|
440
|
-
const message = messages[0] ?? (result.stderr.trim() || result.error?.message);
|
|
515
|
+
const message = envelopeMessages(payload, "errors")[0] ?? (result.stderr.trim() || result.error?.message);
|
|
441
516
|
return message ? summarizeUnityCliText(message) : undefined;
|
|
442
517
|
}
|
|
443
518
|
|
|
@@ -467,7 +542,9 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
467
542
|
const command = resolveUnityCliCommand(options);
|
|
468
543
|
const versionTimeout = options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS;
|
|
469
544
|
const discoveryTimeout = options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS;
|
|
470
|
-
|
|
545
|
+
// Explicitly select the supported human formatter: inherited UNITY_FORMAT must not turn
|
|
546
|
+
// --version into JSON (or another representation) and corrupt capability reporting.
|
|
547
|
+
const versionResult = await execute(command, ["--format", "human", "--version"], { timeout: versionTimeout, signal: options.signal });
|
|
471
548
|
if (versionResult.error && (versionResult.error as NodeJS.ErrnoException).code === "ENOENT") return result;
|
|
472
549
|
if (versionResult.error) {
|
|
473
550
|
result.warnings.push(`Unity CLI version probe ${isUnityCliTimeout(versionResult) ? "timed out" : "failed"}: ${cliFailureMessage(versionResult) ?? "unknown error"}`);
|
|
@@ -479,14 +556,22 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
479
556
|
const pipelineResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "pipeline", "list"], { timeout: discoveryTimeout, signal: options.signal });
|
|
480
557
|
const pipelinePayload = parseJsonObject(pipelineResult.stdout);
|
|
481
558
|
const pipelineData = getRecord(pipelinePayload?.data);
|
|
482
|
-
|
|
559
|
+
const pipelineDiagnostics = cliEnvelopeDiagnostics(pipelinePayload);
|
|
560
|
+
result.warnings.push(...cliEnvelopeInfo(pipelinePayload));
|
|
561
|
+
if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances) || pipelineDiagnostics.length > 0) {
|
|
483
562
|
result.pipelineDiscovery = isUnityCliTimeout(pipelineResult) ? "timeout" : "unavailable";
|
|
484
|
-
|
|
563
|
+
const diagnostic = pipelineDiagnostics.join("; ") || cliFailureMessage(pipelineResult) || "malformed or unsupported JSON response";
|
|
564
|
+
result.warnings.push(`Unity Pipeline instance discovery ${result.pipelineDiscovery === "timeout" ? "timed out; Pipeline startup state is uncertain" : "is incomplete or failed; Pipeline startup state is uncertain"}: ${diagnostic}`);
|
|
565
|
+
// Retain any known positive exact-copy instance descriptors, but never use this
|
|
566
|
+
// incomplete response as a safe launch or connected-dispatch signal.
|
|
567
|
+
result.matchingInstances = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot).instances;
|
|
485
568
|
return result;
|
|
486
569
|
}
|
|
487
570
|
result.pipelineDiscovery = "available";
|
|
488
571
|
const pipeline = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot);
|
|
489
572
|
result.matchingInstances = pipeline.instances;
|
|
573
|
+
const descriptorCapabilities = await readPipelineDescriptorCapabilities(projectRoot);
|
|
574
|
+
result.pipelineSupportsExecArgv = descriptorCapabilities?.includes("exec.argv") === true;
|
|
490
575
|
result.latestPipelineVersion = pipeline.latestVersion;
|
|
491
576
|
if (pipeline.instances.length === 0) {
|
|
492
577
|
result.pipelineDiscovery = "absent";
|
|
@@ -498,15 +583,29 @@ export async function inspectUnityCliProjectCapabilities(
|
|
|
498
583
|
}
|
|
499
584
|
|
|
500
585
|
result.commandDiscoveryAttempted = true;
|
|
501
|
-
|
|
586
|
+
// `unity list` normalizes parameter types/defaults, while `unity command` with no command
|
|
587
|
+
// returns the live Pipeline catalog descriptor needed for exact capability-gated forwarding.
|
|
588
|
+
const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--detail", "full"], { timeout: discoveryTimeout, signal: options.signal });
|
|
502
589
|
const catalog = parseUnityCliCommandCatalog(listResult.stdout);
|
|
503
|
-
|
|
590
|
+
const listPayload = parseJsonObject(listResult.stdout);
|
|
591
|
+
const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
|
|
592
|
+
result.warnings.push(...cliEnvelopeInfo(listPayload));
|
|
593
|
+
if (listResult.error || !catalog.valid || commandDiagnostics.length > 0) {
|
|
504
594
|
result.commandDiscovery = isUnityCliTimeout(listResult) ? "timeout" : "unavailable";
|
|
505
|
-
result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "failed"}: ${cliFailureMessage(listResult)
|
|
595
|
+
result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "is incomplete or failed; command availability is uncertain"}: ${commandDiagnostics.join("; ") || cliFailureMessage(listResult) || "malformed or unsupported JSON response"}`);
|
|
596
|
+
// Commands in a warning-bearing catalog are informational only, not advertised
|
|
597
|
+
// capability evidence. Keep descriptors for status visibility without enabling dispatch.
|
|
598
|
+
result.advertisedCommands = catalog.commands;
|
|
599
|
+
result.advertisedCommandParameters = catalog.parametersByCommand;
|
|
600
|
+
result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
|
|
601
|
+
result.advertisedCommandCount = catalog.total;
|
|
602
|
+
result.advertisedCommandsTruncated = catalog.truncated;
|
|
506
603
|
return result;
|
|
507
604
|
}
|
|
508
605
|
result.commandDiscovery = "available";
|
|
509
606
|
result.advertisedCommands = catalog.commands;
|
|
607
|
+
result.advertisedCommandParameters = catalog.parametersByCommand;
|
|
608
|
+
result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
|
|
510
609
|
result.advertisedCommandCount = catalog.total;
|
|
511
610
|
result.advertisedCommandsTruncated = catalog.truncated;
|
|
512
611
|
result.commandDiscoverySucceeded = true;
|
|
@@ -522,6 +621,7 @@ export const UNITY_PLANNING_READ_COMMANDS = Object.freeze([
|
|
|
522
621
|
"get_authoring_root",
|
|
523
622
|
"get_build_settings",
|
|
524
623
|
"get_player_settings",
|
|
624
|
+
"get_runtime_pipeline_settings",
|
|
525
625
|
"get_scene_hierarchy",
|
|
526
626
|
"editor_status",
|
|
527
627
|
"list_open_scenes",
|
|
@@ -536,12 +636,29 @@ export type UnityPlanningInspectionRequest = {
|
|
|
536
636
|
args?: string[];
|
|
537
637
|
/** A bounded C# snippet for advertised eval. Pipeline compiles it with Roslyn on the Editor main thread. */
|
|
538
638
|
evalSnippet?: string;
|
|
639
|
+
/** Verified Pipeline eval dispatcher wait in milliseconds; distinct from the CLI/host wait. */
|
|
640
|
+
handlerTimeoutMilliseconds?: number;
|
|
539
641
|
};
|
|
540
642
|
|
|
541
643
|
export type UnityPlanningInspectionResult =
|
|
542
644
|
| { outcome: "dispatched"; command: string; output: string; truncated: boolean }
|
|
543
645
|
| { outcome: "rejected"; code: string; message: string };
|
|
544
646
|
|
|
647
|
+
export type UnityPipelineRunScriptRequest = {
|
|
648
|
+
projectRoot: string;
|
|
649
|
+
unityVersion: string;
|
|
650
|
+
file: string;
|
|
651
|
+
entry?: string;
|
|
652
|
+
args?: unknown[];
|
|
653
|
+
dryRun?: boolean;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
/** Attach bounded native discovery guidance without changing the readiness decision. */
|
|
657
|
+
export function unityCapabilityDiagnosticSuffix(capabilities: UnityCliProjectCapabilities): string {
|
|
658
|
+
const message = summarizeUnityCliText(redactUnityPlanningOutput(capabilities.warnings.slice(0, 8).join("; ")), 1_000, 10);
|
|
659
|
+
return message ? ` ${message}` : "";
|
|
660
|
+
}
|
|
661
|
+
|
|
545
662
|
function planningInspectionReadiness(capabilities: UnityCliProjectCapabilities): string | undefined {
|
|
546
663
|
if (!capabilities.cliAvailable) return "unity_cli_unavailable";
|
|
547
664
|
if (capabilities.pipelineDiscovery !== "available") return `pipeline_${capabilities.pipelineDiscovery}`;
|
|
@@ -556,12 +673,46 @@ function caseInsensitiveField(record: Record<string, unknown>, name: string): un
|
|
|
556
673
|
return entry?.[1];
|
|
557
674
|
}
|
|
558
675
|
|
|
676
|
+
function runScriptCommandFailure(output: string): "malformed" | "failure" | undefined {
|
|
677
|
+
const envelope = parseJsonObject(output);
|
|
678
|
+
if (!envelope || caseInsensitiveField(envelope, "success") !== true) return envelope ? "failure" : "malformed";
|
|
679
|
+
const wrapper = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
|
|
680
|
+
if (caseInsensitiveField(wrapper, "success") === false) return "failure";
|
|
681
|
+
let response: unknown = caseInsensitiveField(wrapper, "result");
|
|
682
|
+
// Legacy wrapper puts the documented RunScriptResponse in data.result; compact
|
|
683
|
+
// transport puts it in result. A bare success envelope has no command evidence.
|
|
684
|
+
if (typeof response === "string") { try { response = JSON.parse(response); } catch { return "malformed"; } }
|
|
685
|
+
const result = getRecord(response);
|
|
686
|
+
if (!result || !Object.prototype.hasOwnProperty.call(result, "diagnostics") || !Array.isArray(caseInsensitiveField(result, "diagnostics"))) return "malformed";
|
|
687
|
+
if (caseInsensitiveField(result, "success") === false || caseInsensitiveField(result, "failed") === true) return "failure";
|
|
688
|
+
const diagnostics = caseInsensitiveField(result, "diagnostics") as unknown[];
|
|
689
|
+
if (diagnostics.some(item => {
|
|
690
|
+
const diagnostic = getRecord(item);
|
|
691
|
+
return String(caseInsensitiveField(diagnostic ?? {}, "severity") ?? "").toLowerCase() === "error";
|
|
692
|
+
})) return "failure";
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function hasVerifiedEvalTimeoutContract(capabilities: UnityCliProjectCapabilities): boolean {
|
|
697
|
+
const parameters = capabilities.verifiedCommandParameters?.eval;
|
|
698
|
+
return capabilities.pipelineSupportsExecArgv === true
|
|
699
|
+
&& Array.isArray(parameters)
|
|
700
|
+
&& parameters.length === 2
|
|
701
|
+
&& parameters[0]?.name === "code"
|
|
702
|
+
&& parameters[0]?.type === "String"
|
|
703
|
+
&& parameters[0]?.required === true
|
|
704
|
+
&& parameters[1]?.name === "timeout"
|
|
705
|
+
&& parameters[1]?.type === "Int32"
|
|
706
|
+
&& parameters[1]?.required === false
|
|
707
|
+
&& parameters[1]?.defaultValue === 5000;
|
|
708
|
+
}
|
|
709
|
+
|
|
559
710
|
function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
|
|
560
711
|
const envelope = parseJsonObject(output);
|
|
561
712
|
if (!envelope) return "malformed";
|
|
562
713
|
if (caseInsensitiveField(envelope, "success") !== true) return "failure";
|
|
563
|
-
|
|
564
|
-
|
|
714
|
+
// Pipeline 0.6 compact responses omit the CLI wrapper's data field.
|
|
715
|
+
const data = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
|
|
565
716
|
if (caseInsensitiveField(data, "success") === false) return "failure";
|
|
566
717
|
if (!isEval) return undefined;
|
|
567
718
|
|
|
@@ -610,7 +761,7 @@ export async function dispatchUnityPlanningInspection(
|
|
|
610
761
|
}));
|
|
611
762
|
const initial = await inspect(projectRoot, request.unityVersion);
|
|
612
763
|
const initialFailure = planningInspectionReadiness(initial);
|
|
613
|
-
if (initialFailure) return { outcome: "rejected", code: initialFailure, message:
|
|
764
|
+
if (initialFailure) return { outcome: "rejected", code: initialFailure, message: `Exact-copy Pipeline planning inspection is not established.${unityCapabilityDiagnosticSuffix(initial)}` };
|
|
614
765
|
|
|
615
766
|
const isEval = request.command === "eval";
|
|
616
767
|
const hasBoundedArgs = (request.args?.length ?? 0) <= 12
|
|
@@ -623,6 +774,12 @@ export async function dispatchUnityPlanningInspection(
|
|
|
623
774
|
if (request.args?.length || !snippet || snippet.length > UNITY_PIPELINE_EVAL_MAX_CHARS || /[\u0000]/.test(snippet)) {
|
|
624
775
|
return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval requires one non-empty bounded C# snippet and no separate arguments." };
|
|
625
776
|
}
|
|
777
|
+
if (request.handlerTimeoutMilliseconds !== undefined && (!Number.isInteger(request.handlerTimeoutMilliseconds) || request.handlerTimeoutMilliseconds < 1 || request.handlerTimeoutMilliseconds > 86_400_000)) {
|
|
778
|
+
return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval handler timeout must be an integer from 1 to 86400000 milliseconds." };
|
|
779
|
+
}
|
|
780
|
+
if (request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(initial)) {
|
|
781
|
+
return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline copy does not establish raw argv support and the documented eval timeout signature; eval was not dispatched." };
|
|
782
|
+
}
|
|
626
783
|
} else if (!UNITY_PLANNING_READ_COMMANDS.includes(request.command as typeof UNITY_PLANNING_READ_COMMANDS[number]) || (request.evalSnippet?.trim() ?? "") !== "") {
|
|
627
784
|
return { outcome: "rejected", code: "planning_command_invalid", message: "Only a package-owned purpose-built inspection command may be selected here." };
|
|
628
785
|
}
|
|
@@ -633,18 +790,21 @@ export async function dispatchUnityPlanningInspection(
|
|
|
633
790
|
const refreshed = await inspect(projectRoot, request.unityVersion);
|
|
634
791
|
const refreshedFailure = planningInspectionReadiness(refreshed);
|
|
635
792
|
if (refreshedFailure || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances)) {
|
|
636
|
-
return { outcome: "rejected", code: "unity_project_identity_changed", message:
|
|
793
|
+
return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity changed or disconnected immediately before planning dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
|
|
637
794
|
}
|
|
638
795
|
if (!refreshed.advertisedCommands.includes(request.command)) {
|
|
639
796
|
return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
|
|
640
797
|
}
|
|
798
|
+
if (isEval && request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(refreshed)) {
|
|
799
|
+
return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline eval timeout capability changed before dispatch; eval was not dispatched." };
|
|
800
|
+
}
|
|
641
801
|
|
|
642
802
|
const command = resolveUnityCliCommand({ cliCommand: options.cliCommand });
|
|
643
803
|
const args = [
|
|
644
804
|
"--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot,
|
|
645
805
|
"--timeout", String(Math.max(1, Math.ceil((options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS) / 1000))),
|
|
646
806
|
request.command,
|
|
647
|
-
...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
|
|
807
|
+
...(isEval ? [request.evalSnippet!.trim(), ...(request.handlerTimeoutMilliseconds === undefined ? [] : [String(request.handlerTimeoutMilliseconds)])] : request.args ?? []),
|
|
648
808
|
];
|
|
649
809
|
const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
|
|
650
810
|
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
|
@@ -663,6 +823,37 @@ export async function dispatchUnityPlanningInspection(
|
|
|
663
823
|
return { outcome: "dispatched", command: request.command, output, truncated: output.length < raw.trim().length };
|
|
664
824
|
}
|
|
665
825
|
|
|
826
|
+
/** Dispatch the documented Pipeline 0.6 ephemeral run_script form; hotpatch is deliberately not exposed. */
|
|
827
|
+
export async function dispatchUnityPipelineRunScript(
|
|
828
|
+
request: UnityPipelineRunScriptRequest,
|
|
829
|
+
options: { cliCommand?: string; timeout?: number; signal?: AbortSignal; execute: UnityCliExecutor; inspect?: (projectRoot: string, unityVersion: string) => Promise<UnityCliProjectCapabilities> },
|
|
830
|
+
): Promise<UnityPlanningInspectionResult> {
|
|
831
|
+
let projectRoot: string; let file: string;
|
|
832
|
+
try { projectRoot = await realpath(request.projectRoot); file = await realpath(request.file); } catch {
|
|
833
|
+
return { outcome: "rejected", code: "run_script_path_unavailable", message: "The project root or existing script file could not be canonicalized." };
|
|
834
|
+
}
|
|
835
|
+
const relativeFile = relative(projectRoot, file);
|
|
836
|
+
let fileStats: Awaited<ReturnType<typeof stat>>;
|
|
837
|
+
try { fileStats = await stat(file); } catch { return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one readable existing C# file." }; }
|
|
838
|
+
if (!relativeFile || isAbsolute(relativeFile) || relativeFile === ".." || relativeFile.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || !fileStats.isFile() || !/\.cs$/i.test(relativeFile)) return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one existing .cs file inside the exact project root." };
|
|
839
|
+
let serializedArgs: string;
|
|
840
|
+
try { serializedArgs = JSON.stringify(request.args ?? []); } catch { return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script arguments must be JSON-serializable." }; }
|
|
841
|
+
if (serializedArgs === undefined || serializedArgs.length > 4_000 || (request.entry?.length ?? 0) > 500 || /[\u0000-\u001f\u007f]/.test(request.entry ?? "")) return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script entry or JSON arguments exceed bounded request limits." };
|
|
842
|
+
const inspect = options.inspect ?? ((root, version) => inspectUnityCliProjectCapabilities(root, version, { cliCommand: options.cliCommand, timeout: options.timeout, signal: options.signal, execute: options.execute }));
|
|
843
|
+
const initial = await inspect(projectRoot, request.unityVersion);
|
|
844
|
+
if (planningInspectionReadiness(initial) || !initial.advertisedCommands.includes("run_script")) return { outcome: "rejected", code: "run_script_unavailable", message: `The exact reachable Pipeline copy does not establish advertised run_script support.${unityCapabilityDiagnosticSuffix(initial)}` };
|
|
845
|
+
const refreshed = await inspect(projectRoot, request.unityVersion);
|
|
846
|
+
if (planningInspectionReadiness(refreshed) || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances) || !refreshed.advertisedCommands.includes("run_script")) return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity or run_script availability changed immediately before dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
|
|
847
|
+
const timeout = Math.max(1, Math.min(options.timeout ?? 30_000, 86_400_000));
|
|
848
|
+
const args = ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--timeout", String(Math.ceil(timeout / 1000)), "run_script", "--file", relativeFile, "--mode", "ephemeral", "--args", serializedArgs, "--timeout_ms", String(timeout), ...(request.entry?.trim() ? ["--entry", request.entry.trim()] : []), ...(request.dryRun ? ["--dry_run", "true"] : [])];
|
|
849
|
+
const execution = await options.execute(resolveUnityCliCommand({ cliCommand: options.cliCommand }), args, { timeout, signal: options.signal });
|
|
850
|
+
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n"); const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
|
|
851
|
+
if (execution.error) return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "run_script_timeout" : "run_script_failed", message: `run_script did not complete successfully; its effect may be uncertain.${output ? ` ${output}` : ""}` };
|
|
852
|
+
const failure = runScriptCommandFailure(execution.stdout);
|
|
853
|
+
if (failure) return { outcome: "rejected", code: failure === "malformed" ? "run_script_malformed" : "run_script_reported_failure", message: `${failure === "malformed" ? "run_script returned malformed JSON evidence" : "run_script reported failure"}.${output ? ` ${output}` : ""}` };
|
|
854
|
+
return { outcome: "dispatched", command: "run_script", output, truncated: output.length < raw.trim().length };
|
|
855
|
+
}
|
|
856
|
+
|
|
666
857
|
/** Keep connected inspection output useful without returning common credential forms verbatim. */
|
|
667
858
|
export function redactUnityPlanningOutput(value: string): string {
|
|
668
859
|
return value
|
package/src/unity-pipeline.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { projectPathsMatch } from "./unity-core";
|
|
3
|
-
import {
|
|
3
|
+
import { hasConsistentUnityTestCounts } from "./unity-artifact-inspection";
|
|
4
|
+
import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
|
|
4
5
|
|
|
5
6
|
/** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
|
|
6
7
|
export const UNITY_PIPELINE_COMPILE_TIMEOUT_SECONDS = 180;
|
|
@@ -31,10 +32,17 @@ export type UnityPipelineOperationDetails = {
|
|
|
31
32
|
testPlatform?: "EditMode" | "PlayMode";
|
|
32
33
|
testFilter?: string;
|
|
33
34
|
counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
|
|
35
|
+
/** Bounded nonfatal Pipeline envelope guidance; never compiler-error evidence. */
|
|
36
|
+
warnings?: string[];
|
|
34
37
|
};
|
|
35
38
|
export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
|
|
36
39
|
/** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
|
|
37
40
|
export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
|
|
41
|
+
export type UnityPipelineTerminalTestEvidence = { state: "completed" | "failed" | "cancelled"; outcome: "uncertain" | "tests_failed" | "run_error" | "cancelled"; reason: string; elapsedSeconds: number; selection: { platform: "EditMode" | "PlayMode"; filter?: string }; correlation: Record<string, string>; observations: string[]; testRecords: UnityPipelineTestRecord[]; warnings: string[] };
|
|
42
|
+
/** Terminal Pipeline evidence can be durable and inspectable without being passing evidence. */
|
|
43
|
+
export class UnityPipelineTerminalTestEvidenceError extends Error {
|
|
44
|
+
constructor(readonly evidence: UnityPipelineTerminalTestEvidence) { super(evidence.reason); this.name = "UnityPipelineTerminalTestEvidenceError"; }
|
|
45
|
+
}
|
|
38
46
|
|
|
39
47
|
type RecordValue = Record<string, unknown>;
|
|
40
48
|
type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
|
|
@@ -44,6 +52,8 @@ type NormalizedTest = {
|
|
|
44
52
|
total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
|
|
45
53
|
correlation: Record<string, string>;
|
|
46
54
|
testRecords?: UnityPipelineTestRecord[];
|
|
55
|
+
testFailureEstablished: boolean;
|
|
56
|
+
runnerError: boolean;
|
|
47
57
|
};
|
|
48
58
|
|
|
49
59
|
type PipelineDependencies = {
|
|
@@ -72,6 +82,39 @@ function bounded(value: string, limit = UNITY_PIPELINE_MAX_STACK_CHARS): string
|
|
|
72
82
|
const oneLine = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
73
83
|
return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
|
|
74
84
|
}
|
|
85
|
+
function pipelineEnvelopeWarnings(output: string): string[] {
|
|
86
|
+
const outer = (() => { try { return record(JSON.parse(output)); } catch { return undefined; } })();
|
|
87
|
+
const data = record(outer?.data);
|
|
88
|
+
const entries = [outer?.warnings, data?.warnings].flatMap(value => Array.isArray(value) ? value.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS) : []);
|
|
89
|
+
return entries.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS).flatMap(entry => {
|
|
90
|
+
const item = record(entry); const message = string(field(item ?? {}, "message", "detail", "warning")) ?? string(entry);
|
|
91
|
+
return message ? [bounded(redactUnityPlanningOutput(message), 1_000)] : [];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function retainWarnings(warnings: string[], output: string): void {
|
|
95
|
+
for (const warning of pipelineEnvelopeWarnings(output)) {
|
|
96
|
+
if (warnings.length >= UNITY_PIPELINE_MAX_DIAGNOSTICS) break;
|
|
97
|
+
if (!warnings.includes(warning)) warnings.push(warning);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function warningText(warnings: string[]): string {
|
|
101
|
+
return warnings.length ? `\nPipeline warnings: ${warnings.join("; ")}` : "";
|
|
102
|
+
}
|
|
103
|
+
function pipelineFailureDiagnostic(response: UnityCliExecResult): string {
|
|
104
|
+
const raw = [response.stdout, response.stderr, response.error?.message].filter(Boolean).join("\n");
|
|
105
|
+
let message: string | undefined;
|
|
106
|
+
try {
|
|
107
|
+
const outer = record(JSON.parse(response.stdout)); const data = record(outer?.data);
|
|
108
|
+
const errors = Array.isArray(outer?.errors) ? outer?.errors : [];
|
|
109
|
+
const first = record(errors[0]);
|
|
110
|
+
const messages = [
|
|
111
|
+
string(field(first ?? {}, "code")), string(field(first ?? {}, "message", "detail")),
|
|
112
|
+
...[data, outer].flatMap(item => ["error", "errordetails", "message"].map(key => string(field(item ?? {}, key)))),
|
|
113
|
+
].filter((value): value is string => Boolean(value));
|
|
114
|
+
message = [...new Set(messages)].join("; ") || undefined;
|
|
115
|
+
} catch { /* retain bounded native raw fallback */ }
|
|
116
|
+
return summarizeUnityCliText(redactUnityPlanningOutput(message ?? raw), 1_000, 10);
|
|
117
|
+
}
|
|
75
118
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
76
119
|
if (signal?.aborted) throw new Error("Unity Pipeline operation aborted; its Editor operation may still be running.");
|
|
77
120
|
}
|
|
@@ -102,8 +145,10 @@ export function parseUnityPipelineEnvelope(output: string): ParsedEnvelope {
|
|
|
102
145
|
let outer: RecordValue | undefined;
|
|
103
146
|
try { outer = record(JSON.parse(output)); } catch { return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned malformed JSON." }; }
|
|
104
147
|
if (!outer) return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned a non-object JSON envelope." };
|
|
148
|
+
// Pipeline 0.6 may return the compact exec envelope { success, result, warnings }
|
|
149
|
+
// rather than the older CLI wrapper { success, data: { result } }.
|
|
105
150
|
const data = record(outer.data);
|
|
106
|
-
const rawResult = data?.result ?? data;
|
|
151
|
+
const rawResult = data?.result ?? outer.result ?? data;
|
|
107
152
|
if (typeof rawResult === "string") {
|
|
108
153
|
try {
|
|
109
154
|
const parsed = record(JSON.parse(rawResult));
|
|
@@ -148,22 +193,23 @@ function diagnostics(result: RecordValue): string[] {
|
|
|
148
193
|
});
|
|
149
194
|
return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
150
195
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
&& field(data ?? {}, "retryable") === true;
|
|
196
|
+
/**
|
|
197
|
+
* Do not retry generic busy envelopes. Pipeline 0.6 uses busy for modal dialogs as
|
|
198
|
+
* well as startup settling, and neither is distinguishable in the legacy CLI-shaped
|
|
199
|
+
* response. Retrying would blindly repeat a command blocked by a user-visible modal.
|
|
200
|
+
* A future retry must be wired only to a source-verified pre-dispatch discriminator.
|
|
201
|
+
*/
|
|
202
|
+
export function isUnityPipelineInitialSettlingBusy(_output: string): boolean {
|
|
203
|
+
return false;
|
|
160
204
|
}
|
|
161
205
|
|
|
162
206
|
export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
|
|
163
207
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
164
208
|
if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
|
|
165
209
|
const compilerDiagnostics = diagnostics(parsed.result);
|
|
166
|
-
|
|
210
|
+
let compilationFailed = false;
|
|
211
|
+
walk(parsed.result, item => { if (field(item, "compilationfailed") === true) compilationFailed = true; });
|
|
212
|
+
const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilationFailed || compilerDiagnostics.length > 0;
|
|
167
213
|
const raw = statusOf(parsed.result);
|
|
168
214
|
const state = failed || raw === "failed" || raw === "error" ? "failed" : raw === "up_to_date" || raw === "uptodate" ? "up_to_date"
|
|
169
215
|
: raw === "triggered" ? "triggered" : raw === "compiling" || raw === "running" ? "compiling"
|
|
@@ -193,6 +239,9 @@ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
|
|
|
193
239
|
});
|
|
194
240
|
return values.slice(0, 2_000);
|
|
195
241
|
}
|
|
242
|
+
function isRecognizedFailedTestStatus(status: string): boolean {
|
|
243
|
+
return status.trim().toLowerCase() === "failed";
|
|
244
|
+
}
|
|
196
245
|
function testFailures(result: RecordValue): string[] {
|
|
197
246
|
const values: string[] = [];
|
|
198
247
|
walk(result, item => {
|
|
@@ -202,7 +251,7 @@ function testFailures(result: RecordValue): string[] {
|
|
|
202
251
|
for (const entry of entries.slice(0, 200)) {
|
|
203
252
|
const test = record(entry); if (!test) continue;
|
|
204
253
|
const outcome = string(field(test, "result", "status", "outcome"))?.toLowerCase();
|
|
205
|
-
if (!outcome ||
|
|
254
|
+
if (!outcome || /^(?:passed|success)$/i.test(outcome)) continue;
|
|
206
255
|
const name = string(field(test, "name", "fullname", "testname")) ?? "Unnamed test";
|
|
207
256
|
const message = string(field(test, "message", "error", "failuremessage"));
|
|
208
257
|
const stack = string(field(test, "stacktrace", "stack", "trace"));
|
|
@@ -232,19 +281,23 @@ function correlation(result: RecordValue): Record<string, string> {
|
|
|
232
281
|
}
|
|
233
282
|
export function normalizeUnityPipelineTest(output: string): NormalizedTest {
|
|
234
283
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
235
|
-
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {} };
|
|
284
|
+
if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {}, testFailureEstablished: false, runnerError: false };
|
|
236
285
|
const sum = summary(parsed.result);
|
|
237
286
|
const total = number(field(sum ?? parsed.result, "total"));
|
|
238
287
|
const passed = number(field(sum ?? parsed.result, "passed", "pass"));
|
|
239
288
|
const failedCount = number(field(sum ?? parsed.result, "failed", "fail"));
|
|
240
289
|
const inconclusive = number(field(sum ?? parsed.result, "inconclusive", "skipped"));
|
|
241
290
|
const raw = statusOf(parsed.result);
|
|
242
|
-
const
|
|
243
|
-
const
|
|
291
|
+
const records = testRecords(parsed.result);
|
|
292
|
+
const testFailureEstablished = (Number.isSafeInteger(failedCount) && (failedCount ?? 0) > 0) || records.some(test => isRecognizedFailedTestStatus(test.status));
|
|
293
|
+
const runnerError = !parsed.outerSuccess || raw === "failed" || raw === "error" || hasSemanticFailure(parsed.result);
|
|
294
|
+
// A reported active state remains active even when it carries partial records/counts.
|
|
295
|
+
const state = raw === "cancelled" || raw === "canceled" ? "cancelled"
|
|
244
296
|
: raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
|
|
245
297
|
: raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
|
|
246
|
-
: raw === "completed" || raw === "complete" || raw === "success" ? "
|
|
247
|
-
|
|
298
|
+
: raw === "completed" || raw === "complete" || raw === "success" ? testFailureEstablished || runnerError ? "failed" : "completed"
|
|
299
|
+
: testFailureEstablished || runnerError ? "failed" : "uncertain";
|
|
300
|
+
return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: records, testFailureEstablished, runnerError };
|
|
248
301
|
}
|
|
249
302
|
|
|
250
303
|
function editorStopSucceeded(output: string): boolean {
|
|
@@ -363,7 +416,7 @@ async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot
|
|
|
363
416
|
|
|
364
417
|
async function requirePreflight(deps: PipelineDependencies, projectRoot: string, unityVersion: string, commands: string[], operation: "recompile" | "tests", signal: AbortSignal | undefined, deadline: number, now: () => number, allowAutonomousExitPlayMode = true): Promise<{ capabilities: UnityCliProjectCapabilities; exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }> {
|
|
365
418
|
const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
|
|
366
|
-
const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
|
|
419
|
+
const error = capabilityError(capabilities, commands); if (error) throw new Error(error + unityCapabilityDiagnosticSuffix(capabilities));
|
|
367
420
|
let editor = await executeCommand(deps, projectRoot, "editor_status", [], signal, deadline, now);
|
|
368
421
|
if (editor.error) throw new Error("Unity Pipeline editor_status failed; operation not started.");
|
|
369
422
|
let status = editorStatus(editor.stdout);
|
|
@@ -429,10 +482,18 @@ function checkCorrelation(expected: Record<string, string>, actual: Record<strin
|
|
|
429
482
|
return Object.entries(expected).every(([key, value]) => !actual[key] || actual[key] === value);
|
|
430
483
|
}
|
|
431
484
|
function passingCounts(state: NormalizedTest): { total: number; passed: number; failed: number; inconclusive?: number } | undefined {
|
|
485
|
+
const summary = { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
486
|
+
if (!hasConsistentUnityTestCounts(summary, state.testRecords ?? [])) return undefined;
|
|
487
|
+
if (state.testRecords?.some(test => !/^(?:passed|success)$/i.test(test.status))) return undefined;
|
|
432
488
|
if (state.total === undefined || state.total <= 0 || state.passed === undefined || state.failed !== 0 || (state.inconclusive ?? 0) > 0) return undefined;
|
|
433
489
|
if (state.passed + state.failed + (state.inconclusive ?? 0) !== state.total) return undefined;
|
|
434
490
|
return { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
|
|
435
491
|
}
|
|
492
|
+
function terminalEvidence(state: NormalizedTest, reason: string, request: UnityPipelineTestRequest, elapsedSeconds: number, warnings: string[]): UnityPipelineTerminalTestEvidenceError {
|
|
493
|
+
const outcome = state.state === "cancelled" ? "cancelled" : state.testFailureEstablished ? "tests_failed" : state.runnerError ? "run_error" : "uncertain";
|
|
494
|
+
const observations = [`terminal state=${state.state}`, `terminal outcome=${outcome}`, ...["total", "passed", "failed", "inconclusive"].flatMap(key => state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">] === undefined ? [] : [`reported ${key}=${String(state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">])}`]), ...Object.entries(state.correlation).map(([key, value]) => `correlation ${key}=${value}`)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
495
|
+
return new UnityPipelineTerminalTestEvidenceError({ state: state.state as "completed" | "failed" | "cancelled", outcome, reason, elapsedSeconds, selection: { platform: request.testPlatform, ...(request.testFilter ?? request.testCategory ? { filter: request.testFilter ?? request.testCategory } : {}) }, correlation: state.correlation, observations, testRecords: state.testRecords ?? [], warnings });
|
|
496
|
+
}
|
|
436
497
|
function elapsed(start: number, now: () => number): number { return Math.max(0, (now() - start) / 1000); }
|
|
437
498
|
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.`); }
|
|
438
499
|
function ensureBeforeDeadline(deadline: number, now: () => number, operation: string): void {
|
|
@@ -450,11 +511,12 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
|
|
|
450
511
|
ensureBeforeDeadline(deadline, now, "recompile before dispatch");
|
|
451
512
|
throwIfAborted(signal);
|
|
452
513
|
const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "recompile", [], "recompile", signal, deadline, now, sleep);
|
|
453
|
-
if (dispatched.error) throw new Error(
|
|
514
|
+
if (dispatched.error) throw new Error(`Unity Pipeline recompile dispatch failed; operation may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
|
|
515
|
+
const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
|
|
454
516
|
let state = normalizeUnityPipelineCompile(dispatched.stdout);
|
|
455
517
|
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
|
|
456
518
|
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
457
|
-
if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered
|
|
519
|
+
if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "recompile", terminalState: "up_to_date", elapsedSeconds: elapsed(start, now), compilationTriggered: false, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}), ...playModeDetails(preflight) } };
|
|
458
520
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
459
521
|
options.onUpdate?.(`Unity recompile ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
460
522
|
const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(poll, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, deadline - now());
|
|
@@ -467,9 +529,10 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
|
|
|
467
529
|
const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
|
|
468
530
|
if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
|
|
469
531
|
if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
|
|
532
|
+
retainWarnings(dispatchWarnings, response.stdout);
|
|
470
533
|
state = normalizeUnityPipelineCompile(response.stdout);
|
|
471
534
|
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
472
|
-
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
|
|
535
|
+
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.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}), ...playModeDetails(preflight) } };
|
|
473
536
|
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile status is malformed or uncertain; operation may still be running.");
|
|
474
537
|
}
|
|
475
538
|
throw timeoutMessage("recompile");
|
|
@@ -498,18 +561,19 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
498
561
|
const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
|
|
499
562
|
ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
|
|
500
563
|
const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
|
|
501
|
-
if (dispatched.error) throw new Error(
|
|
564
|
+
if (dispatched.error) throw new Error(`Unity Pipeline test dispatch failed; test run may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
|
|
565
|
+
const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
|
|
502
566
|
let state = normalizeUnityPipelineTest(dispatched.stdout);
|
|
503
567
|
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.");
|
|
504
|
-
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
505
568
|
const requestedCorrelation = { mode: request.testPlatform, ...(request.testFilter ? { filter: request.testFilter } : {}) };
|
|
506
569
|
if (!checkCorrelation(requestedCorrelation, state.correlation)) throw new Error("Unity Pipeline test dispatch reported a different mode or filter; operation state is uncertain.");
|
|
570
|
+
if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
|
|
507
571
|
const expected = { ...requestedCorrelation, ...state.correlation };
|
|
508
572
|
// Some Pipeline versions return a complete result directly from asynchronous dispatch.
|
|
509
573
|
if (state.state === "completed") {
|
|
510
574
|
const counts = passingCounts(state);
|
|
511
|
-
if (!counts) throw
|
|
512
|
-
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
|
|
575
|
+
if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
|
|
576
|
+
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.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
|
|
513
577
|
}
|
|
514
578
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
515
579
|
options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
@@ -523,15 +587,16 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
523
587
|
const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
|
|
524
588
|
if (response.error) continue;
|
|
525
589
|
if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
|
|
590
|
+
retainWarnings(dispatchWarnings, response.stdout);
|
|
526
591
|
state = normalizeUnityPipelineTest(response.stdout);
|
|
527
592
|
if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
|
|
528
|
-
if (state.state === "failed" || state.state === "cancelled") throw
|
|
593
|
+
if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
|
|
529
594
|
if (state.state === "uncertain") throw new Error("Unity Pipeline test status is malformed or uncertain; operation may still be running.");
|
|
530
595
|
if (state.state === "inactive") throw new Error("Unity Pipeline test status became inactive before a terminal result; operation state is uncertain.");
|
|
531
596
|
if (state.state !== "completed") continue;
|
|
532
597
|
const counts = passingCounts(state);
|
|
533
|
-
if (!counts) throw
|
|
534
|
-
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
|
|
598
|
+
if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
|
|
599
|
+
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.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
|
|
535
600
|
}
|
|
536
601
|
throw timeoutMessage("tests");
|
|
537
602
|
}
|
package/src/unity-tests.ts
CHANGED
|
@@ -60,6 +60,8 @@ export type NormalizedUnityTestResult = {
|
|
|
60
60
|
tests: NormalizedUnityTest[];
|
|
61
61
|
flakyTests?: Array<{ name: string; attempts: number }>;
|
|
62
62
|
backendArtifacts?: Record<string, string>;
|
|
63
|
+
/** Bounded non-authoritative observations retained when terminal Pipeline evidence cannot establish a result. */
|
|
64
|
+
diagnostics?: string[];
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
|
|
@@ -250,6 +252,7 @@ export function normalizeUnityTestResult(result: NormalizedUnityTestResult): Nor
|
|
|
250
252
|
summary: Object.fromEntries(Object.entries(result.summary).flatMap(([key, value]) => numberOrUndefined(value) === undefined ? [] : [[key, numberOrUndefined(value)!]])),
|
|
251
253
|
tests, ...(result.flakyTests ? { flakyTests: result.flakyTests.slice(0, UNITY_TEST_MAX_TESTS).map(item => ({ name: bound(item.name, 1_000) || "Unnamed test", attempts: Math.max(1, Math.floor(item.attempts)) })) } : {}),
|
|
252
254
|
...(Object.keys(artifacts).length ? { backendArtifacts: artifacts } : {}),
|
|
255
|
+
...(result.diagnostics ? { diagnostics: result.diagnostics.slice(0, 8).flatMap(value => typeof value === "string" ? [bound(value, 1_000) || ""] : []).filter(Boolean) } : {}),
|
|
253
256
|
};
|
|
254
257
|
}
|
|
255
258
|
|