@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.
- package/CHANGELOG.md +187 -0
- package/README.md +4 -7
- package/adapters/core/inject.sh +1 -6
- package/adapters/extension/inject.mjs +1 -4
- package/adapters/extension/verify.sh +1 -1
- package/adapters/mobile/inject.sh +5 -4
- package/adapters/mobile/verify.sh +15 -19
- package/adapters/shared/harness-source-fingerprint.mjs +14 -12
- package/dist/adapters/harness-freshness.js +49 -0
- package/dist/cli.js +0 -4
- package/dist/command-contract.js +51 -40
- package/dist/command-journal.js +50 -12
- package/dist/commands/call.js +95 -20
- package/dist/commands/check.js +1 -1
- package/dist/commands/completion-candidates.js +7 -19
- package/dist/commands/fixtures.js +50 -1
- package/dist/commands/last.js +9 -1
- package/dist/commands/launch/extension.js +2 -2
- package/dist/commands/launch/mobile.js +2 -0
- package/dist/commands/list-executables.js +56 -20
- package/dist/commands/manifest.js +28 -12
- package/dist/commands/parse-args.js +55 -3
- package/dist/commands/provision.js +0 -1
- package/dist/commands/run-engine.js +514 -336
- package/dist/commands/run.js +25 -37
- package/dist/commands/shared.js +1 -1
- package/dist/heal-bounds.js +5 -0
- package/dist/mm-harness-cli.js +22 -30
- package/dist/run-diagnostics.js +1 -1
- package/dist/run-recording.js +1 -1
- package/dist/runner.js +24 -2
- package/docs/CONTRIBUTING.md +2 -3
- package/docs/QA.md +0 -1
- package/docs/RECIPES.md +52 -91
- package/library/README.md +5 -5
- package/library/actions/core/perps/_controller.mjs +33 -1
- package/library/actions/core/perps/assert_orders.mjs +6 -7
- package/library/actions/core/perps/assert_positions.mjs +6 -7
- package/library/actions/core/perps/close_orders.mjs +2 -0
- package/library/actions/core/perps/close_positions.mjs +2 -0
- package/library/actions/core/perps/ensure_orders.mjs +4 -2
- package/library/actions/core/perps/ensure_positions.mjs +4 -2
- package/library/actions/core/perps/place_order.mjs +7 -3
- package/library/actions/extension/perps/assert_orders.mjs +2 -1
- package/library/actions/extension/perps/assert_positions.mjs +2 -1
- package/library/actions/extension/perps/perps.mjs +43 -14
- package/library/actions/mobile/perps/assert_orders.mjs +2 -1
- package/library/actions/mobile/perps/assert_positions.mjs +2 -1
- package/library/actions/mobile/perps/perps.mjs +40 -12
- package/library/library.json +1 -1
- package/library/manifests/core.action-manifest.json +1170 -413
- package/library/manifests/extension.action-manifest.json +1495 -641
- package/library/manifests/mobile.action-manifest.json +1704 -769
- package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
- package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
- package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
- package/library/recipes/perps/lifecycle.recipe.json +136 -180
- package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
- package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
- package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
- package/library/recipes/perps/performance.mobile.recipe.json +37 -51
- package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
- package/library/recipes/perps/read-markets.core.recipe.json +29 -31
- package/library/recipes/perps/smoke.core.recipe.json +29 -32
- package/library/recipes/perps/smoke.extension.recipe.json +41 -44
- package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
- package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
- package/library/recipes/runner/action-validation.extension.recipe.json +312 -405
- package/library/recipes/runner/action-validation.mobile.recipe.json +316 -409
- package/library/recipes/runner/smoke.core.recipe.json +18 -20
- package/library/recipes/runner/smoke.extension.recipe.json +23 -24
- package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
- package/library/recipes/wallet/smoke.extension.recipe.json +33 -35
- package/library/recipes/wallet/smoke.mobile.recipe.json +33 -35
- package/package.json +3 -3
- package/scripts/completions.sh +1 -4
- package/dist/adapters/extension/harness-freshness.js +0 -39
- package/dist/commands/flows.js +0 -91
- 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 {
|
|
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(
|
|
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 =
|
|
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")
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
|
253
|
-
let
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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 {
|
|
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
|
|
279
|
-
if (
|
|
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
|
|
298
|
-
const nonCanonical =
|
|
299
|
-
const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${
|
|
300
|
-
const suffix =
|
|
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
|
|
304
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 [...
|
|
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(
|
|
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 {
|
|
385
|
+
return {
|
|
386
|
+
unreadable: `recipe ${resolved.recipeFile} must contain a JSON object.`
|
|
387
|
+
};
|
|
386
388
|
}
|
|
387
|
-
const
|
|
388
|
-
const
|
|
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
|
-
|
|
392
|
-
source:
|
|
393
|
-
file:
|
|
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
|
-
...
|
|
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
|
-
|
|
404
|
-
|
|
410
|
+
nestedRecipes: composition.nestedRecipes,
|
|
411
|
+
unresolvedRecipes: composition.unresolvedRecipes
|
|
405
412
|
}
|
|
406
413
|
};
|
|
407
414
|
}
|
|
408
|
-
function
|
|
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
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
441
|
-
|
|
481
|
+
nestedRecipes: [...nestedRecipes].sort(),
|
|
482
|
+
unresolvedRecipes: [...unresolvedRecipes].sort()
|
|
442
483
|
};
|
|
443
484
|
}
|
|
444
|
-
function
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
542
|
-
findings.push(...validation.findings);
|
|
550
|
+
let effectiveParams = params;
|
|
543
551
|
if (isRecord(recipe)) {
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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)
|
|
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 } :
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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(
|
|
683
|
+
const { manifest } = await resolveActionManifest(
|
|
684
|
+
adapter,
|
|
685
|
+
actionManifest,
|
|
686
|
+
librarySources
|
|
687
|
+
);
|
|
656
688
|
const recipe = synthesizeOneNodeRecipe(action, args);
|
|
657
|
-
const validation = await validateRecipeAdapterAware(
|
|
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(
|
|
664
|
-
|
|
665
|
-
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
704
|
-
|
|
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(
|
|
722
|
-
|
|
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(
|
|
733
|
-
|
|
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(
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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(
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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)
|
|
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 {
|
|
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(
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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(
|
|
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({
|
|
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(
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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 (![
|
|
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(
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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(
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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)
|
|
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(
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
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}: ${
|
|
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
|
|
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,
|