@deeeed/metamask-harness 0.17.5 → 0.18.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 (132) hide show
  1. package/CHANGELOG.md +872 -0
  2. package/README.md +94 -140
  3. package/adapters/extension/console-tail.mjs +55 -24
  4. package/adapters/extension/ensure-browser.sh +6 -1
  5. package/adapters/extension/inject.mjs +6 -9
  6. package/adapters/extension/launch-browser.cjs +7 -1
  7. package/adapters/extension/launch-webpack.cjs +83 -0
  8. package/adapters/extension/launch.sh +8 -11
  9. package/adapters/extension/lib/chrome-args.cjs +8 -1
  10. package/adapters/extension/lib/macos-focus.cjs +32 -0
  11. package/adapters/extension/live.sh +12 -21
  12. package/adapters/extension/reattach.sh +1 -0
  13. package/adapters/extension/seed-fixture.sh +4 -12
  14. package/adapters/extension/sidepanel-toggle.sh +4 -1
  15. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  16. package/adapters/extension/start-watch.sh +17 -4
  17. package/adapters/extension/stop-viewers.sh +1 -1
  18. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  19. package/adapters/extension/verify.sh +1 -1
  20. package/adapters/manifest.json +53 -37
  21. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  22. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  23. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  24. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  25. package/adapters/mobile/cleanup.sh +16 -176
  26. package/adapters/mobile/inject.sh +90 -671
  27. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  28. package/adapters/mobile/open-device.sh +45 -7
  29. package/adapters/mobile/start-console-forwarder.sh +70 -0
  30. package/adapters/mobile/start-metro.sh +0 -41
  31. package/adapters/mobile/verify.sh +19 -64
  32. package/adapters/mobile/wait-for-bridge.sh +22 -8
  33. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  34. package/adapters/shared/resolve-slot-ports.sh +17 -5
  35. package/adapters/shared/update-check-worker.mjs +43 -0
  36. package/bin/mm-harness +21 -2
  37. package/dist/adapters/core/surface.js +4 -1
  38. package/dist/adapters/extension/console-capture.js +95 -0
  39. package/dist/adapters/extension/product-config.js +110 -0
  40. package/dist/adapters/extension/runtime-decision.js +20 -2
  41. package/dist/adapters/extension/surface.js +22 -3
  42. package/dist/adapters/mobile/perps-env.js +43 -3
  43. package/dist/adapters/mobile/prepare.js +39 -7
  44. package/dist/adapters/mobile/surface.js +5 -2
  45. package/dist/adapters/resolve-slot-ports.js +2 -2
  46. package/dist/adapters/slot-ports.js +13 -16
  47. package/dist/adapters.js +56 -16
  48. package/dist/checkout-lock.js +27 -2
  49. package/dist/cli-color.js +19 -0
  50. package/dist/cli-commands.js +1 -1
  51. package/dist/cli.js +7 -14
  52. package/dist/command-contract.js +451 -0
  53. package/dist/command-journal.js +225 -0
  54. package/dist/commands/call.js +170 -50
  55. package/dist/commands/check.js +9 -3
  56. package/dist/commands/completion-candidates.js +20 -13
  57. package/dist/commands/device-target.js +27 -12
  58. package/dist/commands/doctor.js +106 -25
  59. package/dist/commands/fixtures.js +92 -34
  60. package/dist/commands/flows.js +39 -10
  61. package/dist/commands/last.js +52 -0
  62. package/dist/commands/launch/extension.js +38 -15
  63. package/dist/commands/launch/index.js +207 -63
  64. package/dist/commands/list-executables.js +151 -29
  65. package/dist/commands/logs.js +8 -6
  66. package/dist/commands/manifest.js +270 -35
  67. package/dist/commands/parse-args.js +13 -1
  68. package/dist/commands/provision.js +10 -3
  69. package/dist/commands/run-engine.js +435 -105
  70. package/dist/commands/run-report.js +12 -3
  71. package/dist/commands/run.js +355 -50
  72. package/dist/commands/shared.js +75 -3
  73. package/dist/commands/status-probe.js +4 -1
  74. package/dist/commands/status.js +2 -1
  75. package/dist/commands/stop.js +7 -2
  76. package/dist/commands/update.js +16 -28
  77. package/dist/completions-cache.js +1 -1
  78. package/dist/doctor.js +57 -10
  79. package/dist/harness.js +47 -137
  80. package/dist/heal-bounds.js +2 -2
  81. package/dist/json-stream.js +57 -0
  82. package/dist/live-adapter-contract.js +138 -32
  83. package/dist/manifest.js +161 -1
  84. package/dist/mm-harness-cli.js +135 -54
  85. package/dist/paths.js +2 -5
  86. package/dist/recipe-security.js +178 -0
  87. package/dist/run-diagnostics.js +261 -0
  88. package/dist/runner.js +117 -8
  89. package/docs/CONTRIBUTING.md +137 -0
  90. package/docs/QA.md +185 -0
  91. package/docs/RECIPES.md +161 -0
  92. package/docs/SECURITY.md +88 -0
  93. package/library/README.md +4 -0
  94. package/library/actions/core/perps/_controller.mjs +10 -55
  95. package/library/actions/core/perps/read_account.mjs +2 -2
  96. package/library/actions/core/perps/read_orders.mjs +2 -1
  97. package/library/actions/core/perps/read_positions.mjs +2 -1
  98. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  99. package/library/actions/extension/platform/cdp.mjs +1 -0
  100. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  101. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  102. package/library/manifests/core.action-manifest.json +68 -7
  103. package/library/manifests/extension.action-manifest.json +53 -0
  104. package/library/manifests/mobile.action-manifest.json +81 -3
  105. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  106. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  107. package/library/recipes/runner/smoke.core.recipe.json +27 -0
  108. package/library/recipes/wallet/smoke.extension.recipe.json +42 -0
  109. package/library/recipes/wallet/smoke.mobile.recipe.json +42 -0
  110. package/package.json +12 -5
  111. package/scripts/completions.sh +7 -7
  112. package/scripts/validate-human-outcomes.mjs +169 -0
  113. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  114. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  115. package/adapters/mobile/overlay/compat/README.md +0 -28
  116. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  117. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  118. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  119. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  120. package/docs/ADAPTER-SURFACE.md +0 -119
  121. package/docs/CHEATSHEET.md +0 -61
  122. package/docs/CLI-SPEC.md +0 -1098
  123. package/docs/CODE-MAP.md +0 -62
  124. package/docs/DEBUG-HANDOVER.md +0 -36
  125. package/docs/MENTAL-MODEL.md +0 -295
  126. package/docs/UX-PRINCIPLES.md +0 -64
  127. package/docs/architecture.md +0 -398
  128. package/docs/live-adapter-contract.md +0 -188
  129. package/docs/package-boundaries.md +0 -47
  130. package/docs/perps-flow-catalog.md +0 -235
  131. package/docs/recipe-libraries.md +0 -301
  132. package/docs/runtime-file-conventions.md +0 -36
