@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.
Files changed (59) hide show
  1. package/INSTALL-BUNDLE.json +1 -1
  2. package/README.md +6 -4
  3. package/RELEASE-SPAN-UPDATE-CONTRACTS.json +114 -0
  4. package/node_modules/@gdh/adapters/dist/index.d.ts.map +1 -1
  5. package/node_modules/@gdh/adapters/dist/index.js +42 -3
  6. package/node_modules/@gdh/adapters/dist/index.js.map +1 -1
  7. package/node_modules/@gdh/adapters/package.json +8 -8
  8. package/node_modules/@gdh/authoring/dist/lsp.d.ts.map +1 -1
  9. package/node_modules/@gdh/authoring/dist/lsp.js +4 -0
  10. package/node_modules/@gdh/authoring/dist/lsp.js.map +1 -1
  11. package/node_modules/@gdh/authoring/dist/prepare.d.ts.map +1 -1
  12. package/node_modules/@gdh/authoring/dist/prepare.js +9 -1
  13. package/node_modules/@gdh/authoring/dist/prepare.js.map +1 -1
  14. package/node_modules/@gdh/authoring/dist/project.js +1 -1
  15. package/node_modules/@gdh/authoring/dist/project.js.map +1 -1
  16. package/node_modules/@gdh/authoring/package.json +2 -2
  17. package/node_modules/@gdh/cli/dist/index.js +4 -4
  18. package/node_modules/@gdh/cli/dist/index.js.map +1 -1
  19. package/node_modules/@gdh/cli/dist/migrate.d.ts +7 -0
  20. package/node_modules/@gdh/cli/dist/migrate.d.ts.map +1 -1
  21. package/node_modules/@gdh/cli/dist/migrate.js +95 -2
  22. package/node_modules/@gdh/cli/dist/migrate.js.map +1 -1
  23. package/node_modules/@gdh/cli/package.json +10 -10
  24. package/node_modules/@gdh/core/dist/index.d.ts +40 -1
  25. package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
  26. package/node_modules/@gdh/core/dist/index.js +7 -1
  27. package/node_modules/@gdh/core/dist/index.js.map +1 -1
  28. package/node_modules/@gdh/core/package.json +1 -1
  29. package/node_modules/@gdh/docs/dist/guidance.d.ts.map +1 -1
  30. package/node_modules/@gdh/docs/dist/guidance.js +10 -0
  31. package/node_modules/@gdh/docs/dist/guidance.js.map +1 -1
  32. package/node_modules/@gdh/docs/package.json +2 -2
  33. package/node_modules/@gdh/mcp/package.json +8 -8
  34. package/node_modules/@gdh/observability/dist/runtime-bundles.js +9 -9
  35. package/node_modules/@gdh/observability/dist/runtime-bundles.js.map +1 -1
  36. package/node_modules/@gdh/observability/package.json +2 -2
  37. package/node_modules/@gdh/runtime/dist/bridge-surface.d.ts.map +1 -1
  38. package/node_modules/@gdh/runtime/dist/bridge-surface.js +307 -33
  39. package/node_modules/@gdh/runtime/dist/bridge-surface.js.map +1 -1
  40. package/node_modules/@gdh/runtime/dist/docker-provider.d.ts +43 -0
  41. package/node_modules/@gdh/runtime/dist/docker-provider.d.ts.map +1 -0
  42. package/node_modules/@gdh/runtime/dist/docker-provider.js +233 -0
  43. package/node_modules/@gdh/runtime/dist/docker-provider.js.map +1 -0
  44. package/node_modules/@gdh/runtime/dist/index.d.ts +1 -0
  45. package/node_modules/@gdh/runtime/dist/index.d.ts.map +1 -1
  46. package/node_modules/@gdh/runtime/dist/index.js +745 -136
  47. package/node_modules/@gdh/runtime/dist/index.js.map +1 -1
  48. package/node_modules/@gdh/runtime/package.json +2 -2
  49. package/node_modules/@gdh/scan/dist/onboard.d.ts.map +1 -1
  50. package/node_modules/@gdh/scan/dist/onboard.js +6 -2
  51. package/node_modules/@gdh/scan/dist/onboard.js.map +1 -1
  52. package/node_modules/@gdh/scan/package.json +3 -3
  53. package/node_modules/@gdh/verify/dist/readiness.js +5 -5
  54. package/node_modules/@gdh/verify/dist/readiness.js.map +1 -1
  55. package/node_modules/@gdh/verify/dist/scenarios.d.ts.map +1 -1
  56. package/node_modules/@gdh/verify/dist/scenarios.js +96 -9
  57. package/node_modules/@gdh/verify/dist/scenarios.js.map +1 -1
  58. package/node_modules/@gdh/verify/package.json +7 -7
  59. 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(executionCheck.launchPreview, artifactDirectory, executionCheck.recipe?.launch.kind ?? "project_launch_adapter", input.maxRuntimeSeconds ?? null);
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: executionCheck.launchPreview.command,
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 = resolveProvider(recipe, input.projectConfig, input.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
- if (!provider.supported) {
403
- reasons.push("provider_not_supported");
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
- !provider.supported);
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
- ? `Runtime recipe "${recipe.id}" is blocked by environment or input requirements.`
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 resolveProvider(recipe, projectConfig, requestedProvider) {
600
- const allowedProviders = projectConfig?.allowedProviders ?? ["local"];
601
- const declaredProviders = recipe.providers.length > 0 ? recipe.providers : ["local"];
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 supported = resolved !== null &&
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
- summary: supported
612
- ? `Provider "${resolved}" is allowed for this target.`
613
- : `Provider "${resolved}" is not supported by the recipe or project configuration.`,
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
- const output = await new Promise((resolve, reject) => {
827
- const child = spawn(command[0], command.slice(1), {
828
- cwd: preview.cwd ?? undefined,
829
- env: {
830
- ...process.env,
831
- ...Object.fromEntries(preview.env.map((entry) => [entry.name, entry.value])),
832
- },
833
- stdio: ["ignore", "pipe", "pipe"],
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
- let stdout = "";
836
- let stderr = "";
837
- let timedOut = false;
838
- let timeoutHandle = null;
839
- let forceKillHandle = null;
840
- if (maxRuntimeSeconds !== null && maxRuntimeSeconds > 0) {
841
- timeoutHandle = setTimeout(() => {
842
- timedOut = true;
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
- child.stdout.on("data", (chunk) => {
850
- stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
851
- });
852
- child.stderr.on("data", (chunk) => {
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
- child.on("error", reject);
856
- child.on("close", (exitCode, signal) => {
857
- if (timeoutHandle !== null) {
858
- clearTimeout(timeoutHandle);
859
- }
860
- if (forceKillHandle !== null) {
861
- clearTimeout(forceKillHandle);
862
- }
863
- resolve({
864
- exitCode: exitCode ?? (timedOut ? 0 : 1),
865
- signal: signal ?? null,
866
- stdout,
867
- stderr,
868
- timedOut,
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
- await fs.writeFile(stdoutPath, output.stdout, "utf8");
873
- await fs.writeFile(stderrPath, output.stderr, "utf8");
874
- if (output.timedOut) {
875
- await fs.writeFile(terminationPath, `${JSON.stringify({
876
- policy: "max_runtime_seconds",
877
- maxRuntimeSeconds,
878
- signal: output.signal,
879
- }, null, 2)}\n`, "utf8");
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: executionCheck.launchPreview.cwd,
1048
- command: executionCheck.launchPreview.command,
1049
- env: executionCheck.launchPreview.env,
1050
- workspaceMode: executionCheck.launchPreview.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
- const child = spawn(executionCheck.launchPreview.command?.[0] ?? "", executionCheck.launchPreview.command?.slice(1) ?? [], {
1057
- cwd: executionCheck.launchPreview.cwd ?? undefined,
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(executionCheck.launchPreview.env.map((entry) => [entry.name, entry.value])),
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: executionCheck.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: executionCheck.launchPreview.command,
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
- const payload = {
1347
- session: summarizeBridgeSession(session),
1348
- launch: {
1349
- cwd: session.launchPreview.cwd,
1350
- command: session.launchPreview.command,
1351
- env: session.launchPreview.env,
1352
- workspaceMode: session.launchPreview.workspaceMode,
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
- transcript: session.transcript,
1951
+ error: "Timed out waiting for the requested node property value.",
1355
1952
  };
1356
- await fs.mkdir(session.artifactDirectory, { recursive: true });
1357
- await fs.writeFile(session.transcriptPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
1358
- await session.workspace.cleanup();
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 {