@skillcap/gdh 0.11.0 → 0.13.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/INSTALL-BUNDLE.json +1 -1
- package/README.md +6 -4
- package/RELEASE-SPAN-UPDATE-CONTRACTS.json +114 -0
- package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/adapters/dist/index.js +42 -3
- package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
- package/node_modules/@gdh/adapters/package.json +8 -8
- package/node_modules/@gdh/authoring/dist/lsp.d.ts.map +1 -1
- package/node_modules/@gdh/authoring/dist/lsp.js +4 -0
- package/node_modules/@gdh/authoring/dist/lsp.js.map +1 -1
- package/node_modules/@gdh/authoring/dist/prepare.d.ts.map +1 -1
- package/node_modules/@gdh/authoring/dist/prepare.js +9 -1
- package/node_modules/@gdh/authoring/dist/prepare.js.map +1 -1
- package/node_modules/@gdh/authoring/dist/project.js +1 -1
- package/node_modules/@gdh/authoring/dist/project.js.map +1 -1
- package/node_modules/@gdh/authoring/package.json +2 -2
- package/node_modules/@gdh/cli/dist/index.js +4 -4
- package/node_modules/@gdh/cli/dist/index.js.map +1 -1
- package/node_modules/@gdh/cli/dist/migrate.d.ts +7 -0
- package/node_modules/@gdh/cli/dist/migrate.d.ts.map +1 -1
- package/node_modules/@gdh/cli/dist/migrate.js +95 -2
- package/node_modules/@gdh/cli/dist/migrate.js.map +1 -1
- package/node_modules/@gdh/cli/package.json +10 -10
- package/node_modules/@gdh/core/dist/index.d.ts +40 -1
- package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/core/dist/index.js +7 -1
- package/node_modules/@gdh/core/dist/index.js.map +1 -1
- package/node_modules/@gdh/core/package.json +1 -1
- package/node_modules/@gdh/docs/dist/guidance.d.ts.map +1 -1
- package/node_modules/@gdh/docs/dist/guidance.js +10 -0
- package/node_modules/@gdh/docs/dist/guidance.js.map +1 -1
- package/node_modules/@gdh/docs/package.json +2 -2
- package/node_modules/@gdh/mcp/package.json +8 -8
- package/node_modules/@gdh/observability/dist/runtime-bundles.js +9 -9
- package/node_modules/@gdh/observability/dist/runtime-bundles.js.map +1 -1
- package/node_modules/@gdh/observability/package.json +2 -2
- package/node_modules/@gdh/runtime/dist/bridge-surface.d.ts.map +1 -1
- package/node_modules/@gdh/runtime/dist/bridge-surface.js +307 -33
- package/node_modules/@gdh/runtime/dist/bridge-surface.js.map +1 -1
- package/node_modules/@gdh/runtime/dist/docker-provider.d.ts +43 -0
- package/node_modules/@gdh/runtime/dist/docker-provider.d.ts.map +1 -0
- package/node_modules/@gdh/runtime/dist/docker-provider.js +233 -0
- package/node_modules/@gdh/runtime/dist/docker-provider.js.map +1 -0
- package/node_modules/@gdh/runtime/dist/index.d.ts +1 -0
- package/node_modules/@gdh/runtime/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/runtime/dist/index.js +745 -136
- package/node_modules/@gdh/runtime/dist/index.js.map +1 -1
- package/node_modules/@gdh/runtime/package.json +2 -2
- package/node_modules/@gdh/scan/dist/onboard.d.ts.map +1 -1
- package/node_modules/@gdh/scan/dist/onboard.js +6 -2
- package/node_modules/@gdh/scan/dist/onboard.js.map +1 -1
- package/node_modules/@gdh/scan/package.json +3 -3
- package/node_modules/@gdh/verify/dist/readiness.js +5 -5
- package/node_modules/@gdh/verify/dist/readiness.js.map +1 -1
- package/node_modules/@gdh/verify/dist/scenarios.d.ts.map +1 -1
- package/node_modules/@gdh/verify/dist/scenarios.js +96 -9
- package/node_modules/@gdh/verify/dist/scenarios.js.map +1 -1
- package/node_modules/@gdh/verify/package.json +7 -7
- package/package.json +11 -11
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
7
8
|
import WebSocket, {} from "ws";
|
|
8
9
|
import { GDH_RECIPE_SCHEMA_VERSION, GDH_RUNTIME_RECIPE_RUN_VERSION, definePackageBoundary, presentPublicRuntimeTerms, resolveConfiguredGodotEditorBin, } from "@gdh/core";
|
|
9
10
|
import { parse } from "yaml";
|
|
10
11
|
import { inspectRuntimeBridgeSurface } from "./bridge-surface.js";
|
|
12
|
+
import { buildDockerRuntimeLaunchPreview, ensureDockerRuntimeImagePlan, inspectDockerRuntimeProvider, toDockerContainerPath, } from "./docker-provider.js";
|
|
11
13
|
export { inspectRuntimeBridgeSurface, installRuntimeBridgeSurface, removeRuntimeBridgeSurface, repairRuntimeBridgeSurface, resolveRuntimeBridgeConfig, } from "./bridge-surface.js";
|
|
12
14
|
export const runtimePackage = definePackageBoundary({
|
|
13
15
|
name: "@gdh/runtime",
|
|
@@ -17,6 +19,15 @@ export const runtimePackage = definePackageBoundary({
|
|
|
17
19
|
});
|
|
18
20
|
const PRIMARY_RUN_CONFIGURATION_DIRECTORY = ".gdh/run-configurations";
|
|
19
21
|
const LEGACY_RECIPE_DIRECTORY = ".gdh/recipes";
|
|
22
|
+
const LOCAL_RUNTIME_PROVIDER = "local";
|
|
23
|
+
const DOCKER_RUNTIME_PROVIDER = "docker";
|
|
24
|
+
const RUNTIME_SCREENSHOT_IMAGE_FILENAME = "screenshot.png";
|
|
25
|
+
const RUNTIME_SCREENSHOT_METADATA_FILENAME = "screenshot.json";
|
|
26
|
+
const RUNTIME_SCREENSHOT_CONTAINER_DIRECTORY = "/gdh-artifacts";
|
|
27
|
+
const ENV_SCREENSHOT_CAPTURE = "GDH_SCREENSHOT_CAPTURE";
|
|
28
|
+
const ENV_SCREENSHOT_PATH = "GDH_SCREENSHOT_PATH";
|
|
29
|
+
const ENV_SCREENSHOT_METADATA_PATH = "GDH_SCREENSHOT_METADATA_PATH";
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
20
31
|
export async function listRuntimeRecipes(input) {
|
|
21
32
|
const context = await loadRuntimeRecipeContext(input);
|
|
22
33
|
const recipes = context.recipes.map((recipe) => ({
|
|
@@ -79,6 +90,7 @@ export async function checkRuntimeRecipe(input) {
|
|
|
79
90
|
});
|
|
80
91
|
}
|
|
81
92
|
export async function runRuntimeRecipe(input) {
|
|
93
|
+
const screenshotRequested = input.screenshotPolicy === "fallback";
|
|
82
94
|
const liveCheck = await checkRuntimeRecipe(input);
|
|
83
95
|
if (liveCheck.state !== "runnable" || liveCheck.recipe === null) {
|
|
84
96
|
return {
|
|
@@ -95,6 +107,9 @@ export async function runRuntimeRecipe(input) {
|
|
|
95
107
|
finishedAt: new Date().toISOString(),
|
|
96
108
|
exitCode: null,
|
|
97
109
|
launchCommand: liveCheck.launchPreview?.command ?? null,
|
|
110
|
+
screenshot: screenshotRequested
|
|
111
|
+
? buildUnavailableRuntimeScreenshotResult("Screenshot capture was requested, but the runtime recipe was blocked before launch.", "runtime_launch_blocked")
|
|
112
|
+
: buildOmittedRuntimeScreenshotResult(),
|
|
98
113
|
artifacts: [],
|
|
99
114
|
statusPromotion: "none",
|
|
100
115
|
};
|
|
@@ -130,6 +145,9 @@ export async function runRuntimeRecipe(input) {
|
|
|
130
145
|
finishedAt: new Date().toISOString(),
|
|
131
146
|
exitCode: null,
|
|
132
147
|
launchCommand: executionCheck.launchPreview?.command ?? null,
|
|
148
|
+
screenshot: screenshotRequested
|
|
149
|
+
? buildUnavailableRuntimeScreenshotResult("Screenshot capture was requested, but GDH could not assemble a runnable launch preview.", "launch_preview_unavailable")
|
|
150
|
+
: buildOmittedRuntimeScreenshotResult(),
|
|
133
151
|
artifacts: [],
|
|
134
152
|
statusPromotion: "none",
|
|
135
153
|
};
|
|
@@ -143,10 +161,11 @@ export async function runRuntimeRecipe(input) {
|
|
|
143
161
|
const artifactDirectory = input.artifactDirectory ??
|
|
144
162
|
(await createRunArtifactDirectory(input.targetPath, `recipe-${input.recipeId}`));
|
|
145
163
|
await fs.mkdir(artifactDirectory, { recursive: true });
|
|
164
|
+
const preparedScreenshot = buildRuntimeScreenshotCapture(executionCheck.launchPreview, artifactDirectory, input.screenshotPolicy ?? "never");
|
|
146
165
|
const startedAt = new Date().toISOString();
|
|
147
166
|
let execution = null;
|
|
148
167
|
try {
|
|
149
|
-
execution = await executeCommand(
|
|
168
|
+
execution = await executeCommand(preparedScreenshot.preview, artifactDirectory, executionCheck.recipe?.launch.kind ?? "project_launch_adapter", input.maxRuntimeSeconds ?? null);
|
|
150
169
|
}
|
|
151
170
|
catch (error) {
|
|
152
171
|
execution = {
|
|
@@ -165,6 +184,7 @@ export async function runRuntimeRecipe(input) {
|
|
|
165
184
|
};
|
|
166
185
|
}
|
|
167
186
|
const finishedAt = new Date().toISOString();
|
|
187
|
+
const screenshot = await finalizeRuntimeScreenshotCapture(preparedScreenshot.result);
|
|
168
188
|
const reportPath = path.join(artifactDirectory, "report.json");
|
|
169
189
|
const result = {
|
|
170
190
|
version: GDH_RUNTIME_RECIPE_RUN_VERSION,
|
|
@@ -186,9 +206,28 @@ export async function runRuntimeRecipe(input) {
|
|
|
186
206
|
startedAt,
|
|
187
207
|
finishedAt,
|
|
188
208
|
exitCode: execution.exitCode,
|
|
189
|
-
launchCommand:
|
|
209
|
+
launchCommand: preparedScreenshot.preview.command,
|
|
210
|
+
screenshot,
|
|
190
211
|
artifacts: [
|
|
191
212
|
...execution.artifacts,
|
|
213
|
+
...(screenshot.state === "captured" && screenshot.imagePath !== null
|
|
214
|
+
? [
|
|
215
|
+
{
|
|
216
|
+
id: "screenshot",
|
|
217
|
+
path: screenshot.imagePath,
|
|
218
|
+
description: "Rendered screenshot captured during this runtime recipe run.",
|
|
219
|
+
},
|
|
220
|
+
]
|
|
221
|
+
: []),
|
|
222
|
+
...(screenshot.metadataPath !== null
|
|
223
|
+
? [
|
|
224
|
+
{
|
|
225
|
+
id: "screenshot-metadata",
|
|
226
|
+
path: screenshot.metadataPath,
|
|
227
|
+
description: "Structured screenshot-capture metadata for this runtime recipe run.",
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
: []),
|
|
192
231
|
{
|
|
193
232
|
id: "report",
|
|
194
233
|
path: reportPath,
|
|
@@ -380,18 +419,20 @@ async function inspectRuntimeRecipe(input) {
|
|
|
380
419
|
};
|
|
381
420
|
}
|
|
382
421
|
const values = resolveRuntimeValues(recipe, input);
|
|
383
|
-
const provider =
|
|
422
|
+
const provider = await inspectProvider(recipe, input.projectConfig, input.provider, values.environment);
|
|
384
423
|
const preChecks = await resolvePrechecks({
|
|
385
424
|
targetPath: input.targetPath,
|
|
386
425
|
evaluationRootPath: input.evaluationRootPath,
|
|
387
426
|
recipe,
|
|
388
427
|
environment: values.environment,
|
|
428
|
+
provider,
|
|
389
429
|
});
|
|
390
430
|
const launchPreview = await buildLaunchPreview({
|
|
391
431
|
targetPath: input.targetPath,
|
|
392
432
|
evaluationRootPath: input.evaluationRootPath,
|
|
393
433
|
recipe,
|
|
394
434
|
values,
|
|
435
|
+
provider,
|
|
395
436
|
workspaceMode: input.workspaceMode,
|
|
396
437
|
});
|
|
397
438
|
const reasons = [
|
|
@@ -399,8 +440,10 @@ async function inspectRuntimeRecipe(input) {
|
|
|
399
440
|
...resolveEnvironmentReasons(values.environment),
|
|
400
441
|
...resolvePrecheckReasons(preChecks),
|
|
401
442
|
];
|
|
402
|
-
|
|
403
|
-
reasons.
|
|
443
|
+
for (const reason of provider.reasons) {
|
|
444
|
+
if (!reasons.includes(reason)) {
|
|
445
|
+
reasons.push(reason);
|
|
446
|
+
}
|
|
404
447
|
}
|
|
405
448
|
const hasMisconfiguration = preChecks.some((entry) => entry.status === "failed" &&
|
|
406
449
|
(entry.kind === "project_path_exists" ||
|
|
@@ -412,7 +455,7 @@ async function inspectRuntimeRecipe(input) {
|
|
|
412
455
|
(preChecks.some((entry) => entry.status === "failed" && entry.required) ||
|
|
413
456
|
values.parameters.some((entry) => entry.status === "missing" && entry.required) ||
|
|
414
457
|
values.environment.some((entry) => entry.status === "missing" && entry.required) ||
|
|
415
|
-
|
|
458
|
+
provider.state !== "ready");
|
|
416
459
|
const state = hasMisconfiguration
|
|
417
460
|
? "misconfigured"
|
|
418
461
|
: isBlocked || launchPreview === null
|
|
@@ -425,7 +468,9 @@ async function inspectRuntimeRecipe(input) {
|
|
|
425
468
|
summary: state === "runnable"
|
|
426
469
|
? `Runtime recipe "${recipe.id}" is runnable.`
|
|
427
470
|
: state === "blocked"
|
|
428
|
-
?
|
|
471
|
+
? provider.state === "blocked"
|
|
472
|
+
? provider.summary
|
|
473
|
+
: `Runtime recipe "${recipe.id}" is blocked by environment or input requirements.`
|
|
429
474
|
: `Runtime recipe "${recipe.id}" is misconfigured.`,
|
|
430
475
|
reasons,
|
|
431
476
|
recipe,
|
|
@@ -596,21 +641,48 @@ function resolveRuntimeValues(recipe, input) {
|
|
|
596
641
|
environment,
|
|
597
642
|
};
|
|
598
643
|
}
|
|
599
|
-
function
|
|
600
|
-
const allowedProviders = projectConfig?.allowedProviders ?? [
|
|
601
|
-
const declaredProviders = recipe.providers.length > 0 ? recipe.providers : [
|
|
644
|
+
async function inspectProvider(recipe, projectConfig, requestedProvider, environment) {
|
|
645
|
+
const allowedProviders = projectConfig?.allowedProviders ?? [LOCAL_RUNTIME_PROVIDER];
|
|
646
|
+
const declaredProviders = recipe.providers.length > 0 ? recipe.providers : [LOCAL_RUNTIME_PROVIDER];
|
|
602
647
|
const resolved = requestedProvider ?? declaredProviders[0] ?? null;
|
|
603
|
-
const
|
|
648
|
+
const supportedBySelection = resolved !== null &&
|
|
604
649
|
allowedProviders.includes(resolved) &&
|
|
605
650
|
declaredProviders.includes(resolved);
|
|
651
|
+
if (!supportedBySelection) {
|
|
652
|
+
return {
|
|
653
|
+
requested: requestedProvider,
|
|
654
|
+
resolved,
|
|
655
|
+
allowedProviders,
|
|
656
|
+
supported: false,
|
|
657
|
+
state: "unsupported",
|
|
658
|
+
reasons: ["provider_not_supported"],
|
|
659
|
+
summary: resolved === null
|
|
660
|
+
? "No runtime provider could be resolved for this run configuration."
|
|
661
|
+
: `Provider "${resolved}" is not supported by the recipe or project configuration.`,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (resolved === DOCKER_RUNTIME_PROVIDER) {
|
|
665
|
+
const docker = await inspectDockerRuntimeProvider(new Map(environment
|
|
666
|
+
.filter((entry) => entry.value !== null)
|
|
667
|
+
.map((entry) => [entry.name, entry.value ?? ""])));
|
|
668
|
+
return {
|
|
669
|
+
requested: requestedProvider,
|
|
670
|
+
resolved,
|
|
671
|
+
allowedProviders,
|
|
672
|
+
supported: docker.state === "ready",
|
|
673
|
+
state: docker.state,
|
|
674
|
+
reasons: docker.reasons,
|
|
675
|
+
summary: docker.summary,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
606
678
|
return {
|
|
607
679
|
requested: requestedProvider,
|
|
608
680
|
resolved,
|
|
609
681
|
allowedProviders,
|
|
610
|
-
supported,
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
682
|
+
supported: true,
|
|
683
|
+
state: "ready",
|
|
684
|
+
reasons: [],
|
|
685
|
+
summary: `Provider "${resolved}" is allowed for this target.`,
|
|
614
686
|
};
|
|
615
687
|
}
|
|
616
688
|
async function resolvePrechecks(input) {
|
|
@@ -641,6 +713,14 @@ async function resolvePrechecks(input) {
|
|
|
641
713
|
continue;
|
|
642
714
|
}
|
|
643
715
|
if (definition.kind === "godot_editor_configured") {
|
|
716
|
+
if (input.provider.resolved === DOCKER_RUNTIME_PROVIDER) {
|
|
717
|
+
results.push({
|
|
718
|
+
...definition,
|
|
719
|
+
status: "skipped",
|
|
720
|
+
reason: null,
|
|
721
|
+
});
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
644
724
|
const configured = Boolean(await resolveConfiguredGodotEditorBin({
|
|
645
725
|
targetPath: input.targetPath,
|
|
646
726
|
environmentOverride: environmentMap.get("GDH_GODOT_EDITOR_BIN") ?? null,
|
|
@@ -704,6 +784,41 @@ async function buildLaunchPreview(input) {
|
|
|
704
784
|
name,
|
|
705
785
|
value,
|
|
706
786
|
}));
|
|
787
|
+
const environmentEntries = new Map(environment.map((entry) => [entry.name, entry.value]));
|
|
788
|
+
if (input.provider.resolved === DOCKER_RUNTIME_PROVIDER) {
|
|
789
|
+
const dockerCommand = input.recipe.launch.kind === "godot_project"
|
|
790
|
+
? [
|
|
791
|
+
"/usr/local/bin/godot-linux",
|
|
792
|
+
"--path",
|
|
793
|
+
toDockerContainerPath(input.evaluationRootPath, path.join(input.evaluationRootPath, input.recipe.projectPath)),
|
|
794
|
+
input.recipe.launch.scenePath,
|
|
795
|
+
...input.recipe.launch.arguments,
|
|
796
|
+
...parameterArguments,
|
|
797
|
+
...featureArguments,
|
|
798
|
+
]
|
|
799
|
+
: [
|
|
800
|
+
input.recipe.launch.interpreter === null
|
|
801
|
+
? toDockerContainerPath(input.evaluationRootPath, path.join(input.evaluationRootPath, input.recipe.launch.scriptPath))
|
|
802
|
+
: input.recipe.launch.interpreter,
|
|
803
|
+
...(input.recipe.launch.interpreter === null
|
|
804
|
+
? []
|
|
805
|
+
: [
|
|
806
|
+
toDockerContainerPath(input.evaluationRootPath, path.join(input.evaluationRootPath, input.recipe.launch.scriptPath)),
|
|
807
|
+
]),
|
|
808
|
+
...input.recipe.launch.arguments,
|
|
809
|
+
...parameterArguments,
|
|
810
|
+
...featureArguments,
|
|
811
|
+
];
|
|
812
|
+
return buildDockerRuntimeLaunchPreview({
|
|
813
|
+
evaluationRootPath: input.evaluationRootPath,
|
|
814
|
+
containerWorkingDirectory: toDockerContainerPath(input.evaluationRootPath, path.join(input.evaluationRootPath, input.recipe.launch.kind === "project_launch_adapter"
|
|
815
|
+
? input.recipe.launch.workingDirectory ?? input.recipe.projectPath
|
|
816
|
+
: input.recipe.projectPath)),
|
|
817
|
+
command: dockerCommand,
|
|
818
|
+
environment: environmentEntries,
|
|
819
|
+
workspaceMode: input.workspaceMode,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
707
822
|
if (input.recipe.launch.kind === "godot_project") {
|
|
708
823
|
const godotBin = await resolveConfiguredGodotEditorBin({
|
|
709
824
|
targetPath: input.targetPath,
|
|
@@ -727,6 +842,8 @@ async function buildLaunchPreview(input) {
|
|
|
727
842
|
cwd: projectDirectory,
|
|
728
843
|
env: environment,
|
|
729
844
|
workspaceMode: input.workspaceMode,
|
|
845
|
+
prepareHints: [],
|
|
846
|
+
cleanupHints: [],
|
|
730
847
|
};
|
|
731
848
|
}
|
|
732
849
|
const scriptPath = path.join(input.evaluationRootPath, input.recipe.launch.scriptPath);
|
|
@@ -749,6 +866,8 @@ async function buildLaunchPreview(input) {
|
|
|
749
866
|
cwd: path.join(input.evaluationRootPath, input.recipe.launch.workingDirectory ?? input.recipe.projectPath),
|
|
750
867
|
env: environment,
|
|
751
868
|
workspaceMode: input.workspaceMode,
|
|
869
|
+
prepareHints: [],
|
|
870
|
+
cleanupHints: [],
|
|
752
871
|
};
|
|
753
872
|
}
|
|
754
873
|
function bridgeNotRequired() {
|
|
@@ -808,6 +927,285 @@ async function createRunArtifactDirectory(targetPath, recipeId) {
|
|
|
808
927
|
await fs.mkdir(directory, { recursive: true });
|
|
809
928
|
return directory;
|
|
810
929
|
}
|
|
930
|
+
function buildRuntimeScreenshotCapture(preview, artifactDirectory, screenshotPolicy) {
|
|
931
|
+
if (screenshotPolicy !== "fallback") {
|
|
932
|
+
return {
|
|
933
|
+
preview,
|
|
934
|
+
result: buildOmittedRuntimeScreenshotResult(),
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
const imagePath = path.resolve(artifactDirectory, RUNTIME_SCREENSHOT_IMAGE_FILENAME);
|
|
938
|
+
const metadataPath = path.resolve(artifactDirectory, RUNTIME_SCREENSHOT_METADATA_FILENAME);
|
|
939
|
+
return {
|
|
940
|
+
preview: isDockerLaunchPreview(preview)
|
|
941
|
+
? attachDockerScreenshotCapture(preview, artifactDirectory)
|
|
942
|
+
: {
|
|
943
|
+
...preview,
|
|
944
|
+
env: [
|
|
945
|
+
...preview.env,
|
|
946
|
+
{
|
|
947
|
+
name: ENV_SCREENSHOT_CAPTURE,
|
|
948
|
+
value: "1",
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
name: ENV_SCREENSHOT_PATH,
|
|
952
|
+
value: imagePath,
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
name: ENV_SCREENSHOT_METADATA_PATH,
|
|
956
|
+
value: metadataPath,
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
},
|
|
960
|
+
result: {
|
|
961
|
+
requested: true,
|
|
962
|
+
state: "unavailable",
|
|
963
|
+
summary: "Screenshot capture was requested but has not completed yet.",
|
|
964
|
+
reason: null,
|
|
965
|
+
imagePath,
|
|
966
|
+
metadataPath,
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
function isDockerLaunchPreview(preview) {
|
|
971
|
+
return (preview.prepareHints ?? []).some((hint) => hint.kind === "docker_runtime_image");
|
|
972
|
+
}
|
|
973
|
+
function attachDockerScreenshotCapture(preview, artifactDirectory) {
|
|
974
|
+
const prepareHint = (preview.prepareHints ?? []).find((hint) => hint.kind === "docker_runtime_image") ?? null;
|
|
975
|
+
if (prepareHint === null ||
|
|
976
|
+
preview.command === null ||
|
|
977
|
+
preview.command[0] !== "docker") {
|
|
978
|
+
return preview;
|
|
979
|
+
}
|
|
980
|
+
const imageIndex = preview.command.lastIndexOf(prepareHint.imageTag);
|
|
981
|
+
if (imageIndex <= 0) {
|
|
982
|
+
return preview;
|
|
983
|
+
}
|
|
984
|
+
const resolvedArtifactDirectory = path.resolve(artifactDirectory);
|
|
985
|
+
const injectedArgs = [
|
|
986
|
+
"--mount",
|
|
987
|
+
`type=bind,src=${resolvedArtifactDirectory},dst=${RUNTIME_SCREENSHOT_CONTAINER_DIRECTORY}`,
|
|
988
|
+
"--env",
|
|
989
|
+
`${ENV_SCREENSHOT_CAPTURE}=1`,
|
|
990
|
+
"--env",
|
|
991
|
+
`${ENV_SCREENSHOT_PATH}=${RUNTIME_SCREENSHOT_CONTAINER_DIRECTORY}/${RUNTIME_SCREENSHOT_IMAGE_FILENAME}`,
|
|
992
|
+
"--env",
|
|
993
|
+
`${ENV_SCREENSHOT_METADATA_PATH}=${RUNTIME_SCREENSHOT_CONTAINER_DIRECTORY}/${RUNTIME_SCREENSHOT_METADATA_FILENAME}`,
|
|
994
|
+
];
|
|
995
|
+
return {
|
|
996
|
+
...preview,
|
|
997
|
+
command: [
|
|
998
|
+
...preview.command.slice(0, imageIndex),
|
|
999
|
+
...injectedArgs,
|
|
1000
|
+
...preview.command.slice(imageIndex),
|
|
1001
|
+
],
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function prepareBridgeSessionLaunchPreview(preview) {
|
|
1005
|
+
const explicitCapture = readExplicitScreenshotCapture(preview);
|
|
1006
|
+
if (explicitCapture === null || !isDockerLaunchPreview(preview)) {
|
|
1007
|
+
return preview;
|
|
1008
|
+
}
|
|
1009
|
+
return attachDockerExplicitScreenshotCapture(preview, explicitCapture);
|
|
1010
|
+
}
|
|
1011
|
+
function readExplicitScreenshotCapture(preview) {
|
|
1012
|
+
const environment = readLaunchEnvironment(preview);
|
|
1013
|
+
if (environment.get(ENV_SCREENSHOT_CAPTURE) !== "1") {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
const imagePath = environment.get(ENV_SCREENSHOT_PATH) ?? null;
|
|
1017
|
+
const metadataPath = environment.get(ENV_SCREENSHOT_METADATA_PATH) ?? null;
|
|
1018
|
+
if (imagePath === null || metadataPath === null) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
imagePath: path.resolve(imagePath),
|
|
1023
|
+
metadataPath: path.resolve(metadataPath),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
function readLaunchEnvironment(preview) {
|
|
1027
|
+
const environment = new Map(preview.env.map((entry) => [entry.name, entry.value]));
|
|
1028
|
+
if (preview.command?.[0] !== "docker") {
|
|
1029
|
+
return environment;
|
|
1030
|
+
}
|
|
1031
|
+
for (let index = 1; index < preview.command.length; index += 1) {
|
|
1032
|
+
if (preview.command[index] !== "--env") {
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const assignment = preview.command[index + 1];
|
|
1036
|
+
if (typeof assignment !== "string") {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const separatorIndex = assignment.indexOf("=");
|
|
1040
|
+
if (separatorIndex <= 0 || separatorIndex === assignment.length - 1) {
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
environment.set(assignment.slice(0, separatorIndex), assignment.slice(separatorIndex + 1));
|
|
1044
|
+
index += 1;
|
|
1045
|
+
}
|
|
1046
|
+
return environment;
|
|
1047
|
+
}
|
|
1048
|
+
async function ensureExplicitScreenshotCaptureDirectories(capture) {
|
|
1049
|
+
if (capture === null) {
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
await Promise.all([
|
|
1053
|
+
fs.mkdir(path.dirname(capture.imagePath), { recursive: true }),
|
|
1054
|
+
fs.mkdir(path.dirname(capture.metadataPath), { recursive: true }),
|
|
1055
|
+
]);
|
|
1056
|
+
}
|
|
1057
|
+
function attachDockerExplicitScreenshotCapture(preview, capture) {
|
|
1058
|
+
const prepareHint = (preview.prepareHints ?? []).find((hint) => hint.kind === "docker_runtime_image") ?? null;
|
|
1059
|
+
if (prepareHint === null ||
|
|
1060
|
+
preview.command === null ||
|
|
1061
|
+
preview.command[0] !== "docker") {
|
|
1062
|
+
return preview;
|
|
1063
|
+
}
|
|
1064
|
+
const imageIndex = preview.command.lastIndexOf(prepareHint.imageTag);
|
|
1065
|
+
if (imageIndex <= 0) {
|
|
1066
|
+
return preview;
|
|
1067
|
+
}
|
|
1068
|
+
const beforeImage = stripDockerEnvArguments(preview.command.slice(1, imageIndex), new Set([
|
|
1069
|
+
ENV_SCREENSHOT_CAPTURE,
|
|
1070
|
+
ENV_SCREENSHOT_PATH,
|
|
1071
|
+
ENV_SCREENSHOT_METADATA_PATH,
|
|
1072
|
+
]));
|
|
1073
|
+
const afterImage = preview.command.slice(imageIndex);
|
|
1074
|
+
const mountMappings = new Map();
|
|
1075
|
+
const mapHostFileToContainer = (hostFilePath) => {
|
|
1076
|
+
const hostDirectory = path.dirname(hostFilePath);
|
|
1077
|
+
let containerDirectory = mountMappings.get(hostDirectory) ?? null;
|
|
1078
|
+
if (containerDirectory === null) {
|
|
1079
|
+
containerDirectory = `${RUNTIME_SCREENSHOT_CONTAINER_DIRECTORY}/bridge-${mountMappings.size}`;
|
|
1080
|
+
mountMappings.set(hostDirectory, containerDirectory);
|
|
1081
|
+
}
|
|
1082
|
+
return path.posix.join(containerDirectory, path.basename(hostFilePath));
|
|
1083
|
+
};
|
|
1084
|
+
const containerImagePath = mapHostFileToContainer(capture.imagePath);
|
|
1085
|
+
const containerMetadataPath = mapHostFileToContainer(capture.metadataPath);
|
|
1086
|
+
const mountArgs = [...mountMappings.entries()].flatMap(([hostDirectory, containerDirectory]) => [
|
|
1087
|
+
"--mount",
|
|
1088
|
+
`type=bind,src=${hostDirectory},dst=${containerDirectory}`,
|
|
1089
|
+
]);
|
|
1090
|
+
return {
|
|
1091
|
+
...preview,
|
|
1092
|
+
command: [
|
|
1093
|
+
"docker",
|
|
1094
|
+
...beforeImage,
|
|
1095
|
+
...mountArgs,
|
|
1096
|
+
"--env",
|
|
1097
|
+
`${ENV_SCREENSHOT_CAPTURE}=1`,
|
|
1098
|
+
"--env",
|
|
1099
|
+
`${ENV_SCREENSHOT_PATH}=${containerImagePath}`,
|
|
1100
|
+
"--env",
|
|
1101
|
+
`${ENV_SCREENSHOT_METADATA_PATH}=${containerMetadataPath}`,
|
|
1102
|
+
...afterImage,
|
|
1103
|
+
],
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
function stripDockerEnvArguments(dockerArgs, environmentNames) {
|
|
1107
|
+
const stripped = [];
|
|
1108
|
+
for (let index = 0; index < dockerArgs.length; index += 1) {
|
|
1109
|
+
const current = dockerArgs[index];
|
|
1110
|
+
if (current !== "--env") {
|
|
1111
|
+
stripped.push(current);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const next = dockerArgs[index + 1];
|
|
1115
|
+
const environmentName = typeof next === "string" ? next.split("=")[0] ?? "" : "";
|
|
1116
|
+
if (next !== undefined && environmentNames.has(environmentName)) {
|
|
1117
|
+
index += 1;
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
stripped.push(current);
|
|
1121
|
+
if (next !== undefined) {
|
|
1122
|
+
stripped.push(next);
|
|
1123
|
+
index += 1;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return stripped;
|
|
1127
|
+
}
|
|
1128
|
+
async function finalizeRuntimeScreenshotCapture(provisional) {
|
|
1129
|
+
if (!provisional.requested) {
|
|
1130
|
+
return provisional;
|
|
1131
|
+
}
|
|
1132
|
+
const metadataPath = provisional.metadataPath;
|
|
1133
|
+
if (metadataPath === null) {
|
|
1134
|
+
return buildUnavailableRuntimeScreenshotResult("Screenshot capture was requested, but GDH did not reserve a metadata artifact path.", "screenshot_metadata_path_missing");
|
|
1135
|
+
}
|
|
1136
|
+
const metadataRaw = await fs.readFile(metadataPath, "utf8").catch(() => null);
|
|
1137
|
+
if (metadataRaw === null) {
|
|
1138
|
+
return {
|
|
1139
|
+
...provisional,
|
|
1140
|
+
state: "unavailable",
|
|
1141
|
+
summary: "Screenshot capture was requested, but the runtime did not write screenshot metadata.",
|
|
1142
|
+
reason: "screenshot_metadata_missing",
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
let parsed = null;
|
|
1146
|
+
try {
|
|
1147
|
+
parsed = JSON.parse(metadataRaw);
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
return {
|
|
1151
|
+
...provisional,
|
|
1152
|
+
state: "failed",
|
|
1153
|
+
summary: "Screenshot metadata was written but is not valid JSON.",
|
|
1154
|
+
reason: "screenshot_metadata_invalid",
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
const state = parsed?.state === "captured" ||
|
|
1158
|
+
parsed?.state === "unavailable" ||
|
|
1159
|
+
parsed?.state === "failed"
|
|
1160
|
+
? parsed.state
|
|
1161
|
+
: "failed";
|
|
1162
|
+
const reason = typeof parsed?.reason === "string" ? parsed.reason : null;
|
|
1163
|
+
const summary = typeof parsed?.summary === "string"
|
|
1164
|
+
? parsed.summary
|
|
1165
|
+
: state === "captured"
|
|
1166
|
+
? "Rendered screenshot captured successfully."
|
|
1167
|
+
: state === "unavailable"
|
|
1168
|
+
? "Rendered screenshot capture was unavailable for this run."
|
|
1169
|
+
: "Rendered screenshot capture failed.";
|
|
1170
|
+
if (state === "captured") {
|
|
1171
|
+
const imagePresent = provisional.imagePath !== null &&
|
|
1172
|
+
(await pathExists(provisional.imagePath));
|
|
1173
|
+
if (!imagePresent) {
|
|
1174
|
+
return {
|
|
1175
|
+
...provisional,
|
|
1176
|
+
state: "failed",
|
|
1177
|
+
summary: "Screenshot metadata reported success, but the image artifact is missing.",
|
|
1178
|
+
reason: "screenshot_image_missing",
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return {
|
|
1183
|
+
...provisional,
|
|
1184
|
+
state,
|
|
1185
|
+
summary,
|
|
1186
|
+
reason,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
function buildOmittedRuntimeScreenshotResult() {
|
|
1190
|
+
return {
|
|
1191
|
+
requested: false,
|
|
1192
|
+
state: "omitted",
|
|
1193
|
+
summary: "Screenshot capture was not requested for this runtime recipe run.",
|
|
1194
|
+
reason: null,
|
|
1195
|
+
imagePath: null,
|
|
1196
|
+
metadataPath: null,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
function buildUnavailableRuntimeScreenshotResult(summary, reason) {
|
|
1200
|
+
return {
|
|
1201
|
+
requested: true,
|
|
1202
|
+
state: "unavailable",
|
|
1203
|
+
summary,
|
|
1204
|
+
reason,
|
|
1205
|
+
imagePath: null,
|
|
1206
|
+
metadataPath: null,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
811
1209
|
async function executeCommand(preview, artifactDirectory, launchKind, maxRuntimeSeconds) {
|
|
812
1210
|
const command = preview.command;
|
|
813
1211
|
if (command === null || command.length === 0) {
|
|
@@ -822,110 +1220,153 @@ async function executeCommand(preview, artifactDirectory, launchKind, maxRuntime
|
|
|
822
1220
|
command,
|
|
823
1221
|
env: preview.env,
|
|
824
1222
|
workspaceMode: preview.workspaceMode,
|
|
1223
|
+
prepareHints: preview.prepareHints ?? [],
|
|
1224
|
+
cleanupHints: preview.cleanupHints ?? [],
|
|
825
1225
|
}, null, 2)}\n`, "utf8");
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1226
|
+
await applyLaunchPrepareHints(preview.prepareHints ?? []);
|
|
1227
|
+
try {
|
|
1228
|
+
const output = await new Promise((resolve, reject) => {
|
|
1229
|
+
const child = spawn(command[0], command.slice(1), {
|
|
1230
|
+
cwd: preview.cwd ?? undefined,
|
|
1231
|
+
env: {
|
|
1232
|
+
...process.env,
|
|
1233
|
+
...Object.fromEntries(preview.env.map((entry) => [entry.name, entry.value])),
|
|
1234
|
+
},
|
|
1235
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1236
|
+
});
|
|
1237
|
+
let stdout = "";
|
|
1238
|
+
let stderr = "";
|
|
1239
|
+
let timedOut = false;
|
|
1240
|
+
let timeoutHandle = null;
|
|
1241
|
+
let forceKillHandle = null;
|
|
1242
|
+
if (maxRuntimeSeconds !== null && maxRuntimeSeconds > 0) {
|
|
1243
|
+
timeoutHandle = setTimeout(() => {
|
|
1244
|
+
timedOut = true;
|
|
1245
|
+
child.kill("SIGTERM");
|
|
1246
|
+
forceKillHandle = setTimeout(() => {
|
|
1247
|
+
child.kill("SIGKILL");
|
|
1248
|
+
}, 5000);
|
|
1249
|
+
}, maxRuntimeSeconds * 1000);
|
|
1250
|
+
}
|
|
1251
|
+
child.stdout.on("data", (chunk) => {
|
|
1252
|
+
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1253
|
+
});
|
|
1254
|
+
child.stderr.on("data", (chunk) => {
|
|
1255
|
+
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1256
|
+
});
|
|
1257
|
+
child.on("error", reject);
|
|
1258
|
+
child.on("close", (exitCode, signal) => {
|
|
1259
|
+
if (timeoutHandle !== null) {
|
|
1260
|
+
clearTimeout(timeoutHandle);
|
|
1261
|
+
}
|
|
1262
|
+
if (forceKillHandle !== null) {
|
|
1263
|
+
clearTimeout(forceKillHandle);
|
|
1264
|
+
}
|
|
1265
|
+
resolve({
|
|
1266
|
+
exitCode: exitCode ?? (timedOut ? 0 : 1),
|
|
1267
|
+
signal: signal ?? null,
|
|
1268
|
+
stdout,
|
|
1269
|
+
stderr,
|
|
1270
|
+
timedOut,
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
834
1273
|
});
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
child.kill("SIGTERM");
|
|
844
|
-
forceKillHandle = setTimeout(() => {
|
|
845
|
-
child.kill("SIGKILL");
|
|
846
|
-
}, 5000);
|
|
847
|
-
}, maxRuntimeSeconds * 1000);
|
|
1274
|
+
await fs.writeFile(stdoutPath, output.stdout, "utf8");
|
|
1275
|
+
await fs.writeFile(stderrPath, output.stderr, "utf8");
|
|
1276
|
+
if (output.timedOut) {
|
|
1277
|
+
await fs.writeFile(terminationPath, `${JSON.stringify({
|
|
1278
|
+
policy: "max_runtime_seconds",
|
|
1279
|
+
maxRuntimeSeconds,
|
|
1280
|
+
signal: output.signal,
|
|
1281
|
+
}, null, 2)}\n`, "utf8");
|
|
848
1282
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1283
|
+
const stderrFatality = detectFatalGodotStderr({
|
|
1284
|
+
launchKind,
|
|
1285
|
+
exitCode: output.exitCode,
|
|
1286
|
+
stderr: output.stderr,
|
|
854
1287
|
});
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1288
|
+
const timedOutSuccessfully = output.timedOut && !stderrFatality.detected;
|
|
1289
|
+
return {
|
|
1290
|
+
exitCode: output.exitCode,
|
|
1291
|
+
state: timedOutSuccessfully || (!stderrFatality.detected && output.exitCode === 0)
|
|
1292
|
+
? "passed"
|
|
1293
|
+
: "failed",
|
|
1294
|
+
reasons: stderrFatality.detected && stderrFatality.reason !== null
|
|
1295
|
+
? [stderrFatality.reason]
|
|
1296
|
+
: timedOutSuccessfully || output.exitCode === 0
|
|
1297
|
+
? []
|
|
1298
|
+
: [`process_exit_${output.exitCode}`],
|
|
1299
|
+
summaryOverride: stderrFatality.summary ??
|
|
1300
|
+
(timedOutSuccessfully
|
|
1301
|
+
? `Runtime run stayed alive for ${maxRuntimeSeconds} second(s) and GDH terminated it intentionally.`
|
|
1302
|
+
: null),
|
|
1303
|
+
artifacts: [
|
|
1304
|
+
{
|
|
1305
|
+
id: "launch",
|
|
1306
|
+
path: launchPath,
|
|
1307
|
+
description: "Assembled launch preview used for the runtime recipe run.",
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
id: "stdout",
|
|
1311
|
+
path: stdoutPath,
|
|
1312
|
+
description: "Captured standard output from the runtime recipe process.",
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
id: "stderr",
|
|
1316
|
+
path: stderrPath,
|
|
1317
|
+
description: "Captured standard error from the runtime recipe process.",
|
|
1318
|
+
},
|
|
1319
|
+
...(output.timedOut
|
|
1320
|
+
? [
|
|
1321
|
+
{
|
|
1322
|
+
id: "termination",
|
|
1323
|
+
path: terminationPath,
|
|
1324
|
+
description: "Termination policy artifact for a bounded runtime run stopped by GDH.",
|
|
1325
|
+
},
|
|
1326
|
+
]
|
|
1327
|
+
: []),
|
|
1328
|
+
],
|
|
1329
|
+
errorMessage: null,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
finally {
|
|
1333
|
+
await applyLaunchCleanupHints(preview.cleanupHints ?? []).catch(() => { });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async function applyLaunchPrepareHints(hints) {
|
|
1337
|
+
for (const hint of hints) {
|
|
1338
|
+
if (hint.kind === "docker_runtime_image") {
|
|
1339
|
+
await ensureDockerRuntimeImagePlan({
|
|
1340
|
+
imageTag: hint.imageTag,
|
|
1341
|
+
platform: hint.platform,
|
|
1342
|
+
source: hint.source,
|
|
869
1343
|
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
async function applyLaunchCleanupHints(hints) {
|
|
1348
|
+
for (const hint of hints) {
|
|
1349
|
+
if (hint.kind === "docker_container") {
|
|
1350
|
+
await removeDockerContainer(hint.containerName);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
async function removeDockerContainer(containerName) {
|
|
1355
|
+
try {
|
|
1356
|
+
await execFileAsync("docker", ["rm", "-f", containerName], {
|
|
1357
|
+
env: process.env,
|
|
1358
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
870
1359
|
});
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
const stderrFatality = detectFatalGodotStderr({
|
|
882
|
-
launchKind,
|
|
883
|
-
exitCode: output.exitCode,
|
|
884
|
-
stderr: output.stderr,
|
|
885
|
-
});
|
|
886
|
-
const timedOutSuccessfully = output.timedOut && !stderrFatality.detected;
|
|
887
|
-
return {
|
|
888
|
-
exitCode: output.exitCode,
|
|
889
|
-
state: timedOutSuccessfully || (!stderrFatality.detected && output.exitCode === 0)
|
|
890
|
-
? "passed"
|
|
891
|
-
: "failed",
|
|
892
|
-
reasons: stderrFatality.detected && stderrFatality.reason !== null
|
|
893
|
-
? [stderrFatality.reason]
|
|
894
|
-
: timedOutSuccessfully || output.exitCode === 0
|
|
895
|
-
? []
|
|
896
|
-
: [`process_exit_${output.exitCode}`],
|
|
897
|
-
summaryOverride: stderrFatality.summary ??
|
|
898
|
-
(timedOutSuccessfully
|
|
899
|
-
? `Runtime run stayed alive for ${maxRuntimeSeconds} second(s) and GDH terminated it intentionally.`
|
|
900
|
-
: null),
|
|
901
|
-
artifacts: [
|
|
902
|
-
{
|
|
903
|
-
id: "launch",
|
|
904
|
-
path: launchPath,
|
|
905
|
-
description: "Assembled launch preview used for the runtime recipe run.",
|
|
906
|
-
},
|
|
907
|
-
{
|
|
908
|
-
id: "stdout",
|
|
909
|
-
path: stdoutPath,
|
|
910
|
-
description: "Captured standard output from the runtime recipe process.",
|
|
911
|
-
},
|
|
912
|
-
{
|
|
913
|
-
id: "stderr",
|
|
914
|
-
path: stderrPath,
|
|
915
|
-
description: "Captured standard error from the runtime recipe process.",
|
|
916
|
-
},
|
|
917
|
-
...(output.timedOut
|
|
918
|
-
? [
|
|
919
|
-
{
|
|
920
|
-
id: "termination",
|
|
921
|
-
path: terminationPath,
|
|
922
|
-
description: "Termination policy artifact for a bounded runtime run stopped by GDH.",
|
|
923
|
-
},
|
|
924
|
-
]
|
|
925
|
-
: []),
|
|
926
|
-
],
|
|
927
|
-
errorMessage: null,
|
|
928
|
-
};
|
|
1360
|
+
}
|
|
1361
|
+
catch (error) {
|
|
1362
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1363
|
+
if (message.includes("No such container") ||
|
|
1364
|
+
message.includes("is not running") ||
|
|
1365
|
+
message.includes("Error: No such container")) {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
throw error;
|
|
1369
|
+
}
|
|
929
1370
|
}
|
|
930
1371
|
function detectFatalGodotStderr(input) {
|
|
931
1372
|
if (input.launchKind !== "godot_project" || input.exitCode !== 0) {
|
|
@@ -965,6 +1406,17 @@ function detectFatalGodotStderr(input) {
|
|
|
965
1406
|
const BRIDGE_SESSION_DIRECTORY = ".gdh-state/bridge-sessions";
|
|
966
1407
|
const BRIDGE_LAUNCH_HOLD_ITERATIONS = "3600";
|
|
967
1408
|
const BRIDGE_HANDSHAKE_TIMEOUT_MS = 30000;
|
|
1409
|
+
const HOST_RUNTIME_BRIDGE_ENTRIES = [
|
|
1410
|
+
{
|
|
1411
|
+
id: "state.node_property.await",
|
|
1412
|
+
summary: "Wait for one serializable node property to match an expected value through bounded polling.",
|
|
1413
|
+
kind: "waiter",
|
|
1414
|
+
shape: "composite",
|
|
1415
|
+
source: "core",
|
|
1416
|
+
executionRoute: null,
|
|
1417
|
+
safetyLevel: "read_only",
|
|
1418
|
+
},
|
|
1419
|
+
];
|
|
968
1420
|
export function createRuntimeBridgeManager() {
|
|
969
1421
|
const sessions = new Map();
|
|
970
1422
|
return {
|
|
@@ -1015,6 +1467,7 @@ export function createRuntimeBridgeManager() {
|
|
|
1015
1467
|
const startedAt = new Date().toISOString();
|
|
1016
1468
|
let startedChild = null;
|
|
1017
1469
|
let startedSocket = null;
|
|
1470
|
+
let executionLaunchPreview = null;
|
|
1018
1471
|
try {
|
|
1019
1472
|
const executionCheck = await checkRuntimeRecipe({
|
|
1020
1473
|
...input,
|
|
@@ -1043,21 +1496,27 @@ export function createRuntimeBridgeManager() {
|
|
|
1043
1496
|
session: null,
|
|
1044
1497
|
};
|
|
1045
1498
|
}
|
|
1499
|
+
const explicitScreenshotCapture = readExplicitScreenshotCapture(executionCheck.launchPreview);
|
|
1500
|
+
executionLaunchPreview = prepareBridgeSessionLaunchPreview(executionCheck.launchPreview);
|
|
1501
|
+
await ensureExplicitScreenshotCaptureDirectories(explicitScreenshotCapture);
|
|
1046
1502
|
await fs.writeFile(launchPath, `${JSON.stringify({
|
|
1047
|
-
cwd:
|
|
1048
|
-
command:
|
|
1049
|
-
env:
|
|
1050
|
-
workspaceMode:
|
|
1503
|
+
cwd: executionLaunchPreview.cwd,
|
|
1504
|
+
command: executionLaunchPreview.command,
|
|
1505
|
+
env: executionLaunchPreview.env,
|
|
1506
|
+
workspaceMode: executionLaunchPreview.workspaceMode,
|
|
1507
|
+
prepareHints: executionLaunchPreview.prepareHints ?? [],
|
|
1508
|
+
cleanupHints: executionLaunchPreview.cleanupHints ?? [],
|
|
1051
1509
|
bridge: {
|
|
1052
1510
|
port: bridgePort,
|
|
1053
1511
|
sessionId,
|
|
1054
1512
|
},
|
|
1055
1513
|
}, null, 2)}\n`, "utf8");
|
|
1056
|
-
|
|
1057
|
-
|
|
1514
|
+
await applyLaunchPrepareHints(executionLaunchPreview.prepareHints ?? []);
|
|
1515
|
+
const child = spawn(executionLaunchPreview.command?.[0] ?? "", executionLaunchPreview.command?.slice(1) ?? [], {
|
|
1516
|
+
cwd: executionLaunchPreview.cwd ?? undefined,
|
|
1058
1517
|
env: {
|
|
1059
1518
|
...process.env,
|
|
1060
|
-
...Object.fromEntries(
|
|
1519
|
+
...Object.fromEntries(executionLaunchPreview.env.map((entry) => [entry.name, entry.value])),
|
|
1061
1520
|
},
|
|
1062
1521
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1063
1522
|
});
|
|
@@ -1089,7 +1548,7 @@ export function createRuntimeBridgeManager() {
|
|
|
1089
1548
|
timeoutMs: BRIDGE_HANDSHAKE_TIMEOUT_MS,
|
|
1090
1549
|
});
|
|
1091
1550
|
startedSocket = ws;
|
|
1092
|
-
const entries = await listBridgeEntriesFromSocket(ws, sessionId);
|
|
1551
|
+
const entries = mergeRuntimeBridgeEntries(await listBridgeEntriesFromSocket(ws, sessionId));
|
|
1093
1552
|
const handle = {
|
|
1094
1553
|
sessionId,
|
|
1095
1554
|
targetPath: input.targetPath,
|
|
@@ -1098,7 +1557,7 @@ export function createRuntimeBridgeManager() {
|
|
|
1098
1557
|
workspace,
|
|
1099
1558
|
workspaceMode: input.workspaceMode,
|
|
1100
1559
|
startedAt,
|
|
1101
|
-
launchPreview:
|
|
1560
|
+
launchPreview: executionLaunchPreview,
|
|
1102
1561
|
bridgePort,
|
|
1103
1562
|
bridgeToken,
|
|
1104
1563
|
workingCopyPath: input.workspaceMode === "isolated_copy" ? workspace.workspacePath : null,
|
|
@@ -1155,7 +1614,7 @@ export function createRuntimeBridgeManager() {
|
|
|
1155
1614
|
summary: "Runtime bridge session is connected and ready for bounded invocation.",
|
|
1156
1615
|
reasons: [],
|
|
1157
1616
|
},
|
|
1158
|
-
launchCommand:
|
|
1617
|
+
launchCommand: executionLaunchPreview.command,
|
|
1159
1618
|
session: summarizeBridgeSession(handle),
|
|
1160
1619
|
};
|
|
1161
1620
|
}
|
|
@@ -1166,6 +1625,7 @@ export function createRuntimeBridgeManager() {
|
|
|
1166
1625
|
if (startedChild) {
|
|
1167
1626
|
startedChild.kill("SIGTERM");
|
|
1168
1627
|
}
|
|
1628
|
+
await applyLaunchCleanupHints(executionLaunchPreview?.cleanupHints ?? []).catch(() => { });
|
|
1169
1629
|
await workspace.cleanup();
|
|
1170
1630
|
return {
|
|
1171
1631
|
targetPath: input.targetPath,
|
|
@@ -1199,7 +1659,7 @@ export function createRuntimeBridgeManager() {
|
|
|
1199
1659
|
entries: session.entries,
|
|
1200
1660
|
};
|
|
1201
1661
|
}
|
|
1202
|
-
session.entries = await listBridgeEntriesFromSocket(session.ws, sessionId);
|
|
1662
|
+
session.entries = mergeRuntimeBridgeEntries(await listBridgeEntriesFromSocket(session.ws, sessionId));
|
|
1203
1663
|
return {
|
|
1204
1664
|
sessionId,
|
|
1205
1665
|
state: "ready",
|
|
@@ -1249,6 +1709,26 @@ export function createRuntimeBridgeManager() {
|
|
|
1249
1709
|
entryId,
|
|
1250
1710
|
input: inputValue,
|
|
1251
1711
|
});
|
|
1712
|
+
const syntheticResponse = await invokeSyntheticBridgeEntry(session, entryId, inputValue);
|
|
1713
|
+
if (syntheticResponse !== null) {
|
|
1714
|
+
recordBridgeEvent(session, "invoke_response", {
|
|
1715
|
+
entryId,
|
|
1716
|
+
state: syntheticResponse.state,
|
|
1717
|
+
result: syntheticResponse.result,
|
|
1718
|
+
error: syntheticResponse.error,
|
|
1719
|
+
});
|
|
1720
|
+
return {
|
|
1721
|
+
sessionId,
|
|
1722
|
+
entryId,
|
|
1723
|
+
state: syntheticResponse.state,
|
|
1724
|
+
summary: syntheticResponse.state === "ok"
|
|
1725
|
+
? `Bridge entry "${entryId}" completed successfully.`
|
|
1726
|
+
: `Bridge entry "${entryId}" returned state "${syntheticResponse.state}".`,
|
|
1727
|
+
reasons: syntheticResponse.error ? [syntheticResponse.error] : [],
|
|
1728
|
+
result: syntheticResponse.result,
|
|
1729
|
+
transcriptPath: session.transcriptPath,
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1252
1732
|
const response = await invokeBridgeEntryOverSocket(session.ws, sessionId, entryId, inputValue);
|
|
1253
1733
|
recordBridgeEvent(session, "invoke_response", {
|
|
1254
1734
|
entryId,
|
|
@@ -1343,19 +1823,148 @@ async function stopBridgeSessionHandle(session) {
|
|
|
1343
1823
|
};
|
|
1344
1824
|
}
|
|
1345
1825
|
async function finalizeBridgeSession(session) {
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1826
|
+
try {
|
|
1827
|
+
await applyLaunchCleanupHints(session.launchPreview.cleanupHints ?? []);
|
|
1828
|
+
}
|
|
1829
|
+
catch (error) {
|
|
1830
|
+
recordBridgeEvent(session, "cleanup_error", {
|
|
1831
|
+
message: formatBridgeError(error),
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
finally {
|
|
1835
|
+
const payload = {
|
|
1836
|
+
session: summarizeBridgeSession(session),
|
|
1837
|
+
launch: {
|
|
1838
|
+
cwd: session.launchPreview.cwd,
|
|
1839
|
+
command: session.launchPreview.command,
|
|
1840
|
+
env: session.launchPreview.env,
|
|
1841
|
+
workspaceMode: session.launchPreview.workspaceMode,
|
|
1842
|
+
prepareHints: session.launchPreview.prepareHints ?? [],
|
|
1843
|
+
cleanupHints: session.launchPreview.cleanupHints ?? [],
|
|
1844
|
+
},
|
|
1845
|
+
transcript: session.transcript,
|
|
1846
|
+
};
|
|
1847
|
+
await fs.mkdir(session.artifactDirectory, { recursive: true });
|
|
1848
|
+
await fs.writeFile(session.transcriptPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
1849
|
+
await session.workspace.cleanup();
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
function mergeRuntimeBridgeEntries(entries) {
|
|
1853
|
+
const merged = [...entries];
|
|
1854
|
+
const presentIds = new Set(entries.map((entry) => entry.id));
|
|
1855
|
+
for (const entry of HOST_RUNTIME_BRIDGE_ENTRIES) {
|
|
1856
|
+
if (!presentIds.has(entry.id)) {
|
|
1857
|
+
merged.push(entry);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return merged;
|
|
1861
|
+
}
|
|
1862
|
+
async function invokeSyntheticBridgeEntry(session, entryId, inputValue) {
|
|
1863
|
+
if (entryId === "state.node_property.await") {
|
|
1864
|
+
return invokeNodePropertyAwaitEntry(session, inputValue);
|
|
1865
|
+
}
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
async function invokeNodePropertyAwaitEntry(session, inputValue) {
|
|
1869
|
+
const input = toJsonRecord(inputValue);
|
|
1870
|
+
const nodePath = typeof input["nodePath"] === "string" ? input["nodePath"] : "";
|
|
1871
|
+
const property = typeof input["property"] === "string" ? input["property"] : "";
|
|
1872
|
+
const hasExpected = Object.prototype.hasOwnProperty.call(input, "expected");
|
|
1873
|
+
const expected = hasExpected ? input["expected"] : null;
|
|
1874
|
+
const timeoutMs = clampNumber(input["timeoutMs"], 1500, 100, 5000);
|
|
1875
|
+
const pollIntervalMs = clampNumber(input["pollIntervalMs"], 50, 25, 250);
|
|
1876
|
+
if (nodePath.length === 0 || property.length === 0) {
|
|
1877
|
+
return {
|
|
1878
|
+
state: "failed",
|
|
1879
|
+
result: null,
|
|
1880
|
+
error: "state.node_property.await requires nodePath and property.",
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
if (!hasExpected) {
|
|
1884
|
+
return {
|
|
1885
|
+
state: "failed",
|
|
1886
|
+
result: null,
|
|
1887
|
+
error: "state.node_property.await requires an expected value.",
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
const startedAt = Date.now();
|
|
1891
|
+
let attempts = 0;
|
|
1892
|
+
let lastValue = null;
|
|
1893
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
1894
|
+
attempts += 1;
|
|
1895
|
+
recordBridgeEvent(session, "invoke_expanded_leaf_step", {
|
|
1896
|
+
parentEntryId: "state.node_property.await",
|
|
1897
|
+
leafEntryId: "state.node_property.get",
|
|
1898
|
+
attempt: attempts,
|
|
1899
|
+
input: {
|
|
1900
|
+
nodePath,
|
|
1901
|
+
property,
|
|
1902
|
+
},
|
|
1903
|
+
});
|
|
1904
|
+
const response = await invokeBridgeEntryOverSocket(session.ws, session.sessionId, "state.node_property.get", {
|
|
1905
|
+
nodePath,
|
|
1906
|
+
property,
|
|
1907
|
+
});
|
|
1908
|
+
recordBridgeEvent(session, "invoke_expanded_leaf_response", {
|
|
1909
|
+
parentEntryId: "state.node_property.await",
|
|
1910
|
+
leafEntryId: "state.node_property.get",
|
|
1911
|
+
attempt: attempts,
|
|
1912
|
+
state: response.state,
|
|
1913
|
+
result: response.result,
|
|
1914
|
+
error: response.error,
|
|
1915
|
+
});
|
|
1916
|
+
if (response.state !== "ok") {
|
|
1917
|
+
return {
|
|
1918
|
+
state: response.state,
|
|
1919
|
+
result: response.result,
|
|
1920
|
+
error: response.error,
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
const payload = toJsonRecord(response.result);
|
|
1924
|
+
lastValue = (payload["value"] ?? null);
|
|
1925
|
+
if (jsonValuesEqual(lastValue, expected)) {
|
|
1926
|
+
return {
|
|
1927
|
+
state: "ok",
|
|
1928
|
+
result: {
|
|
1929
|
+
nodePath,
|
|
1930
|
+
property,
|
|
1931
|
+
expected,
|
|
1932
|
+
value: lastValue,
|
|
1933
|
+
attempts,
|
|
1934
|
+
elapsedMs: Date.now() - startedAt,
|
|
1935
|
+
},
|
|
1936
|
+
error: null,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
await waitMs(pollIntervalMs);
|
|
1940
|
+
}
|
|
1941
|
+
return {
|
|
1942
|
+
state: "unavailable",
|
|
1943
|
+
result: {
|
|
1944
|
+
nodePath,
|
|
1945
|
+
property,
|
|
1946
|
+
expected,
|
|
1947
|
+
value: lastValue,
|
|
1948
|
+
attempts,
|
|
1949
|
+
elapsedMs: Date.now() - startedAt,
|
|
1353
1950
|
},
|
|
1354
|
-
|
|
1951
|
+
error: "Timed out waiting for the requested node property value.",
|
|
1355
1952
|
};
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1953
|
+
}
|
|
1954
|
+
function toJsonRecord(value) {
|
|
1955
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
1956
|
+
return {};
|
|
1957
|
+
}
|
|
1958
|
+
return value;
|
|
1959
|
+
}
|
|
1960
|
+
function clampNumber(value, fallback, minimum, maximum) {
|
|
1961
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1962
|
+
return fallback;
|
|
1963
|
+
}
|
|
1964
|
+
return Math.max(minimum, Math.min(maximum, Math.trunc(value)));
|
|
1965
|
+
}
|
|
1966
|
+
function jsonValuesEqual(left, right) {
|
|
1967
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1359
1968
|
}
|
|
1360
1969
|
function summarizeBridgeSession(session) {
|
|
1361
1970
|
return {
|