@deeeed/metamask-harness 0.18.0 → 0.19.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 (79) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +4 -7
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/inject.mjs +1 -4
  5. package/adapters/extension/verify.sh +1 -1
  6. package/adapters/mobile/inject.sh +5 -4
  7. package/adapters/mobile/verify.sh +15 -19
  8. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  9. package/dist/adapters/harness-freshness.js +49 -0
  10. package/dist/cli.js +0 -4
  11. package/dist/command-contract.js +51 -40
  12. package/dist/command-journal.js +50 -12
  13. package/dist/commands/call.js +95 -20
  14. package/dist/commands/check.js +1 -1
  15. package/dist/commands/completion-candidates.js +7 -19
  16. package/dist/commands/fixtures.js +50 -1
  17. package/dist/commands/last.js +9 -1
  18. package/dist/commands/launch/extension.js +2 -2
  19. package/dist/commands/launch/mobile.js +2 -0
  20. package/dist/commands/list-executables.js +56 -20
  21. package/dist/commands/manifest.js +28 -12
  22. package/dist/commands/parse-args.js +55 -3
  23. package/dist/commands/provision.js +0 -1
  24. package/dist/commands/run-engine.js +514 -336
  25. package/dist/commands/run.js +25 -37
  26. package/dist/commands/shared.js +1 -1
  27. package/dist/heal-bounds.js +5 -0
  28. package/dist/mm-harness-cli.js +22 -30
  29. package/dist/run-diagnostics.js +1 -1
  30. package/dist/run-recording.js +1 -1
  31. package/dist/runner.js +24 -2
  32. package/docs/CONTRIBUTING.md +2 -3
  33. package/docs/QA.md +0 -1
  34. package/docs/RECIPES.md +52 -91
  35. package/library/README.md +5 -5
  36. package/library/actions/core/perps/_controller.mjs +33 -1
  37. package/library/actions/core/perps/assert_orders.mjs +6 -7
  38. package/library/actions/core/perps/assert_positions.mjs +6 -7
  39. package/library/actions/core/perps/close_orders.mjs +2 -0
  40. package/library/actions/core/perps/close_positions.mjs +2 -0
  41. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  42. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  43. package/library/actions/core/perps/place_order.mjs +7 -3
  44. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  45. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  46. package/library/actions/extension/perps/perps.mjs +43 -14
  47. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  48. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  49. package/library/actions/mobile/perps/perps.mjs +40 -12
  50. package/library/library.json +1 -1
  51. package/library/manifests/core.action-manifest.json +1170 -413
  52. package/library/manifests/extension.action-manifest.json +1495 -641
  53. package/library/manifests/mobile.action-manifest.json +1704 -769
  54. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  55. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  56. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  57. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  58. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  59. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  60. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  61. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  62. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  63. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  64. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  65. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  66. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  67. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  68. package/library/recipes/runner/action-validation.extension.recipe.json +312 -405
  69. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -409
  70. package/library/recipes/runner/smoke.core.recipe.json +18 -20
  71. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  72. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  73. package/library/recipes/wallet/smoke.extension.recipe.json +33 -35
  74. package/library/recipes/wallet/smoke.mobile.recipe.json +33 -35
  75. package/package.json +3 -3
  76. package/scripts/completions.sh +1 -4
  77. package/dist/adapters/extension/harness-freshness.js +0 -39
  78. package/dist/commands/flows.js +0 -91
  79. package/library/flows/perps.flows.json +0 -64
@@ -7,7 +7,8 @@ import {
7
7
  newHealState,
8
8
  parseHeal,
9
9
  recipeRunning,
10
- checkHealBounds
10
+ checkHealBounds,
11
+ conciseFailureForHuman
11
12
  } from "../heal-bounds.js";
12
13
  import { resolveActionManifest, validateManifest } from "../manifest.js";
13
14
  import { getAdapterSurface } from "../adapters/surface.js";
@@ -15,11 +16,9 @@ import { extensionProductConfigBlock } from "../adapters/extension/product-confi
15
16
  import {
16
17
  importRecipeHarness,
17
18
  importRecipeProtocol,
18
- recipePath,
19
19
  runnerDir
20
20
  } from "../paths.js";
21
21
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
22
- import { listRecipeFiles } from "../recipe-files.js";
23
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
24
23
  import {
25
24
  beginRunDiagnostics,
@@ -29,19 +28,12 @@ import { EXIT, spawnScriptStreaming } from "./shared.js";
29
28
  import {
30
29
  actionManifestPathOption,
31
30
  optionString,
31
+ optionStrings,
32
32
  isRecord,
33
33
  shellQuoteArg,
34
34
  targetPath,
35
35
  usageError
36
36
  } from "./parse-args.js";
37
- function requireRecipeTrustResolver(harness) {
38
- if (typeof harness.resolveRecipeTrustInput !== "function") {
39
- throw usageError(
40
- "Recipe trust enforcement requires a newer @farmslot/recipe-harness than the installed package. Next: mm-harness update"
41
- );
42
- }
43
- return harness.resolveRecipeTrustInput;
44
- }
45
37
  async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}, preflightedExecution) {
46
38
  const restoreRuntimeEnvironment = activateRecipeRuntimeEnvironment(
47
39
  adapter,
@@ -57,7 +49,12 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
57
49
  actionManifestPath,
58
50
  runtimeOptions
59
51
  );
60
- const { runner, runRequest, absoluteArtifactsDir, useFramedExtensionRecording } = execution;
52
+ const {
53
+ runner,
54
+ runRequest,
55
+ absoluteArtifactsDir,
56
+ useFramedExtensionRecording
57
+ } = execution;
61
58
  await runner.preflight(runRequest);
62
59
  await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
63
60
  const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
@@ -72,7 +69,10 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
72
69
  } finally {
73
70
  if (adapter === "mobile") {
74
71
  const { hideMobileHudOnTeardown } = await import("../adapters.js");
75
- await hideMobileHudOnTeardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
72
+ await hideMobileHudOnTeardown(
73
+ projectRoot,
74
+ recipeRunEnv(adapter, runtimeOptions)
75
+ );
76
76
  }
77
77
  }
78
78
  await stopRecipeRecording(recording, result);
@@ -109,7 +109,7 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
109
109
  const absoluteArtifactsDir = path.resolve(artifactsDir);
110
110
  const absoluteRecipePath = typeof recipe === "string" ? path.resolve(recipe) : void 0;
111
111
  const recordVideo = runtimeOptions.recordVideo ?? false;