@@ -25,10 +25,19 @@ function renderRunReport(summary, entries) {
25
25
  "",
26
26
  `Status: ${String(summary.status ?? "unknown")}`,
27
27
  `Duration: ${formatDuration(Number(summary.durationMs ?? 0))}`,
28
- `Nodes: ${Number(summary.passed ?? 0)}/${Number(summary.total ?? entries.length)} passed`,
29
- "",
30
- "## Steps"
28
+ `Nodes: ${Number(summary.passed ?? 0)}/${Number(summary.total ?? entries.length)} passed`
31
29
  ];
30
+ const sideFindings = isRecord(summary.sideFindings) ? summary.sideFindings : void 0;
31
+ const sideFindingCounts = sideFindings && isRecord(sideFindings.counts) ? sideFindings.counts : void 0;
32
+ const sideFindingTotal = Number(sideFindingCounts?.total ?? 0);
33
+ if (sideFindingTotal > 0) {
34
+ lines.push(
35
+ "",
36
+ "## Side findings",
37
+ `- REVIEW ${sideFindingTotal} distinct application warning/error event(s) (non-blocking; expanded below and stored in diagnostics.json)`
38
+ );
39
+ }
40
+ lines.push("", "## Steps");
32
41
  for (const entry of entries) {
33
42
  if (!isRecord(entry)) continue;
34
43
  const mark = entry.ok === false ? "FAIL" : "PASS";
@@ -3,85 +3,274 @@ import path from "node:path";
3
3
  import { walletFixturePath } from "../paths.js";
4
4
  import { color } from "../cli-color.js";
5
5
  import { recipeRunning } from "../heal-bounds.js";
6
- import { EXIT } from "./shared.js";
6
+ import { checkoutBusyOut, EXIT, usageOut, writeInteractiveProgress } from "./shared.js";
7
7
  import {
8
8
  optionFlag,
9
9
  optionString,
10
- requiredOption,
11
10
  resolveAdapter,
12
11
  runtimeOptionsFromCli,
13
12
  isRecord,
13
+ shellQuote,
14
+ targetPath,
14
15
  usageError
15
16
  } from "./parse-args.js";
16
17
  import {
17
18
  countRecipeNodes,
18
19
  emitHealViolation,
19
20
  executeWithHealBounds,
21
+ preflightRecipe,
20
22
  prepareHeal,
21
23
  recoverRunInfra,
22
24
  runRecipe,
23
25
  validateRunRecipeStatic
24
26
  } from "./run-engine.js";
25
- import { handleListExecutables } from "./list-executables.js";
27
+ import { handleDescribeRecipe, handleListExecutables } from "./list-executables.js";
26
28
  import { applyDeviceTargeting } from "./device-target.js";
27
29
  import { getAdapterSurface } from "../adapters/surface.js";
28
30
  import { coreDependencyBlock } from "./core-readiness.js";
29
31
  import { writeRunReport } from "./run-report.js";
30
32
  import { acquireCheckoutLock } from "../checkout-lock.js";
31
- async function handleRun({ positional, options }) {
32
- if (optionFlag(options, "list")) return handleListExecutables("run", options);
33
+ import { JsonStreamWriter } from "../json-stream.js";
34
+ import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
35
+ import { recipeTrustFailure } from "../recipe-security.js";
36
+ async function handleRun(parsed) {
37
+ const stream = new JsonStreamWriter("run", optionFlag(parsed.options, "jsonStream"));
38
+ const restoreStdout = stream.isolateStdout();
39
+ const target = targetPath(parsed.options);
40
+ try {
41
+ const exitCode = await handleRunInner(parsed, stream);
42
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
43
+ return exitCode;
44
+ } catch (error) {
45
+ const trustFailure = recipeTrustFailure(error);
46
+ if (trustFailure) {
47
+ if (stream.enabled) {
48
+ stream.error(trustFailure);
49
+ stream.complete("fail", EXIT.validation);
50
+ } else if (optionFlag(parsed.options, "json")) {
51
+ console.log(JSON.stringify({
52
+ schemaVersion: 1,
53
+ command: "run",
54
+ status: "fail",
55
+ error: trustFailure,
56
+ exitCode: EXIT.validation
57
+ }, null, 2));
58
+ } else {
59
+ console.error(`\u2717 mm-harness run: ${trustFailure.message}`);
60
+ const blocked = trustFailure.details?.blocked;
61
+ if (blocked?.length) {
62
+ console.error(" Restricted plan nodes:");
63
+ for (const node of blocked.slice(0, 10)) {
64
+ const implementation = node.implementation?.digest ? ` implementation=${node.implementation.kind ?? "custom"}@${node.implementation.digest}` : "";
65
+ console.error(
66
+ ` - ${node.nodeId}: ${node.action} [${node.capabilities.join(", ")}] source=${node.source}${implementation}`
67
+ );
68
+ }
69
+ if (blocked.length > 10) console.error(` - \u2026 ${blocked.length - 10} more (use --json)`);
70
+ }
71
+ console.error(` Next: ${trustFailure.userAction}`);
72
+ }
73
+ return EXIT.validation;
74
+ }
75
+ const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
76
+ stream.error({
77
+ code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "RUN_FAILED",
78
+ message: error instanceof Error ? error.message : String(error),
79
+ userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
80
+ });
81
+ stream.complete("fail", exitCode);
82
+ throw error;
83
+ } finally {
84
+ restoreStdout();
85
+ }
86
+ }
87
+ async function handleRunInner({ positional, options }, stream) {
88
+ if (optionFlag(options, "describe") && (optionFlag(options, "list") || optionFlag(options, "plan"))) {
89
+ const message = "--describe cannot be combined with --list or --plan.";
90
+ const userAction = "choose one: mm-harness run --list, mm-harness run <recipe> --describe, or mm-harness run <recipe> --plan";
91
+ if (stream.enabled) {
92
+ stream.error({ code: "CLI_USAGE_ERROR", message, userAction });
93
+ return EXIT.usage;
94
+ }
95
+ return usageOut(optionFlag(options, "json"), "run", message, userAction);
96
+ }
97
+ if (optionFlag(options, "list")) {
98
+ if (stream.enabled) {
99
+ const message = "--json-stream is for recipe execution; use run --list --json for discovery.";
100
+ stream.error({ code: "JSON_STREAM_UNSUPPORTED_MODE", message, userAction: "mm-harness run --list --json" });
101
+ return EXIT.usage;
102
+ }
103
+ return handleListExecutables("run", options);
104
+ }
33
105
  const targetRecipe = positional[0];
34
106
  if (!targetRecipe) throw usageError("run requires <recipe.json>.");
35
- if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
107
+ if (optionFlag(options, "describe")) {
108
+ if (stream.enabled) {
109
+ const message = "--json-stream is for recipe execution; use --describe --json for discovery.";
110
+ stream.error({
111
+ code: "JSON_STREAM_UNSUPPORTED_MODE",
112
+ message,
113
+ userAction: `mm-harness run ${shellQuote(targetRecipe)} --describe --json`
114
+ });
115
+ return EXIT.usage;
116
+ }
117
+ return handleDescribeRecipe(targetRecipe, options);
118
+ }
119
+ if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options, stream);
36
120
  const { adapter, target } = resolveAdapter(options);
37
121
  const json = optionFlag(options, "json");
122
+ const jsonOutput = json && !stream.enabled;
123
+ const machine = json || stream.enabled;
124
+ stream.phase("resolve", { adapter, target, recipe: targetRecipe });
125
+ if (!fs.existsSync(target)) {
126
+ const message = `target does not exist: ${target}`;
127
+ const userAction = "pass --target <metamask-checkout> pointing to an existing checkout";
128
+ if (stream.enabled) {
129
+ stream.error({ code: "TARGET_NOT_FOUND", message, userAction });
130
+ return EXIT.usage;
131
+ }
132
+ return usageOut(jsonOutput, "run", message, userAction);
133
+ }
134
+ writeInteractiveProgress(machine, `\u2192 recipe run \u2014 validating ${targetRecipe} \xB7 ${adapter}`);
38
135
  getAdapterSurface(adapter).resolveSlotPorts(target);
39
136
  const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
40
137
  if ("code" in dtResult) {
41
- return emitRunUsageError(json, adapter, targetRecipe, dtResult.code, dtResult.message);
42
- }
43
- if (recipeRunning(target)) return emitRunRecipeRunning(json);
44
- if (optionString(options, "artifactsDir") === void 0 && runArgLooksLikeRecipeFile(targetRecipe)) {
45
- requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
138
+ return emitRunUsageError(jsonOutput, stream, adapter, targetRecipe, dtResult.code, dtResult.message, dtResult.userAction);
46
139
  }
140
+ if (recipeRunning(target)) return emitRunRecipeRunning(jsonOutput, stream, target);
141
+ stream.phase("validate");
47
142
  const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
48
143
  if (validated.usageError) {
49
- return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
144
+ const userAction = runUsageRecovery(
145
+ validated.usageError.code,
146
+ validated.usageError.message,
147
+ adapter,
148
+ validated.recipeFile
149
+ );
150
+ return emitRunUsageError(
151
+ jsonOutput,
152
+ stream,
153
+ adapter,
154
+ validated.recipeFile,
155
+ validated.usageError.code,
156
+ validated.usageError.message,
157
+ userAction
158
+ );
50
159
  }
51
160
  if (validated.errorCount > 0) {
52
- return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
161
+ return emitRunValidationError(jsonOutput, stream, adapter, validated.recipeFile, validated.findings, validated.errorCount);
53
162
  }
54
163
  const depsBlock = adapter === "core" && recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
55
164
  if (depsBlock) {
56
- return emitRunUsageError(json, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
165
+ return emitRunUsageError(jsonOutput, stream, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
57
166
  }
58
- const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
167
+ let artifactsDir;
168
+ try {
169
+ artifactsDir = resolveRunArtifactsDir(target, optionString(options, "artifactsDir"));
170
+ } catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ return emitRunUsageError(
173
+ jsonOutput,
174
+ stream,
175
+ adapter,
176
+ validated.recipeFile,
177
+ "ARTIFACT_DIR_INVALID",
178
+ message,
179
+ "set RECIPE_TASK_DIR/FARMSLOT_TASK_DIR inside the checkout or pass --artifacts-dir <path>"
180
+ );
181
+ }
182
+ const librarySources = validated.librarySources;
183
+ const runtimeOptions = {
184
+ ...runtimeOptionsFromCli(options),
185
+ ...librarySources ? { librarySources } : {},
186
+ stdoutIsMachineContract: machine,
187
+ onActionEvent: ({ nodeId, action, status }) => stream.node(nodeId, action, status)
188
+ };
189
+ stream.phase("authorize");
190
+ let preflightedExecution = await preflightRecipe(
191
+ adapter,
192
+ validated.recipeFile,
193
+ artifactsDir,
194
+ target,
195
+ optionString(options, "actionManifest"),
196
+ runtimeOptions
197
+ );
59
198
  const lock = acquireCheckoutLock(target, "run");
60
- if ("message" in lock) return emitRunRecipeRunning(json, lock.message);
199
+ if ("message" in lock) {
200
+ const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
201
+ stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
202
+ return checkoutBusyOut(jsonOutput, "run", lock.message, lock.path);
203
+ }
61
204
  try {
62
- const prepared = await prepareHeal(adapter, target, options, json);
63
- if (typeof prepared === "number") return prepared;
205
+ const prepared = await prepareHeal(adapter, target, options, machine, {
206
+ onPhase: (phase, fields) => stream.phase(phase, fields)
207
+ });
208
+ if (typeof prepared === "number") {
209
+ stream.error({
210
+ code: "RUN_PREPARE_FAILED",
211
+ message: "runtime preparation failed; inspect stderr for the exact probe",
212
+ userAction: `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`
213
+ });
214
+ return prepared;
215
+ }
64
216
  const { state, heal } = prepared;
65
- const librarySources = validated.librarySources;
66
- const runtimeOptions = {
67
- ...runtimeOptionsFromCli(options),
68
- ...librarySources ? { librarySources } : {},
69
- stdoutIsMachineContract: json
70
- };
217
+ stream.phase("execute");
71
218
  const { result, violation } = await executeWithHealBounds(
72
219
  // validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
73
220
  // that only the resolver knows how to turn into a file.
74
- () => runRecipe(adapter, validated.recipeFile, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
221
+ () => {
222
+ const execution = preflightedExecution;
223
+ preflightedExecution = void 0;
224
+ return runRecipe(
225
+ adapter,
226
+ validated.recipeFile,
227
+ artifactsDir,
228
+ target,
229
+ optionString(options, "actionManifest"),
230
+ runtimeOptions,
231
+ execution
232
+ );
233
+ },
75
234
  adapter,
76
235
  target,
77
236
  heal,
78
237
  state,
79
- () => recoverRunInfra(adapter, target, json)
238
+ () => recoverRunInfra(adapter, target, machine),
239
+ (code) => stream.phase("recover", { code })
80
240
  );
81
- if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
241
+ for (const mutation of state.mutations) stream.mutation(mutation);
242
+ for (const recovery of state.recovered) stream.recovery(recovery);
243
+ if (violation !== null) {
244
+ const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
245
+ stream.error({
246
+ code: violation.code,
247
+ message: violation.message,
248
+ userAction,
249
+ originalError: violation.originalError ?? null
250
+ });
251
+ return emitHealViolation(jsonOutput, "run", result, violation, state, adapter);
252
+ }
82
253
  const report = writeRunReport(result);
83
254
  const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
84
- if (json) {
255
+ const failureUserAction = `mm-harness last --target ${shellQuote(target)} --json`;
256
+ if (stream.enabled) {
257
+ if (result.status === "fail") {
258
+ stream.error({
259
+ code: "RECIPE_EXECUTION_FAILED",
260
+ message: "recipe execution failed; inspect the persisted result and evidence paths",
261
+ userAction: failureUserAction
262
+ });
263
+ }
264
+ stream.complete(result.status === "pass" ? "pass" : "fail", exitCode, {
265
+ adapter,
266
+ reportPath: report.path,
267
+ summaryPath: result.summaryPath,
268
+ tracePath: result.tracePath,
269
+ artifactManifestPath: result.artifactManifestPath,
270
+ recovered: state.recovered,
271
+ mutations: state.mutations
272
+ });
273
+ } else if (json) {
85
274
  console.log(
86
275
  JSON.stringify(
87
276
  {
@@ -93,7 +282,8 @@ async function handleRun({ positional, options }) {
93
282
  recovered: state.recovered,
94
283
  mutations: state.mutations,
95
284
  reportPath: report.path,
96
- result
285
+ result,
286
+ ...result.status === "fail" ? { error: { code: "RECIPE_EXECUTION_FAILED", message: "recipe execution failed; inspect the persisted result and evidence paths", userAction: failureUserAction } } : {}
97
287
  },
98
288
  null,
99
289
  2
@@ -106,14 +296,75 @@ async function handleRun({ positional, options }) {
106
296
  console.log(out("label", "summary:"));
107
297
  for (const line of report.preview) console.log(` ${formatPreviewLine(line, out)}`);
108
298
  }
109
- console.log(`${out("label", "report:")} ${out("path", report.path)}`);
110
- console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
299
+ const artifacts = runArtifactInventory(result.artifactManifestPath);
300
+ console.log(out("label", `artifacts (${artifacts.length}):`));
301
+ for (const artifact of artifacts) {
302
+ console.log(` ${out("dim", `${artifact.label}:`)} ${out("path", artifact.absolutePath)}`);
303
+ }
304
+ if (result.status === "fail") {
305
+ console.error(` Next: ${failureUserAction}`);
306
+ }
307
+ console.log(out("label", "diagnostics:"));
308
+ const diagnostics = readRunDiagnosticsDocument(result.diagnosticsPath);
309
+ for (const line of formatRunDiagnosticsForHuman(diagnostics, adapter)) {
310
+ console.log(` ${formatDiagnosticLine(line, out)}`);
311
+ }
111
312
  }
112
313
  return exitCode;
113
314
  } finally {
114
315
  lock.release();
115
316
  }
116
317
  }
318
+ function formatDiagnosticLine(line, out) {
319
+ const match = /^(CLEAN|REVIEW|UNAVAILABLE|N\/A|WARNING|ERROR|EXCEPTION)(.*)$/u.exec(line);
320
+ if (!match) return line;
321
+ const status = match[1];
322
+ const style = status === "CLEAN" ? "ok" : status === "ERROR" || status === "EXCEPTION" ? "err" : "warn";
323
+ return `${out(style, status)}${match[2]}`;
324
+ }
325
+ function resolveRunArtifactsDir(target, explicit) {
326
+ if (explicit !== void 0) return path.resolve(explicit);
327
+ const taskDir = process.env.RECIPE_TASK_DIR || process.env.FARMSLOT_TASK_DIR;
328
+ if (taskDir) {
329
+ const resolvedTask = path.resolve(target, taskDir);
330
+ const relative = path.relative(path.resolve(target), resolvedTask);
331
+ if (!relative.startsWith(`..${path.sep}`) && relative !== "..") {
332
+ return path.join(resolvedTask, "artifacts");
333
+ }
334
+ throw new Error(`task directory must be inside the target checkout: ${taskDir}`);
335
+ }
336
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/gu, "");
337
+ return path.join(target, "temp", "recipe", "runs", `${stamp}-${process.pid}`);
338
+ }
339
+ function runArtifactInventory(manifestPathValue) {
340
+ const manifestPath = path.resolve(String(manifestPathValue));
341
+ const root = path.dirname(manifestPath);
342
+ let manifest;
343
+ try {
344
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
345
+ } catch {
346
+ return [{ absolutePath: manifestPath, basename: path.basename(manifestPath), label: "Artifact manifest" }];
347
+ }
348
+ const entries = isRecord(manifest) && Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
349
+ const artifacts = [];
350
+ for (const entry of entries) {
351
+ if (!isRecord(entry) || typeof entry.path !== "string" || entry.path.length === 0) continue;
352
+ const absolutePath = path.resolve(root, entry.path);
353
+ const basename = path.basename(absolutePath);
354
+ const label = typeof entry.label === "string" && entry.label.length > 0 ? entry.label : basename;
355
+ artifacts.push({ absolutePath, basename, label });
356
+ }
357
+ artifacts.push({ absolutePath: manifestPath, basename: path.basename(manifestPath), label: "Artifact manifest" });
358
+ const priority = /* @__PURE__ */ new Map([
359
+ ["diagnostics.json", 0],
360
+ ["report.md", 1],
361
+ ["summary.json", 2],
362
+ ["trace.json", 3],
363
+ ["recipe.json", 4],
364
+ ["artifact-manifest.json", 100]
365
+ ]);
366
+ return artifacts.filter((artifact, index, all) => all.findIndex((candidate) => candidate.absolutePath === artifact.absolutePath) === index).sort((left, right) => (priority.get(left.basename) ?? 50) - (priority.get(right.basename) ?? 50));
367
+ }
117
368
  function formatPreviewLine(line, out) {
118
369
  const match = /^(PASS|FAIL)\s+(.+)$/u.exec(line);
119
370
  if (!match) return line;
@@ -185,12 +436,29 @@ function loadFlowCatalogs(librarySources) {
185
436
  function actionUsesCoreController(action) {
186
437
  return typeof action === "string" && action.startsWith("metamask.perps.");
187
438
  }
188
- async function handleRunPlan(recipeArg, options) {
439
+ async function handleRunPlan(recipeArg, options, stream) {
189
440
  const json = optionFlag(options, "json");
441
+ const jsonOutput = json && !stream.enabled;
190
442
  const { adapter, target } = resolveAdapter(options);
443
+ stream.phase("resolve", { adapter, target, recipe: recipeArg });
444
+ stream.phase("validate");
191
445
  const validated = await validateRunRecipeStatic(recipeArg, adapter, options);
192
446
  if (validated.usageError) {
193
- return emitPlanUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
447
+ const userAction = runUsageRecovery(
448
+ validated.usageError.code,
449
+ validated.usageError.message,
450
+ adapter,
451
+ validated.recipeFile
452
+ );
453
+ return emitPlanUsageError(
454
+ jsonOutput,
455
+ stream,
456
+ adapter,
457
+ validated.recipeFile,
458
+ validated.usageError.code,
459
+ validated.usageError.message,
460
+ userAction
461
+ );
194
462
  }
195
463
  const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid } = validated;
196
464
  const status = errorCount === 0 ? "pass" : "fail";
@@ -244,9 +512,27 @@ async function handleRunPlan(recipeArg, options) {
244
512
  detail: nodeCount === void 0 ? "would execute the recipe nodes" : `would execute ${nodeCount} recipe node(s)`
245
513
  }
246
514
  ];
247
- if (json) {
248
- const payload = { schemaVersion: 1, command: "run", mode: "plan", status, adapter, recipe: recipeFile, findings, plan };
249
- if (status === "fail") payload.error = { code: "RECIPE_VALIDATION_FAILED", message: `recipe validation found ${errorCount} error(s)` };
515
+ const payload = {
516
+ schemaVersion: 1,
517
+ command: "run",
518
+ mode: "plan",
519
+ status,
520
+ adapter,
521
+ recipe: recipeFile,
522
+ findings,
523
+ plan
524
+ };
525
+ if (status === "fail") {
526
+ payload.error = {
527
+ code: "RECIPE_VALIDATION_FAILED",
528
+ message: `recipe validation found ${errorCount} error(s)`,
529
+ userAction: runPlanProbe(adapter, recipeFile)
530
+ };
531
+ }
532
+ if (stream.enabled) {
533
+ if (status === "fail") stream.error(payload.error);
534
+ stream.complete(status, status === "pass" ? EXIT.ok : EXIT.validation, { mode: "plan", adapter, recipe: recipeFile, findings, plan });
535
+ } else if (json) {
250
536
  console.log(JSON.stringify(payload, null, 2));
251
537
  } else {
252
538
  console.log(`plan ${status} \u2014 ${adapter} \u2014 ${recipeFile}`);
@@ -260,52 +546,58 @@ async function handleRunPlan(recipeArg, options) {
260
546
  console.log(` ${finding.severity === "error" ? "\u2717" : "\u26A0"} ${finding.code} ${finding.path} \u2014 ${finding.message}`);
261
547
  }
262
548
  }
549
+ if (status === "fail") console.error(` Next: ${runPlanProbe(adapter, recipeFile)}`);
263
550
  }
264
551
  return status === "pass" ? EXIT.ok : EXIT.validation;
265
552
  }
266
- function emitPlanUsageError(json, adapter, recipeFile, code, message) {
553
+ function emitPlanUsageError(json, stream, adapter, recipeFile, code, message, userAction) {
554
+ stream.error({ code, message, userAction, mode: "plan", adapter, recipe: recipeFile });
267
555
  if (json) {
268
556
  console.log(
269
557
  JSON.stringify(
270
- { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message } },
558
+ { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message, userAction } },
271
559
  null,
272
560
  2
273
561
  )
274
562
  );
275
563
  } else {
276
- console.error(`\u2717 run --plan: ${message}`);
564
+ console.error(`\u2717 run --plan: ${message}
565
+ Next: ${userAction}`);
277
566
  }
278
567
  return EXIT.usage;
279
568
  }
280
- function runArgLooksLikeRecipeFile(value) {
281
- return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
282
- }
283
- function emitRunRecipeRunning(json, detail) {
569
+ function emitRunRecipeRunning(json, stream, target, detail) {
284
570
  const message = detail ?? "a recipe is currently running \u2014 refusing to start while another recipe executes.";
571
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
572
+ stream.error({ code: "RECIPE_RUNNING", message, userAction, recoverable: false });
285
573
  if (json) {
286
- console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message } }, null, 2));
574
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message, userAction } }, null, 2));
287
575
  } else {
288
- console.error(`\u2717 mm-harness: ${message}`);
576
+ console.error(`\u2717 mm-harness: ${message}
577
+ Next: ${userAction}`);
289
578
  }