112
- const resolveRecipeTrustInput = requireRecipeTrustResolver(await importRecipeHarness());
112
+ const { resolveRecipeTrustInput } = await importRecipeHarness();
113
113
  const trust = resolveRecipeTrustInput({
114
114
  sourceTrust: runtimeOptions.source?.trust,
115
115
  sourceKind: runtimeOptions.source?.kind,
@@ -130,6 +130,7 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
130
130
  const { createMetaMaskRunner } = await import("../runner.js");
131
131
  const runner = await createMetaMaskRunner(adapter, manifest, {
132
132
  quietStdout: runtimeOptions.stdoutIsMachineContract === true,
133
+ suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
133
134
  autoHud: trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
134
135
  onActionEvent: runtimeOptions.onActionEvent,
135
136
  actionSources,
@@ -155,7 +156,9 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
155
156
  },
156
157
  recordVideo: useFramedExtensionRecording ? false : recordVideo,
157
158
  ...trust,
158
- ...librarySources ? { librarySources } : {}
159
+ ...librarySources ? { librarySources } : {},
160
+ adapter,
161
+ ...runtimeOptions.params ? { params: runtimeOptions.params } : {}
159
162
  }
160
163
  };
161
164
  }
@@ -166,7 +169,8 @@ function recipeProcessEnvironment() {
166
169
  }
167
170
  function executionLibrarySources(sources, invocationTrust) {
168
171
  return sources?.map((source) => {
169
- if (source.name === "metamask" || source.provenance?.trust === "trusted") return source;
172
+ if (source.name === "metamask" || source.provenance?.trust === "trusted")
173
+ return source;
170
174
  return {
171
175
  ...source,
172
176
  provenance: {
@@ -201,14 +205,20 @@ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions)
201
205
  restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
202
206
  restoreEnv("WATCHER_PORT", previousWatcherPort);
203
207
  restoreEnv("METRO_PORT", previousMetroPort);
204
- restoreEnv("METAMASK_RECIPE_EXTENSION_AUTOLAUNCH", previousExtensionAutolaunch);
208
+ restoreEnv(
209
+ "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH",
210
+ previousExtensionAutolaunch
211
+ );
205
212
  };
206
213
  }
207
214
  function actionSourceMap(sources) {
208
215
  const roots = {};
209
216
  for (const [action, source] of sources) {
210
217
  if (source.tier === "official") continue;
211
- roots[action] = source.implementationRoot ?? (source.tier === "task" ? path.join(path.dirname(source.manifestPath), "actions") : path.join(path.dirname(path.dirname(source.manifestPath)), "actions"));
218
+ roots[action] = source.implementationRoot ?? (source.tier === "task" ? path.join(path.dirname(source.manifestPath), "actions") : path.join(
219
+ path.dirname(path.dirname(source.manifestPath)),
220
+ "actions"
221
+ ));
212
222
  }
213
223
  return JSON.stringify(roots);
214
224
  }
@@ -233,7 +243,8 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
233
243
  };
234
244
  }
235
245
  async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
236
- if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true) return;
246
+ if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true)
247
+ return;
237
248
  const { prepareExtensionRuntime } = await import("../adapters/extension/runtime.js");