290
579
  return EXIT.bounded;
291
580
  }
292
- function emitRunUsageError(json, adapter, recipeFile, code, message, userAction) {
581
+ function emitRunUsageError(json, stream, adapter, recipeFile, code, message, userAction) {
582
+ stream.error({ code, message, userAction });
293
583
  if (json) {
294
584
  console.log(
295
585
  JSON.stringify(
296
- { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, ...userAction ? { userAction } : {} } },
586
+ { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, userAction } },
297
587
  null,
298
588
  2
299
589
  )
300
590
  );
301
591
  } else {
302
592
  console.error(`\u2717 run: ${message}`);
303
- if (userAction) console.error(` Next: ${userAction}`);
593
+ console.error(` Next: ${userAction}`);
304
594
  }
305
595
  return EXIT.usage;
306
596
  }
307
- function emitRunValidationError(json, adapter, recipeFile, findings, errorCount) {
597
+ function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount) {
308
598
  const message = `recipe validation found ${errorCount} error(s)`;
599
+ const userAction = runPlanProbe(adapter, recipeFile);
600
+ stream.error({ code: "RECIPE_VALIDATION_FAILED", message, userAction, findings });
309
601
  if (json) {
310
602
  console.log(
311
603
  JSON.stringify(
@@ -319,7 +611,7 @@ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount)
319
611
  mutations: [],
320
612
  recipe: recipeFile,
321
613
  findings,
322
- error: { code: "RECIPE_VALIDATION_FAILED", message }
614
+ error: { code: "RECIPE_VALIDATION_FAILED", message, userAction }
323
615
  },
324
616
  null,
325
617
  2
@@ -330,9 +622,22 @@ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount)
330
622
  for (const finding of findings) {
331
623
  if (finding.severity === "error") console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
332
624
  }
625
+ console.error(` Next: ${userAction}`);
333
626
  }
334
627
  return EXIT.validation;
335
628
  }
629
+ function runPlanProbe(adapter, recipeFile) {
630
+ return `mm-harness run --plan ${shellQuote(recipeFile)} --adapter ${adapter} --json`;
631
+ }
632
+ function runUsageRecovery(code, message, adapter, recipeFile) {
633
+ const actionCommand = /This is an action, not a recipe\. Use: (mm-harness call .+)\.$/u.exec(message)?.[1];
634
+ if (actionCommand) return actionCommand;
635
+ if (code === "RECIPE_NOT_FOUND") return `mm-harness run --list --adapter ${adapter} --json`;
636
+ if (code === "RECIPE_UNPARSEABLE") {
637
+ return `fix the JSON syntax in ${shellQuote(recipeFile)}, then retry: ${runPlanProbe(adapter, recipeFile)}`;
638
+ }
639
+ return runPlanProbe(adapter, recipeFile);
640
+ }
336
641
  export {
337
642
  handleRun
338
643
  };