238
249
  await prepareExtensionRuntime({
239
250
  projectRoot,
@@ -248,21 +259,74 @@ function restoreEnv(key, value) {
248
259
  if (value === void 0) delete process.env[key];
249
260
  else process.env[key] = value;
250
261
  }
251
- async function validateRecipeAdapterAware(recipe, manifest, librarySources) {
252
- const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
253
- let externalFlowIds;
262
+ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySources, rootRef, params) {
263
+ const protocol = await importRecipeProtocol();
264
+ let externalRecipeIds;
265
+ let libraryResolution;
266
+ const harness = await importRecipeHarness();
254
267
  if (librarySources && librarySources.length > 0) {
255
- const harness = await importRecipeHarness();
256
- const resolution = await harness.loadRecipeLibraries(librarySources);
257
- externalFlowIds = new Set(resolution.flows.keys());
268
+ libraryResolution = await harness.loadRecipeLibraries(librarySources, {
269
+ adapter
270
+ });
271
+ externalRecipeIds = new Set(libraryResolution.recipes.keys());
272
+ }
273
+ const validationOptions = externalRecipeIds !== void 0 ? { externalRecipeIds } : void 0;
274
+ const withManifest = protocol.validateRecipeWithManifest(
275
+ recipe,
276
+ manifest,
277
+ validationOptions
278
+ );
279
+ const findings = [...withManifest.findings];
280
+ if (withManifest.status === "valid" && isRecord(recipe) && libraryResolution) {
281
+ try {
282
+ const digest = protocol.digestRecipeDocument(recipe);
283
+ const dependencies = harness.resolveRecipeDependencies({
284
+ rootRef: rootRef ?? `$root:${digest}`,
285
+ root: recipe,
286
+ rootSource: {
287
+ kind: "recipe-file",
288
+ trust: "unknown",
289
+ name: rootRef ?? "recipe file",
290
+ digest
291
+ },
292
+ recipes: libraryResolution.recipes
293
+ });
294
+ if (params) {
295
+ harness.validateRecipeDependencyParams({
296
+ root: recipe,
297
+ params,
298
+ recipes: dependencies.recipes
299
+ });
300
+ }
301
+ for (const dependency of dependencies.recipes.values()) {
302
+ findings.push(
303
+ ...protocol.validateRecipeWithManifest(
304
+ dependency.document,
305
+ manifest,
306
+ validationOptions
307
+ ).findings
308
+ );
309
+ }
310
+ } catch (error) {
311
+ const resolutionError = error instanceof harness.RecipeResolutionError ? error : void 0;
312
+ if (!resolutionError) throw error;
313
+ findings.push({
314
+ severity: "error",
315
+ code: resolutionError.code,
316
+ path: "workflow",
317
+ message: `${resolutionError.message} Next: ${resolutionError.userAction}`
318
+ });
319
+ }
258
320
  }
259
- const validationOptions = externalFlowIds !== void 0 ? { externalFlowIds } : void 0;
260
- const schema = validateRecipeDocument(recipe, validationOptions);
261
- const withManifest = validateRecipeWithManifest(recipe, manifest, validationOptions);
262
- const findings = [...schema.findings, ...withManifest.findings];
263
- const errors = findings.filter((finding) => finding.severity === "error").length;
321
+ const errors = findings.filter(
322
+ (finding) => finding.severity === "error"
323
+ ).length;
264
324
  const warnings = findings.length - errors;
265
- return { status: errors > 0 ? "invalid" : "valid", findings, summary: { errors, warnings } };
325
+ return {
326
+ status: errors > 0 ? "invalid" : "valid",
327
+ findings,
328
+ summary: { errors, warnings }
329
+ };
266
330
  }
267
331
  function isRecipeFile(p) {
268
332
  try {
@@ -271,107 +335,43 @@ function isRecipeFile(p) {
271
335
  return false;
272
336
  }
273
337
  }
274
- function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
338
+ async function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
275
339
  const direct = path.resolve(recipeArg);
276
340
  if (isRecipeFile(direct)) return { recipeFile: direct };
341
+ const sources = effectiveLibrarySources(librarySources);
342
+ const harness = await importRecipeHarness();
343
+ const resolution = await harness.loadRecipeLibraries(sources, { adapter });
277
344
  if (!recipeArg.includes("/") && !recipeArg.includes(path.sep)) {
278
- const candidates = recipeNameCandidates(recipeArg, adapter);
279
- if (librarySources && librarySources.length > 0) {
280
- for (const source of librarySources) {
281
- for (const candidate of candidates) {
282
- const file = path.join(source.root, "recipes", candidate);
283
- if (isRecipeFile(file)) return { recipeFile: file };
284
- }
285
- const listedFile = recipeFileByPublicName(path.join(source.root, "recipes"), recipeArg, adapter);
286
- if (listedFile) return { recipeFile: listedFile };
287
- }
288
- } else {
289
- for (const candidate of candidates) {
290
- const file = recipePath(candidate);
291
- if (isRecipeFile(file)) return { recipeFile: file };
292
- }
293
- const listedFile = recipeFileByPublicName(recipePath(""), recipeArg, adapter);
294
- if (listedFile) return { recipeFile: listedFile };
295
- }
345
+ const selected = resolution.recipes.get(recipeArg);
346
+ if (selected) return { recipeFile: selected.path, ref: selected.ref };
296
347
  }
297
- const packagedNames = libraryRecipeNames(adapter);
298
- const nonCanonical = librarySources?.filter((s) => s.name !== "metamask") ?? [];
299
- const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${librarySources.map((s) => s.name ?? path.basename(s.root)).join(", ")}].` : `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched.`;
300
- const suffix = packagedNames.length > 0 ? ` Library recipes for ${adapter}: ${packagedNames.join(", ")} (mm-harness run <name>).` : ` The packaged library has no recipes for ${adapter}.`;
348
+ const available = [...resolution.recipes.keys()].sort();
349
+ const nonCanonical = sources.filter((source) => source.name !== "metamask");
350
+ const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${sources.map((source) => source.name ?? path.basename(source.root)).join(", ")}].` : `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched.`;
351
+ const suffix = available.length > 0 ? ` Library recipes for ${adapter}: ${available.join(", ")} (mm-harness run <name>).` : ` The packaged library has no recipes for ${adapter}.`;
301
352
  return { notFound: notFoundCore + suffix };
302
353
  }
303
- function readRecipeMetadata(file) {
304
- try {
305
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
306
- return {
307
- ...typeof parsed.title === "string" && parsed.title.trim() ? { title: parsed.title.trim() } : {},
308
- ...typeof parsed.description === "string" && parsed.description.trim() ? { description: parsed.description.trim() } : {}
309
- };
310
- } catch {
311
- return {};
312
- }
313
- }
314
- function recipeNameFromFile(file, adapter) {
315
- if (!file.endsWith(".recipe.json")) return void 0;
316
- let name = file.slice(0, -".recipe.json".length).replace(/[\\/]/gu, ".");
317
- const scope = name.split(".").pop() ?? "";
318
- if (["mobile", "extension", "core"].includes(scope) && scope !== adapter) return void 0;
319
- const scoped = scope === adapter;
320
- if (scoped) name = name.slice(0, -(adapter.length + 1));
321
- return { name, scoped };
354
+ function effectiveLibrarySources(librarySources) {
355
+ return librarySources && librarySources.length > 0 ? librarySources : [{ name: "metamask", root: path.resolve(runnerDir, "library") }];
322
356
  }
323
- function recipeFileByPublicName(recipesRoot, recipeName, adapter) {
324
- let match;
325
- let files;
326
- try {
327
- files = listRecipeFiles(recipesRoot);
328
- } catch {
329
- return void 0;
330
- }
331
- for (const file of files) {
332
- const candidate = recipeNameFromFile(file, adapter);
333
- if (!candidate || candidate.name !== recipeName) continue;
334
- if (!match || candidate.scoped && !match.scoped) match = { file, scoped: candidate.scoped };
335
- }
336
- return match ? path.join(recipesRoot, match.file) : void 0;
337
- }
338
- function listRunnableRecipes(adapter, librarySources) {
339
- const sources = librarySources && librarySources.length > 0 ? librarySources : [{ name: "metamask", root: recipePath("") }];
340
- const resolved = /* @__PURE__ */ new Map();
341
- for (const source of sources) {
342
- const recipesRoot = librarySources && librarySources.length > 0 ? path.join(source.root, "recipes") : source.root;
343
- let files;
344
- try {
345
- files = listRecipeFiles(recipesRoot);
346
- } catch {
347
- continue;
348
- }
349
- const sourceName = source.name ?? path.basename(source.root);
350
- const withinSource = /* @__PURE__ */ new Map();
351
- for (const file of files) {
352
- const candidate = recipeNameFromFile(file, adapter);
353
- if (!candidate) continue;
354
- const existing = withinSource.get(candidate.name);
355
- if (!existing || candidate.scoped && !existing.scoped) {
356
- withinSource.set(candidate.name, { file, scoped: candidate.scoped });
357
- }
358
- }
359
- for (const [name, candidate] of withinSource) {
360
- const winner = resolved.get(name);
361
- if (winner) winner.shadows.push(sourceName);
362
- else resolved.set(name, {
363
- name,
364
- source: sourceName,
365
- file: path.join("recipes", candidate.file),
366
- shadows: [],
367
- ...readRecipeMetadata(path.join(recipesRoot, candidate.file))
368
- });
357
+ async function listRunnableRecipes(adapter, librarySources) {
358
+ const harness = await importRecipeHarness();
359
+ const resolution = await harness.loadRecipeLibraries(
360
+ effectiveLibrarySources(librarySources),
361
+ {
362
+ adapter
369
363
  }
370
- }
371
- return [...resolved.values()].sort((left, right) => left.name.localeCompare(right.name));
364
+ );
365
+ return [...resolution.recipes.values()].map(
366
+ (recipe) => recipeSummary(recipe.ref, adapter, recipe.document, recipe)
367
+ ).sort((left, right) => left.name.localeCompare(right.name));
372
368
  }
373
- function describeRunnableRecipe(recipeArg, adapter, librarySources) {
374
- const resolved = resolveRunRecipeArg(recipeArg, adapter, librarySources);
369
+ async function describeRunnableRecipe(recipeArg, adapter, librarySources) {
370
+ const resolved = await resolveRunRecipeArg(
371
+ recipeArg,
372
+ adapter,
373
+ librarySources
374
+ );
375
375
  if ("notFound" in resolved) return resolved;
376
376
  let document;
377
377
  try {
@@ -382,114 +382,122 @@ function describeRunnableRecipe(recipeArg, adapter, librarySources) {
382
382
  };
383
383
  }
384
384
  if (!isRecord(document)) {
385
- return { unreadable: `recipe ${resolved.recipeFile} must contain a JSON object.` };
385
+ return {
386
+ unreadable: `recipe ${resolved.recipeFile} must contain a JSON object.`
387
+ };
386
388
  }
387
- const listed = listRunnableRecipes(adapter, librarySources).find((candidate) => candidate.name === recipeArg);
388
- const composition = recipeComposition(document);
389
+ const harness = await importRecipeHarness();
390
+ const libraryResolution = await harness.loadRecipeLibraries(
391
+ effectiveLibrarySources(librarySources),
392
+ { adapter }
393
+ );
394
+ const selected = resolved.ref ? libraryResolution.recipes.get(resolved.ref) : void 0;
395
+ const composition = recipeComposition(document, libraryResolution.recipes);
396
+ const summary = recipeSummary(
397
+ resolved.ref ?? recipeArg,
398
+ adapter,
399
+ document,
400
+ selected
401
+ );
389
402
  return {
390
403
  recipe: {
391
- name: listed?.name ?? recipeArg,
392
- source: listed?.source ?? "file",
393
- file: listed?.file ?? resolved.recipeFile,
394
- shadows: listed?.shadows ?? [],
395
- ...typeof document.title === "string" && document.title.trim() ? { title: document.title.trim() } : {},
396
- ...typeof document.description === "string" && document.description.trim() ? { description: document.description.trim() } : {},
397
- adapter,
404
+ ...summary,
405
+ source: selected?.source ?? "file",
406
+ file: selected?.file ?? resolved.recipeFile,
398
407
  path: resolved.recipeFile,
399
- ...Object.prototype.hasOwnProperty.call(document, "schema_version") ? { schemaVersion: document.schema_version } : {},
400
- inputs: isRecord(document.inputs) ? redactRecipeInputs(document.inputs) : {},
401
- inputOverridesSupported: false,
408
+ ...typeof document.$schema === "string" ? { $schema: document.$schema } : {},
402
409
  actions: composition.actions,
403
- actionsComplete: composition.actionsComplete,
404
- flows: composition.flows
410
+ nestedRecipes: composition.nestedRecipes,
411
+ unresolvedRecipes: composition.unresolvedRecipes
405
412
  }
406
413
  };
407
414
  }
408
- function recipeComposition(recipe) {
415
+ function recipeSummary(ref, adapter, document, selected) {
416
+ return {
417
+ name: ref,
418
+ source: selected?.source ?? "file",
419
+ file: selected?.file ?? "",
420
+ shadows: selected?.shadows ?? [],
421
+ adapter,
422
+ variant: selected?.adapter ?? null,
423
+ parameters: recipeParameters(document),
424
+ ...typeof document.title === "string" && document.title.trim() ? { title: document.title.trim() } : {},
425
+ ...typeof document.description === "string" && document.description.trim() ? { description: document.description.trim() } : {}
426
+ };
427
+ }
428
+ function recipeParameters(document) {
429
+ if (!isRecord(document.paramsSchema)) return [];
430
+ const schema = document.paramsSchema;
431
+ const required = new Set(
432
+ Array.isArray(schema.required) ? schema.required.filter(
433
+ (entry) => typeof entry === "string"
434
+ ) : []
435
+ );
436
+ if (!isRecord(schema.properties)) return [];
437
+ return Object.entries(schema.properties).map(([name, raw]) => {
438
+ const property = isRecord(raw) ? raw : {};
439
+ return {
440
+ name,
441
+ ...typeof property.type === "string" ? { type: property.type } : {},
442
+ required: required.has(name),
443
+ ...Object.hasOwn(property, "default") ? { default: property.default } : {},
444
+ ...typeof property.description === "string" ? { description: property.description } : {},
445
+ ...Array.isArray(property.enum) ? { enum: property.enum } : {}
446
+ };
447
+ });
448
+ }
449
+ function recipeComposition(recipe, recipes) {
409
450
  const actions = /* @__PURE__ */ new Set();
410
- const flows = /* @__PURE__ */ new Set();
451
+ const nestedRecipes = /* @__PURE__ */ new Set();
452
+ const unresolvedRecipes = /* @__PURE__ */ new Set();
453
+ const visitedRecipes = /* @__PURE__ */ new Set();
411
454
  const visitNode = (value) => {
412
455
  if (!isRecord(value) || typeof value.action !== "string") return;
413
- actions.add(value.action);
414
- if (value.action === "call" && typeof value.ref === "string") flows.add(value.ref);
456
+ if (value.action !== "call") {
457
+ actions.add(value.action);
458
+ return;
459
+ }
460
+ if (typeof value.ref !== "string") return;
461
+ nestedRecipes.add(value.ref);
462
+ const dependency = recipes.get(value.ref);
463
+ if (!dependency) {
464
+ unresolvedRecipes.add(value.ref);
465
+ return;
466
+ }
467
+ if (visitedRecipes.has(value.ref)) return;
468
+ visitedRecipes.add(value.ref);
469
+ visitRecipe(dependency.document);
415
470
  };
416
471
  const visitWorkflow = (value) => {
417
472
  if (!isRecord(value)) return;
418
- for (const phase of ["setup", "teardown"]) {
419
- if (Array.isArray(value[phase])) value[phase].forEach(visitNode);
420
- }
421
473
  if (isRecord(value.nodes)) Object.values(value.nodes).forEach(visitNode);
422
474
  };
423
- visitNode(recipe.startState);
424
- if (isRecord(recipe.validate)) visitWorkflow(recipe.validate.workflow);
425
- const inlineFlows = isRecord(recipe.flows) ? recipe.flows : {};
426
- const visitedFlows = /* @__PURE__ */ new Set();
427
- let actionsComplete = true;
428
- for (const ref of flows) {
429
- if (visitedFlows.has(ref)) continue;
430
- visitedFlows.add(ref);
431
- const flow = inlineFlows[ref];
432
- if (flow !== void 0) {
433
- visitWorkflow(isRecord(flow) && isRecord(flow.workflow) ? flow.workflow : flow);
434
- } else {
435
- actionsComplete = false;
436
- }
437
- }
475
+ const visitRecipe = (document) => {
476
+ visitWorkflow(document.workflow);
477
+ };
478
+ visitRecipe(recipe);
438
479
  return {
439
480
  actions: [...actions].sort(),
440
- actionsComplete,
441
- flows: [...flows].sort()
481
+ nestedRecipes: [...nestedRecipes].sort(),
482
+ unresolvedRecipes: [...unresolvedRecipes].sort()
442
483
  };
443
484
  }
444
- function redactRecipeInputs(inputs) {
445
- return Object.fromEntries(
446
- Object.entries(inputs).map(([key, value]) => [
447
- key,
448
- /(?:password|passphrase|secret|token|credential|mnemonic|seed|srp|vault|api[_-]?key|private[_-]?key)/iu.test(key) ? "<redacted>" : isRecord(value) ? redactRecipeInputs(value) : Array.isArray(value) ? value.map((entry) => isRecord(entry) ? redactRecipeInputs(entry) : entry) : value
449
- ])
485
+ async function validateRunRecipeStatic(recipeArg, adapter, options, params = {}) {
486
+ let librarySources = await resolveMetaMaskLibrarySources(
487
+ optionStrings(options, "library")
488
+ );
489
+ const resolved = await resolveRunRecipeArg(
490
+ recipeArg,
491
+ adapter,
492
+ librarySources
450
493
  );
451
- }
452
- function libraryRecipeNames(adapter) {
453
- let entries;
454
- try {
455
- entries = listRecipeFiles(recipePath(""));
456
- } catch {
457
- return [];
458
- }
459
- const names = entries.map((file) => recipeNameFromFile(file, adapter)?.name).filter((name) => Boolean(name));
460
- return [...new Set(names)].sort();
461
- }
462
- function recipeNameCandidates(recipeArg, adapter) {
463
- return singleRecipeNameCandidates(recipeArg, adapter);
464
- }
465
- function singleRecipeNameCandidates(name, adapter) {
466
- if (name.endsWith(".recipe.json")) {
467
- return uniqueStrings([domainPathForRecipeFile(name), name]);
468
- }
469
- const bases = uniqueStrings([domainPathForRecipeName(name), name]);
470
- const candidates = [];
471
- for (const base of bases) {
472
- candidates.push(`${base}.${adapter}.recipe.json`, `${base}.recipe.json`, base);
473
- }
474
- return candidates;
475
- }
476
- function domainPathForRecipeFile(name) {
477
- const suffix = ".recipe.json";
478
- const base = name.slice(0, -suffix.length);
479
- return `${domainPathForRecipeName(base)}${suffix}`;
480
- }
481
- function domainPathForRecipeName(name) {
482
- if (name.includes("/") || name.includes(path.sep)) return name;
483
- const dot = name.indexOf(".");
484
- return dot > 0 ? `${name.slice(0, dot)}/${name.slice(dot + 1)}` : name;
485
- }
486
- function uniqueStrings(values) {
487
- return [...new Set(values)];
488
- }
489
- async function validateRunRecipeStatic(recipeArg, adapter, options) {
490
- const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
491
- const resolved = resolveRunRecipeArg(recipeArg, adapter, librarySources);
492
494
  const recipeFile = "recipeFile" in resolved ? resolved.recipeFile : path.resolve(recipeArg);
495
+ if ("recipeFile" in resolved && !resolved.ref) {
496
+ librarySources = await resolveMetaMaskLibrarySources(
497
+ optionStrings(options, "library"),
498
+ recipeFile
499
+ );
500
+ }
493
501
  const empty = {
494
502
  recipe: void 0,
495
503
  recipeFile,
@@ -497,6 +505,7 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
497
505
  errorCount: 0,
498
506
  manifestOk: false,
499
507
  schemaValid: false,
508
+ effectiveParams: params,
500
509
  ...librarySources ? { librarySources } : {}
501
510
  };
502
511
  if ("notFound" in resolved) {
@@ -538,30 +547,47 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
538
547
  message: error instanceof Error ? error.message : String(error)
539
548
  });
540
549
  }
541
- const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
542
- findings.push(...validation.findings);
550
+ let effectiveParams = params;
543
551
  if (isRecord(recipe)) {
544
- const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
545
- const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
546
- if (workflow?.startState != null) {
547
- findings.push({
548
- severity: "error",
549
- code: "recipe.start-state-placement",
550
- path: "validate.workflow.startState",
551
- message: "startState must be declared at the recipe root, not validate.workflow.startState"
552
- });
553
- }
554
- const startState = isRecord(recipe.startState) ? recipe.startState : void 0;
555
- if (adapter === "extension" && startState?.action === "metamask.perps.start_state" && startState.page === false) {
556
- findings.push({
557
- severity: "error",
558
- code: "recipe.extension-perps-start-page",
559
- path: "startState.page",
560
- message: "Extension metamask.perps.start_state must enter Perps; omit page or provide a string target instead of false"
561
- });
552
+ const protocol = await importRecipeProtocol();
553
+ effectiveParams = protocol.applyRecipeParamDefaults(
554
+ params,
555
+ recipe.paramsSchema
556
+ );
557
+ findings.push(
558
+ ...protocol.validateRecipeParams(effectiveParams, recipe.paramsSchema).findings
559
+ );
560
+ const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
561
+ const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
562
+ if (adapter === "extension" && nodes) {
563
+ for (const [nodeId, node] of Object.entries(nodes)) {
564
+ if (isRecord(node) && node.action === "metamask.perps.start_state" && node.page === false) {
565
+ findings.push({
566
+ severity: "error",
567
+ code: "recipe.extension-perps-start-page",
568
+ path: `workflow.nodes.${nodeId}.page`,
569
+ message: "Extension metamask.perps.start_state must enter Perps; omit page or provide a string target instead of false"
570
+ });
571
+ }
572
+ }
562
573
  }
563
574
  }
564
- const errorCount = findings.filter((finding) => finding.severity === "error").length;
575
+ const validation = manifestOk ? await validateRecipeAdapterAware(
576
+ adapter,
577
+ recipe,
578
+ manifest,
579
+ librarySources,
580
+ resolved.ref,
581
+ effectiveParams
582
+ ) : {
583
+ status: "invalid",
584
+ findings: [],
585
+ summary: { errors: 1, warnings: 0 }
586
+ };
587
+ findings.push(...validation.findings);
588
+ const errorCount = findings.filter(
589
+ (finding) => finding.severity === "error"
590
+ ).length;
565
591
  return {
566
592
  recipe,
567
593
  recipeFile,
@@ -569,13 +595,16 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
569
595
  errorCount,
570
596
  manifestOk,
571
597
  schemaValid: validation.status === "valid" && errorCount === 0,
598
+ effectiveParams,
572
599
  ...librarySources ? { librarySources } : {}
573
600
  };
574
601
  }
575
602
  async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
576
603
  if (recipeArg.includes("/") || recipeArg.includes(path.sep)) return "";
577
604
  try {
578
- const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
605
+ const librarySources = await resolveMetaMaskLibrarySources(
606
+ optionStrings(options, "library")
607
+ );
579
608
  const { manifest } = await resolveActionManifest(
580
609
  adapter,
581
610
  optionString(options, "actionManifest"),
@@ -588,30 +617,31 @@ async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
588
617
  const device = optionString(options, "device");
589
618
  const actionManifest = optionString(options, "actionManifest");
590
619
  const parts = ["mm-harness", "call", matches[0]];
591
- parts.push("--adapter", adapter, "--target", shellQuoteArg(targetPath(options)));
620
+ parts.push(
621
+ "--adapter",
622
+ adapter,
623
+ "--target",
624
+ shellQuoteArg(targetPath(options))
625
+ );
592
626
  if (device) parts.push("--device", shellQuoteArg(device));
593
- if (actionManifest) parts.push("--action-manifest", shellQuoteArg(actionManifest));
627
+ if (actionManifest)
628
+ parts.push("--action-manifest", shellQuoteArg(actionManifest));
594
629
  return ` This is an action, not a recipe. Use: ${parts.join(" ")}.`;
595
630
  } catch {
596
631
  return "";
597
632
  }
598
633
  }
599
- async function resolveMetaMaskLibrarySources(libraryEntry) {
634
+ async function resolveMetaMaskLibrarySources(libraryEntry, recipePath) {
600
635
  const harness = await importRecipeHarness();
601
- if (typeof harness.resolveRecipeLibrarySources !== "function") {
602
- if (libraryEntry) {
603
- throw usageError(
604
- "--library requires a current @farmslot/recipe-harness installation that exports recipe-library support. Next: mm-harness update"
605
- );
606
- }
607
- return void 0;
608
- }
609
636
  const cliEntries = typeof libraryEntry === "string" ? [libraryEntry] : libraryEntry ? [...libraryEntry] : [];
610
- const sources = await harness.resolveRecipeLibrarySources(
611
- cliEntries.length > 0 ? { cliEntries } : void 0
612
- );
637
+ const sources = await harness.resolveRecipeLibrarySources({
638
+ ...cliEntries.length > 0 ? { cliEntries } : {},
639
+ ...recipePath ? { recipePath } : {}
640
+ });
613
641
  const canonicalRoot = path.resolve(runnerDir, "library");
614
- const configuredCanonical = sources.find((source) => source.name === "metamask");
642
+ const configuredCanonical = sources.find(
643
+ (source) => source.name === "metamask"
644
+ );
615
645
  if (configuredCanonical && path.resolve(configuredCanonical.root) !== canonicalRoot) {
616
646
  throw usageError(
617
647
  `Recipe library source metamask must resolve to ${canonicalRoot}; got ${path.resolve(configuredCanonical.root)}.`
@@ -631,39 +661,53 @@ async function resolveMetaMaskLibrarySources(libraryEntry) {
631
661
  }
632
662
  function synthesizeOneNodeRecipe(action, args) {
633
663
  return {
634
- schema_version: 1,
664
+ $schema: "https://farmslot.io/schemas/recipe-v1.schema.json",
635
665
  title: `mm-harness call ${action}`,
636
666
  description: `Ad-hoc single-action execution of ${action} via the real engine path (mm-harness call).`,
637
- validate: {
638
- workflow: {
639
- entry: "call",
640
- nodes: {
641
- call: {
642
- ...args,
643
- action,
644
- next: "done",
645
- intent: typeof args.intent === "string" ? args.intent : `Call ${action} in isolation`
646
- },
647
- done: { action: "end", status: "pass" }
648
- }
667
+ workflow: {
668
+ entry: "call",
669
+ nodes: {
670
+ call: {
671
+ ...args,
672
+ action,
673
+ next: "done",
674
+ intent: typeof args.intent === "string" ? args.intent : `Complete the requested ${action} operation`
675
+ },
676
+ done: { action: "end", status: "pass" }
649
677
  }
650
678
  }
651
679
  };
652
680
  }
653
681
  async function runOneNode(adapter, action, args, target, actionManifest) {
654
682
  const librarySources = await resolveMetaMaskLibrarySources(void 0);
655
- const { manifest } = await resolveActionManifest(adapter, actionManifest, librarySources);
683
+ const { manifest } = await resolveActionManifest(
684
+ adapter,
685
+ actionManifest,
686
+ librarySources
687
+ );
656
688
  const recipe = synthesizeOneNodeRecipe(action, args);
657
- const validation = await validateRecipeAdapterAware(recipe, manifest);
689
+ const validation = await validateRecipeAdapterAware(
690
+ adapter,
691
+ recipe,
692
+ manifest,
693
+ librarySources
694
+ );
658
695
  if (validation.status === "invalid") return { status: "fail" };
659
696
  const scratch = await mkdtemp(path.join(os.tmpdir(), "mm-harness-fixtures-"));
660
697
  const recipeFile = path.join(scratch, "set.recipe.json");
661
698
  fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
662
699
  `);
663
- const result = await runRecipe(adapter, recipeFile, path.join(scratch, "artifacts"), target, actionManifest, {
664
- librarySources,
665
- stdoutIsMachineContract: true
666
- });
700
+ const result = await runRecipe(
701
+ adapter,
702
+ recipeFile,
703
+ path.join(scratch, "artifacts"),
704
+ target,
705
+ actionManifest,
706
+ {
707
+ librarySources,
708
+ stdoutIsMachineContract: true
709
+ }
710
+ );
667
711
  return { status: result.status === "pass" ? "pass" : "fail" };
668
712
  }
669
713
  async function prepareHeal(adapter, target, options, json, prepareOptions = {}) {
@@ -671,7 +715,18 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
671
715
  const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
672
716
  const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuoteArg(target)} --json; retry after the active recipe finishes`;
673
717
  if (json) {
674
- console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg, userAction } }, null, 2));
718
+ console.log(
719
+ JSON.stringify(
720
+ {
721
+ schemaVersion: 1,
722
+ status: "fail",
723
+ recoverable: false,
724
+ error: { code: "RECIPE_RUNNING", message: msg, userAction }
725
+ },
726
+ null,
727
+ 2
728
+ )
729
+ );
675
730
  } else {
676
731
  console.error(`\u2717 mm-harness: ${msg}
677
732
  Next: ${userAction}`);
@@ -688,20 +743,38 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
688
743
  }
689
744
  const state = newHealState();
690
745
  prepareOptions.onPhase?.("install");
691
- const ensured = await ensureOverlay(adapter, target, heal, state, json);
746
+ const ensured = await ensureOverlay(
747
+ adapter,
748
+ target,
749
+ heal,
750
+ state,
751
+ json
752
+ );
692
753
  if (!ensured.ok) {
693
754
  const userAction = `mm-harness install --adapter ${adapter} --target ${shellQuoteArg(target)}`;
694
755
  if (json) {
695
756
  console.log(
696
757
  JSON.stringify(
697
- { schemaVersion: 1, status: "fail", recoverable: false, mutations: state.mutations, error: { code: "OVERLAY_INSTALL_FAILED", message: ensured.error, userAction } },
758
+ {
759
+ schemaVersion: 1,
760
+ status: "fail",
761
+ recoverable: false,
762
+ mutations: state.mutations,
763
+ error: {
764
+ code: "OVERLAY_INSTALL_FAILED",
765
+ message: ensured.error,
766
+ userAction
767
+ }
768
+ },
698
769
  null,
699
770
  2
700
771
  )
701
772
  );
702
773
  } else {
703
- console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}
704
- Next: ${userAction}`);
774
+ console.error(
775
+ `\u2717 overlay auto-ensure failed: ${ensured.error}
776
+ Next: ${userAction}`
777
+ );
705
778
  }
706
779
  return EXIT.infra;
707
780
  }
@@ -718,9 +791,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
718
791
  }
719
792
  if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
720
793
  try {
721
- const current = await ensureExtensionProofRuntime(target, heal, state, json, {
722
- onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
723
- });
794
+ const current = await ensureExtensionProofRuntime(
795
+ target,
796
+ heal,
797
+ state,
798
+ json,
799
+ {
800
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
801
+ }
802
+ );
724
803
  if (current !== null) return current;
725
804
  } finally {
726
805
  restoreEnv("CDP_PORT", previousCdpPort);
@@ -729,9 +808,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
729
808
  }
730
809
  }
731
810
  if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
732
- const current = await ensureMobileProofRuntime(target, heal, state, json, {
733
- onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
734
- });
811
+ const current = await ensureMobileProofRuntime(
812
+ target,
813
+ heal,
814
+ state,
815
+ json,
816
+ {
817
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
818
+ }
819
+ );
735
820
  if (current !== null) return current;
736
821
  }
737
822
  return { state, heal };
@@ -743,12 +828,18 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
743
828
  const initial = check();
744
829
  if (initial.status === "unavailable") {
745
830
  if (json) {
746
- console.log(JSON.stringify({
747
- schemaVersion: 1,
748
- status: "fail",
749
- recoverable: false,
750
- error: initial.error
751
- }, null, 2));
831
+ console.log(
832
+ JSON.stringify(
833
+ {
834
+ schemaVersion: 1,
835
+ status: "fail",
836
+ recoverable: false,
837
+ error: initial.error
838
+ },
839
+ null,
840
+ 2
841
+ )
842
+ );
752
843
  } else {
753
844
  console.error(`\u2717 ${initial.error.message}`);
754
845
  console.error(` Next: ${initial.error.userAction}`);
@@ -761,13 +852,19 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
761
852
  if (heal === "off") {
762
853
  const message = `Mobile loaded source is not current (${initial.status}).`;
763
854
  if (json) {
764
- console.log(JSON.stringify({
765
- schemaVersion: 1,
766
- status: "fail",
767
- recoverable: true,
768
- mutations: state.mutations,
769
- error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
770
- }, null, 2));
855
+ console.log(
856
+ JSON.stringify(
857
+ {
858
+ schemaVersion: 1,
859
+ status: "fail",
860
+ recoverable: true,
861
+ mutations: state.mutations,
862
+ error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
863
+ },
864
+ null,
865
+ 2
866
+ )
867
+ );
771
868
  } else {
772
869
  console.error(`\u2717 ${message}`);
773
870
  console.error(` Next: ${userAction}`);
@@ -777,7 +874,10 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
777
874
  const recoveryCode = "mobile.source_reloaded";
778
875
  state.attemptedRecoveries.push(recoveryCode);
779
876
  deps.onRecovery?.(recoveryCode);
780
- if (!json) console.error(`\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`);
877
+ if (!json)
878
+ console.error(
879
+ `\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`
880
+ );
781
881
  const restart = deps.restart ?? (async () => {
782
882
  const result2 = await runOneNode(
783
883
  "mobile",
@@ -786,7 +886,10 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
786
886
  target,
787
887
  void 0
788
888
  );
789
- return { status: result2.status === "pass" ? 0 : EXIT.runtime, output: result2.status };
889
+ return {
890
+ status: result2.status === "pass" ? 0 : EXIT.runtime,
891
+ output: result2.status
892
+ };
790
893
  });
791
894
  const result = await restart();
792
895
  const platform = resolveRunMobilePlatform();
@@ -800,21 +903,38 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
800
903
  if (result.status !== 0 || bridge.status !== 0 || !recorded || verified.status !== "current") {
801
904
  const message = bridge.status !== 0 ? "Mobile source restart completed but the platform-matched bridge did not become ready." : "Mobile source restart did not produce a current loaded-source baseline.";
802
905
  if (json) {
803
- console.log(JSON.stringify({
804
- schemaVersion: 1,
805
- status: "fail",
806
- recoverable: false,
807
- attemptedRecoveries: state.attemptedRecoveries,
808
- error: { code: bridge.status !== 0 ? "MOBILE_BRIDGE_NOT_READY" : "MOBILE_SOURCE_RELOAD_FAILED", message, userAction, originalError: bridge.output || result.output }
809
- }, null, 2));
906
+ console.log(
907
+ JSON.stringify(
908
+ {
909
+ schemaVersion: 1,
910
+ status: "fail",
911
+ recoverable: false,
912
+ attemptedRecoveries: state.attemptedRecoveries,
913
+ error: {
914
+ code: bridge.status !== 0 ? "MOBILE_BRIDGE_NOT_READY" : "MOBILE_SOURCE_RELOAD_FAILED",
915
+ message,
916
+ userAction,
917
+ originalError: bridge.output || result.output
918
+ }
919
+ },
920
+ null,
921
+ 2
922
+ )
923
+ );
810
924
  } else {
811
925
  console.error(`\u2717 ${message}`);
812
- console.error(` Next: ${bridge.status !== 0 ? "inspect the bridge-status log and rerun mm-harness launch <platform>" : userAction}`);
926
+ console.error(
927
+ ` Next: ${bridge.status !== 0 ? "inspect the bridge-status log and rerun mm-harness launch <platform>" : userAction}`
928
+ );
813
929
  }
814
930
  return EXIT.infra;
815
931
  }
816
932
  state.recovered.push(recoveryCode);
817
- state.mutations.push({ type: "runtime", action: "restarted", reasonCode: `source-${initial.status}` });
933
+ state.mutations.push({
934
+ type: "runtime",
935
+ action: "restarted",
936
+ reasonCode: `source-${initial.status}`
937
+ });
818
938
  return null;
819
939
  }
820
940
  function resolveRunMobilePlatform() {
@@ -838,19 +958,34 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
838
958
  const message = `Extension proof runtime freshness could not be checked: ${initial.reasons[0] ?? initial.decision}`;
839
959
  const userAction2 = `mm-harness doctor --adapter extension --target ${JSON.stringify(path.resolve(target))}`;
840
960
  if (json) {
841
- console.log(JSON.stringify({
842
- schemaVersion: 1,
843
- status: "fail",
844
- recoverable: false,
845
- error: { code: "EXTENSION_RUNTIME_CHECK_FAILED", message, userAction: userAction2 }
846
- }, null, 2));
961
+ console.log(
962
+ JSON.stringify(
963
+ {
964
+ schemaVersion: 1,
965
+ status: "fail",
966
+ recoverable: false,
967
+ error: {
968
+ code: "EXTENSION_RUNTIME_CHECK_FAILED",
969
+ message,
970
+ userAction: userAction2
971
+ }
972
+ },
973
+ null,
974
+ 2
975
+ )
976
+ );
847
977
  } else {
848
978
  console.error(`\u2717 ${message}`);
849
979
  console.error(` Next: ${userAction2}`);
850
980
  }
851
981
  return EXIT.infra;
852
982
  }
853
- if (!["dist-stale", "dist-missing", "runtime-dist-stale", "runtime-dist-missing"].includes(initial.reasonCode)) {
983
+ if (![
984
+ "dist-stale",
985
+ "dist-missing",
986
+ "runtime-dist-stale",
987
+ "runtime-dist-missing"
988
+ ].includes(initial.reasonCode)) {
854
989
  return null;
855
990
  }
856
991
  const tier = initial.decision === "build" ? "build" : "quick";
@@ -860,13 +995,23 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
860
995
  if (!canHeal) {
861
996
  const message = `Extension proof runtime is not current (${initial.reasonCode}): ${initial.reasons[0] ?? initial.decision}`;
862
997
  if (json) {
863
- console.log(JSON.stringify({
864
- schemaVersion: 1,
865
- status: "fail",
866
- recoverable: true,
867
- mutations: state.mutations,
868
- error: { code: "EXTENSION_RUNTIME_NOT_CURRENT", message, userAction }
869
- }, null, 2));
998
+ console.log(
999
+ JSON.stringify(
1000
+ {
1001
+ schemaVersion: 1,
1002
+ status: "fail",
1003
+ recoverable: true,
1004
+ mutations: state.mutations,
1005
+ error: {
1006
+ code: "EXTENSION_RUNTIME_NOT_CURRENT",
1007
+ message,
1008
+ userAction
1009
+ }
1010
+ },
1011
+ null,
1012
+ 2
1013
+ )
1014
+ );
870
1015
  } else {
871
1016
  console.error(`\u2717 ${message}`);
872
1017
  console.error(` Next: ${userAction}`);
@@ -879,13 +1024,23 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
879
1024
  const message = productConfigBlock.message;
880
1025
  const userAction2 = productConfigBlock.userAction;
881
1026
  if (json) {
882
- console.log(JSON.stringify({
883
- schemaVersion: 1,
884
- status: "fail",
885
- recoverable: false,
886
- mutations: state.mutations,
887
- error: { code: "EXTENSION_PRODUCT_CONFIG_REQUIRED", message, userAction: userAction2 }
888
- }, null, 2));
1027
+ console.log(
1028
+ JSON.stringify(
1029
+ {
1030
+ schemaVersion: 1,
1031
+ status: "fail",
1032
+ recoverable: false,
1033
+ mutations: state.mutations,
1034
+ error: {
1035
+ code: "EXTENSION_PRODUCT_CONFIG_REQUIRED",
1036
+ message,
1037
+ userAction: userAction2
1038
+ }
1039
+ },
1040
+ null,
1041
+ 2
1042
+ )
1043
+ );
889
1044
  } else {
890
1045
  console.error(`\u2717 ${message}`);
891
1046
  console.error(` Next: ${userAction2}`);
@@ -894,7 +1049,10 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
894
1049
  }
895
1050
  state.attemptedRecoveries.push(recoveryCode);
896
1051
  deps.onRecovery?.(recoveryCode);
897
- if (!json) console.error(`\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`);
1052
+ if (!json)
1053
+ console.error(
1054
+ `\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`
1055
+ );
898
1056
  const launch = deps.launch ?? (async (requestedTier) => {
899
1057
  const { launchExtension } = await import("./launch/extension.js");
900
1058
  return launchExtension(target, requestedTier, false);
@@ -904,13 +1062,24 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
904
1062
  if (result.status !== 0 || verified.decision !== "ready") {
905
1063
  const message = `Extension runtime recovery did not produce a current healthy runtime (${verified.reasonCode}).`;
906
1064
  if (json) {
907
- console.log(JSON.stringify({
908
- schemaVersion: 1,
909
- status: "fail",
910
- recoverable: false,
911
- attemptedRecoveries: state.attemptedRecoveries,
912
- error: { code: "EXTENSION_RUNTIME_RECOVERY_FAILED", message, userAction, originalError: result.output }
913
- }, null, 2));
1065
+ console.log(
1066
+ JSON.stringify(
1067
+ {
1068
+ schemaVersion: 1,
1069
+ status: "fail",
1070
+ recoverable: false,
1071
+ attemptedRecoveries: state.attemptedRecoveries,
1072
+ error: {
1073
+ code: "EXTENSION_RUNTIME_RECOVERY_FAILED",
1074
+ message,
1075
+ userAction,
1076
+ originalError: result.output
1077
+ }
1078
+ },
1079
+ null,
1080
+ 2
1081
+ )
1082
+ );
914
1083
  } else {
915
1084
  console.error(`\u2717 ${message}`);
916
1085
  console.error(` Next: ${userAction}`);
@@ -918,7 +1087,11 @@ async function ensureExtensionProofRuntime(target, heal, state, json, deps = {})
918
1087
  return EXIT.infra;
919
1088
  }
920
1089
  state.recovered.push(recoveryCode);
921
- state.mutations.push({ type: "runtime", action: tier === "build" ? "rebuilt" : "reloaded", reasonCode: initial.reasonCode });
1090
+ state.mutations.push({
1091
+ type: "runtime",
1092
+ action: tier === "build" ? "rebuilt" : "reloaded",
1093
+ reasonCode: initial.reasonCode
1094
+ });
922
1095
  return null;
923
1096
  }
924
1097
  const RUN_RECOVERY_CODE = {
@@ -943,7 +1116,9 @@ function readRunFailureText(result) {
943
1116
  try {
944
1117
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
945
1118
  const entries = Array.isArray(trace.entries) ? trace.entries : [];
946
- return entries.filter((entry) => entry && entry.ok === false && typeof entry.error === "string").map((entry) => entry.error).join("\n").trim();
1119
+ return entries.filter(
1120
+ (entry) => entry && entry.ok === false && typeof entry.error === "string"
1121
+ ).map((entry) => entry.error).join("\n").trim();
947
1122
  } catch {
948
1123
  return "";
949
1124
  }
@@ -952,7 +1127,11 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
952
1127
  let result = await exec();
953
1128
  for (; ; ) {
954
1129
  if (result.status === "pass") return { result, violation: null };
955
- const violation = checkHealBounds(target, readRunFailureText(result), state);
1130
+ const violation = checkHealBounds(
1131
+ target,
1132
+ readRunFailureText(result),
1133
+ state
1134
+ );
956
1135
  if (violation !== null) return { result, violation };
957
1136
  if (heal === "off") return { result, violation: null };
958
1137
  const recoveryCode = RUN_RECOVERY_CODE[adapter];
@@ -996,8 +1175,9 @@ function emitHealViolation(json, command, result, violation, state, adapter) {
996
1175
  )
997
1176
  );
998
1177
  } else {
1178
+ const message = violation.code === "APP_LOGIC_FAILURE" && violation.originalError ? conciseFailureForHuman(violation.originalError) : violation.message;
999
1179
  console.error(
1000
- `\u2717 mm-harness ${command}: ${violation.message}` + (violation.originalError ? `
1180
+ `\u2717 mm-harness ${command}: ${message}` + (violation.originalError && violation.code !== "APP_LOGIC_FAILURE" ? `
1001
1181
  --- original failure ---
1002
1182
  ${violation.originalError}` : "") + (userAction ? `
1003
1183
  Next: ${userAction}` : "")
@@ -1007,8 +1187,7 @@ ${violation.originalError}` : "") + (userAction ? `
1007
1187
  }
1008
1188
  function countRecipeNodes(recipe) {
1009
1189
  if (!isRecord(recipe)) return void 0;
1010
- const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
1011
- const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
1190
+ const workflow = isRecord(recipe.workflow) ? recipe.workflow : void 0;
1012
1191
  const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
1013
1192
  return nodes ? Object.keys(nodes).length : void 0;
1014
1193
  }
@@ -1023,7 +1202,6 @@ export {
1023
1202
  preflightRecipe,
1024
1203
  prepareHeal,
1025
1204
  recoverRunInfra,
1026
- requireRecipeTrustResolver,
1027
1205
  resolveMetaMaskLibrarySources,
1028
1206
  resolveRunRecipeArg,
1029
1207
  runOneNode,