@deeeed/metamask-harness 0.15.2 → 0.16.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 +7 -0
- package/README.md +9 -1
- package/dist/cli-commands.js +1 -1
- package/dist/commands/call.js +6 -1
- package/dist/commands/manifest.js +45 -4
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/run-engine.js +8 -1
- package/dist/commands/run-report.js +12 -3
- package/dist/mm-harness-cli.js +4 -0
- package/dist/run-diagnostics.js +271 -0
- package/docs/CLI-SPEC.md +9 -5
- package/docs/MENTAL-MODEL.md +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.16.0 - 2026-07-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Mobile and Extension recipe runs now write bounded, redacted, non-blocking side findings to `diagnostics.json`: Mobile combines its in-app issue buffer with run-scoped log bytes, while Extension owns a CDP console subscription for the run. Core remains N/A because it is headless.
|
|
10
|
+
- `actions --categories` now lists compact category counts, and `actions --category <name>` returns a bounded action/schema view so agents can discover capabilities without loading the complete adapter catalog.
|
|
11
|
+
|
|
5
12
|
## 0.15.2 - 2026-07-13
|
|
6
13
|
|
|
7
14
|
### Fixed
|
package/README.md
CHANGED
|
@@ -50,6 +50,13 @@ mm-harness launch ios # mobile: ios | android; extension: jus
|
|
|
50
50
|
mm-harness run recipe.json --adapter mobile --artifacts-dir /tmp/recipe-artifacts --json
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
Mobile and Extension runs also write `diagnostics.json` from application events
|
|
54
|
+
emitted during that run. Mobile combines its bounded in-app issue buffer with
|
|
55
|
+
run-scoped log bytes; Extension owns a CDP console subscription for the run.
|
|
56
|
+
These redacted warnings/errors are non-blocking side findings: they help spot
|
|
57
|
+
adjacent bugs without claiming the recipe or current change caused them. Core
|
|
58
|
+
is headless, so this is N/A there.
|
|
59
|
+
|
|
53
60
|
Outputs: `summary.json`, `trace.json`, screenshots, logs, and an artifact
|
|
54
61
|
manifest.
|
|
55
62
|
|
|
@@ -69,7 +76,8 @@ windows, wallet state, health, ports, parallel).
|
|
|
69
76
|
|
|
70
77
|
```bash
|
|
71
78
|
# Capabilities (compose recipes from the vocabulary + flow library)
|
|
72
|
-
mm-harness actions --adapter mobile --json
|
|
79
|
+
mm-harness actions --adapter mobile --categories --json # compact discovery
|
|
80
|
+
mm-harness actions --adapter mobile --category ui --json # bounded vocabulary
|
|
73
81
|
mm-harness actions --adapter mobile --raw # raw action-manifest dump
|
|
74
82
|
mm-harness call unlock --adapter extension # run one action via the real engine path
|
|
75
83
|
mm-harness flows --json # reusable library flows, with provenance
|
package/dist/cli-commands.js
CHANGED
|
@@ -15,7 +15,7 @@ const SPEC = {
|
|
|
15
15
|
{ name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
|
|
16
16
|
{ name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
|
|
17
17
|
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
|
|
18
|
-
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
|
|
18
|
+
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--categories", "--category", "--action"] },
|
|
19
19
|
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
|
|
20
20
|
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
|
21
21
|
{ name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
|
package/dist/commands/call.js
CHANGED
|
@@ -174,6 +174,8 @@ async function handleCall(argv) {
|
|
|
174
174
|
summaryPath: result.summaryPath,
|
|
175
175
|
tracePath: result.tracePath,
|
|
176
176
|
artifactManifestPath: result.artifactManifestPath,
|
|
177
|
+
...result.diagnosticsPath ? { diagnosticsPath: result.diagnosticsPath } : {},
|
|
178
|
+
...result.sideFindings ? { sideFindings: result.sideFindings } : {},
|
|
177
179
|
...callOutput !== void 0 ? { output: callOutput } : {},
|
|
178
180
|
recovered: state.recovered,
|
|
179
181
|
mutations: state.mutations,
|
|
@@ -187,12 +189,15 @@ async function handleCall(argv) {
|
|
|
187
189
|
const rendered = callOutput !== void 0 ? `
|
|
188
190
|
Result:
|
|
189
191
|
${formatCallOutput(callOutput)}` : "";
|
|
192
|
+
const sideFindingTotal = result.sideFindings?.counts.total ?? 0;
|
|
190
193
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
194
|
+
const sideFindings = sideFindingTotal > 0 ? `${out("label", "Side findings:")} REVIEW ${sideFindingTotal} distinct application warning/error event(s); see ${out("path", result.diagnosticsPath ?? "diagnostics.json")} (non-blocking)
|
|
195
|
+
` : "";
|
|
191
196
|
console.log(
|
|
192
197
|
`${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
|
|
193
198
|
${out("label", "Result:")}
|
|
194
199
|
${formatCallOutput(callOutput)}` : ""}
|
|
195
|
-
|
|
200
|
+
` + sideFindings + `${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
|
|
196
201
|
);
|
|
197
202
|
}
|
|
198
203
|
return result.status === "pass" ? EXIT.ok : EXIT.runtime;
|
|
@@ -22,13 +22,42 @@ async function handleActions({ options }) {
|
|
|
22
22
|
await validateManifest(manifest);
|
|
23
23
|
const json = optionFlag(options, "json");
|
|
24
24
|
const action = optionString(options, "action");
|
|
25
|
+
const category = optionString(options, "category")?.toLowerCase();
|
|
26
|
+
const categoriesOnly = optionFlag(options, "categories");
|
|
25
27
|
const all = describeManifestActions(manifest);
|
|
26
|
-
const
|
|
28
|
+
const categories = summarizeActionCategories(all);
|
|
29
|
+
if (categoriesOnly && (action || category)) {
|
|
30
|
+
const message = "--categories cannot be combined with --action or --category.";
|
|
31
|
+
if (json) {
|
|
32
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, error: { code: "ACTION_FILTER_CONFLICT", message } }, null, 2));
|
|
33
|
+
} else {
|
|
34
|
+
console.error(`\u2717 mm-harness actions: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
return EXIT.usage;
|
|
37
|
+
}
|
|
38
|
+
if (categoriesOnly) {
|
|
39
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, categories }, null, 2));
|
|
40
|
+
else for (const entry of categories) console.log(`${entry.name} (${entry.count})`);
|
|
41
|
+
return EXIT.ok;
|
|
42
|
+
}
|
|
43
|
+
const categoryActions = category ? all.filter((entry) => entry.category === category) : all;
|
|
44
|
+
if (category && categoryActions.length === 0) {
|
|
45
|
+
const message = `no action category matches "${category}" for the ${adapter} adapter.`;
|
|
46
|
+
const userAction = `mm-harness actions --adapter ${adapter} --categories`;
|
|
47
|
+
if (json) {
|
|
48
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, category, availableCategories: categories, error: { code: "ACTION_CATEGORY_UNKNOWN", message, userAction } }, null, 2));
|
|
49
|
+
} else {
|
|
50
|
+
console.error(`\u2717 mm-harness actions: ${message}
|
|
51
|
+
Next: ${userAction}`);
|
|
52
|
+
}
|
|
53
|
+
return EXIT.usage;
|
|
54
|
+
}
|
|
55
|
+
const actions = action ? fuzzyResolveActions(categoryActions, action) : categoryActions;
|
|
27
56
|
if (action && actions.length === 0) {
|
|
28
57
|
const message = `no action matches "${action}" for the ${adapter} adapter.`;
|
|
29
58
|
const userAction = `mm-harness actions --adapter ${adapter} # list the vocabulary`;
|
|
30
59
|
if (json) {
|
|
31
|
-
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
|
|
60
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, category, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
|
|
32
61
|
} else {
|
|
33
62
|
console.error(`\u2717 mm-harness actions: ${message}
|
|
34
63
|
Next: ${userAction}`);
|
|
@@ -36,7 +65,7 @@ async function handleActions({ options }) {
|
|
|
36
65
|
return EXIT.usage;
|
|
37
66
|
}
|
|
38
67
|
if (json) {
|
|
39
|
-
console.log(JSON.stringify({ adapter, actions }, null, 2));
|
|
68
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, category, actions }, null, 2));
|
|
40
69
|
} else {
|
|
41
70
|
for (const entry of actions) {
|
|
42
71
|
const fields = entry.fields.length ? ` fields=${entry.fields.join(",")}` : "";
|
|
@@ -53,6 +82,11 @@ function fuzzyResolveActions(entries, query) {
|
|
|
53
82
|
if (exactSegment.length > 0) return exactSegment;
|
|
54
83
|
return entries.filter((e) => finalSegment(e.name).includes(query));
|
|
55
84
|
}
|
|
85
|
+
function summarizeActionCategories(actions) {
|
|
86
|
+
const counts = /* @__PURE__ */ new Map();
|
|
87
|
+
for (const action of actions) counts.set(action.category, (counts.get(action.category) ?? 0) + 1);
|
|
88
|
+
return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((left, right) => left.name.localeCompare(right.name));
|
|
89
|
+
}
|
|
56
90
|
function describeManifestActions(manifest) {
|
|
57
91
|
const manifestRecord = isRecord(manifest) ? manifest : {};
|
|
58
92
|
const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
|
|
@@ -80,15 +114,22 @@ function describeManifestAction(name, kind, metadata) {
|
|
|
80
114
|
return {
|
|
81
115
|
name,
|
|
82
116
|
kind,
|
|
117
|
+
category: actionCategory(name),
|
|
83
118
|
description: typeof record.description === "string" ? record.description : "",
|
|
84
119
|
fields: properties,
|
|
85
120
|
schema,
|
|
86
121
|
examples: record.examples
|
|
87
122
|
};
|
|
88
123
|
}
|
|
124
|
+
function actionCategory(name) {
|
|
125
|
+
const segments = name.split(".");
|
|
126
|
+
if (segments[0] === "metamask" && segments.length > 2) return segments[1] ?? "metamask";
|
|
127
|
+
return segments[0] || "other";
|
|
128
|
+
}
|
|
89
129
|
export {
|
|
90
130
|
describeManifestActions,
|
|
91
131
|
fuzzyResolveActions,
|
|
92
132
|
handleActions,
|
|
93
|
-
handleManifest
|
|
133
|
+
handleManifest,
|
|
134
|
+
summarizeActionCategories
|
|
94
135
|
};
|
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
|
|
21
21
|
import { listRecipeFiles } from "../recipe-files.js";
|
|
22
22
|
import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
|
|
23
|
+
import {
|
|
24
|
+
beginRunDiagnostics,
|
|
25
|
+
finishRunDiagnostics,
|
|
26
|
+
stopRunDiagnostics
|
|
27
|
+
} from "../run-diagnostics.js";
|
|
23
28
|
import { EXIT } from "./shared.js";
|
|
24
29
|
import {
|
|
25
30
|
actionManifestPathOption,
|
|
@@ -49,6 +54,7 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
|
|
|
49
54
|
try {
|
|
50
55
|
await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
|
|
51
56
|
const absoluteArtifactsDir = path.resolve(artifactsDir);
|
|
57
|
+
const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
|
|
52
58
|
const recordVideo = runtimeOptions.recordVideo ?? false;
|
|
53
59
|
const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
|
|
54
60
|
const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
|
|
@@ -80,8 +86,9 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
|
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
await stopRecipeRecording(recording, result);
|
|
83
|
-
return result;
|
|
89
|
+
return finishRunDiagnostics(diagnosticBaseline, result);
|
|
84
90
|
} finally {
|
|
91
|
+
stopRunDiagnostics(diagnosticBaseline);
|
|
85
92
|
await stopRecipeRecording(recording);
|
|
86
93
|
}
|
|
87
94
|
} finally {
|
|
@@ -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); see diagnostics.json (non-blocking)`
|
|
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";
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -76,6 +76,8 @@ Example:
|
|
|
76
76
|
List the action vocabulary + field schemas for the checkout adapter.
|
|
77
77
|
|
|
78
78
|
--action <name> Describe one action; fuzzy-resolves like call (short or full name)
|
|
79
|
+
--categories List compact action categories and counts
|
|
80
|
+
--category <name> List only one category (for example ui, wallet, or perps)
|
|
79
81
|
--adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
|
|
80
82
|
--target <path> Checkout path (default: cwd)
|
|
81
83
|
--raw Dump raw action registry JSON
|
|
@@ -83,6 +85,8 @@ Example:
|
|
|
83
85
|
|
|
84
86
|
Example:
|
|
85
87
|
mm-harness actions --adapter mobile
|
|
88
|
+
mm-harness actions --adapter mobile --categories --json
|
|
89
|
+
mm-harness actions --adapter mobile --category ui --json
|
|
86
90
|
mm-harness actions --adapter mobile --action assert_orders
|
|
87
91
|
mm-harness actions --adapter extension --raw`
|
|
88
92
|
},
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { getAdapterSurface } from "./adapters/surface.js";
|
|
6
|
+
import { runnerDir } from "./paths.js";
|
|
7
|
+
const MAX_CAPTURE_BYTES = 512 * 1024;
|
|
8
|
+
const MAX_FINDINGS = 20;
|
|
9
|
+
const MAX_PREVIEW_CHARS = 320;
|
|
10
|
+
async function beginRunDiagnostics(adapter, projectRoot) {
|
|
11
|
+
const source = getAdapterSurface(adapter).appLogSource(projectRoot);
|
|
12
|
+
if (!source) return null;
|
|
13
|
+
const mobileIssueBuffer = adapter === "mobile" && armMobileIssueBuffer(projectRoot) ? { projectRoot } : void 0;
|
|
14
|
+
const extensionConsoleCapture = adapter === "extension" ? await startExtensionConsoleCapture(source, projectRoot) : void 0;
|
|
15
|
+
const stat = safeStat(source.path);
|
|
16
|
+
return {
|
|
17
|
+
source,
|
|
18
|
+
offset: stat?.size ?? 0,
|
|
19
|
+
...stat ? { inode: stat.ino } : {},
|
|
20
|
+
...mobileIssueBuffer ? { mobileIssueBuffer } : {},
|
|
21
|
+
...extensionConsoleCapture ? { extensionConsoleCapture } : {}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function finishRunDiagnostics(baseline, result) {
|
|
25
|
+
if (!baseline) return result;
|
|
26
|
+
try {
|
|
27
|
+
stopRunDiagnostics(baseline);
|
|
28
|
+
const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
|
|
29
|
+
const diagnostics = collectRunDiagnostics(baseline, bufferedIssues);
|
|
30
|
+
const artifactsDir = path.dirname(result.summaryPath);
|
|
31
|
+
const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
|
|
32
|
+
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
|
|
33
|
+
`);
|
|
34
|
+
indexDiagnosticArtifact(result.artifactManifestPath);
|
|
35
|
+
indexDiagnosticSummary(result.summaryPath, diagnostics);
|
|
36
|
+
return {
|
|
37
|
+
...result,
|
|
38
|
+
diagnosticsPath,
|
|
39
|
+
sideFindings: {
|
|
40
|
+
status: diagnostics.status,
|
|
41
|
+
note: diagnostics.note,
|
|
42
|
+
counts: diagnostics.counts
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
47
|
+
console.warn(`WARN: run diagnostics were unavailable: ${detail}`);
|
|
48
|
+
return {
|
|
49
|
+
...result,
|
|
50
|
+
sideFindings: {
|
|
51
|
+
status: "unavailable",
|
|
52
|
+
note: "Run diagnostics could not be collected; the recipe result is unchanged.",
|
|
53
|
+
counts: { total: 0, warning: 0, error: 0, exception: 0 }
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function stopRunDiagnostics(baseline) {
|
|
59
|
+
const capture = baseline?.extensionConsoleCapture;
|
|
60
|
+
if (!capture || capture.exitCode !== null || capture.signalCode !== null) return;
|
|
61
|
+
capture.kill("SIGTERM");
|
|
62
|
+
}
|
|
63
|
+
function collectRunDiagnostics(baseline, bufferedIssues) {
|
|
64
|
+
const stat = safeStat(baseline.source.path);
|
|
65
|
+
const source = {
|
|
66
|
+
label: baseline.source.label,
|
|
67
|
+
path: baseline.source.path,
|
|
68
|
+
startOffset: baseline.offset,
|
|
69
|
+
endOffset: stat?.size ?? baseline.offset,
|
|
70
|
+
bytesRead: 0,
|
|
71
|
+
truncated: false,
|
|
72
|
+
inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected"
|
|
73
|
+
};
|
|
74
|
+
let text = "";
|
|
75
|
+
if (stat) {
|
|
76
|
+
const sameFile = baseline.inode === void 0 || baseline.inode === stat.ino;
|
|
77
|
+
const startOffset = sameFile && stat.size >= baseline.offset ? baseline.offset : 0;
|
|
78
|
+
const available = Math.max(0, stat.size - startOffset);
|
|
79
|
+
const bytesToRead = Math.min(available, MAX_CAPTURE_BYTES);
|
|
80
|
+
source.startOffset = startOffset;
|
|
81
|
+
source.endOffset = stat.size;
|
|
82
|
+
source.bytesRead = bytesToRead;
|
|
83
|
+
source.truncated = available > MAX_CAPTURE_BYTES;
|
|
84
|
+
if (bytesToRead > 0) text = readSlice(baseline.source.path, startOffset, bytesToRead);
|
|
85
|
+
}
|
|
86
|
+
const allFindings = dedupeFindings(
|
|
87
|
+
[
|
|
88
|
+
...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
|
|
89
|
+
...(bufferedIssues ?? []).map(classifyBufferedIssue).filter((finding) => finding !== null)
|
|
90
|
+
]
|
|
91
|
+
);
|
|
92
|
+
const findings = allFindings.slice(0, MAX_FINDINGS);
|
|
93
|
+
const counts = countFindings(allFindings);
|
|
94
|
+
const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
|
|
95
|
+
const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
|
|
96
|
+
return {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
scope: "recipe-run-application",
|
|
99
|
+
status,
|
|
100
|
+
nonBlocking: true,
|
|
101
|
+
note,
|
|
102
|
+
source,
|
|
103
|
+
counts,
|
|
104
|
+
findings
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function classifyLine(line) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
if (!trimmed) return null;
|
|
110
|
+
let level = null;
|
|
111
|
+
if (/\bEXCEPTION\s{2,}|\b(?:Uncaught|UnhandledPromiseRejection|FATAL EXCEPTION)\b/iu.test(trimmed)) {
|
|
112
|
+
level = "exception";
|
|
113
|
+
} else if (/^(?:ERROR\s{2,}|\[error\]\s*)/iu.test(trimmed) || /\]\s+ERROR\s{2,}/u.test(trimmed)) {
|
|
114
|
+
level = "error";
|
|
115
|
+
} else if (/^(?:WARN(?:ING)?\s{2,}|\[warn(?:ing)?\]\s*)/iu.test(trimmed) || /\]\s+WARN(?:ING)?\s{2,}/u.test(trimmed)) {
|
|
116
|
+
level = "warning";
|
|
117
|
+
}
|
|
118
|
+
if (!level) return null;
|
|
119
|
+
return makeFinding(level, trimmed);
|
|
120
|
+
}
|
|
121
|
+
function classifyBufferedIssue(value) {
|
|
122
|
+
if (!isRecord(value) || typeof value.text !== "string") return null;
|
|
123
|
+
const rawLevel = String(value.level ?? "").toLowerCase();
|
|
124
|
+
const level = rawLevel === "warn" || rawLevel === "warning" ? "warning" : rawLevel === "error" ? "error" : rawLevel === "exception" ? "exception" : null;
|
|
125
|
+
return level ? makeFinding(level, value.text) : null;
|
|
126
|
+
}
|
|
127
|
+
function makeFinding(level, text) {
|
|
128
|
+
const preview = redactPreview(text.trim());
|
|
129
|
+
const identity = preview.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\b/gu, "[TIME]").replace(/\[(?:sw|page:[^\]]+|console:[^\]]+)\]/gu, "[APP]").replace(/^(?:(?:WARN(?:ING)?|ERROR|EXCEPTION|\[TIME\]|\[APP\])\s*)+/u, "");
|
|
130
|
+
return {
|
|
131
|
+
level,
|
|
132
|
+
fingerprint: createHash("sha256").update(`${level}|${identity}`).digest("hex").slice(0, 12),
|
|
133
|
+
preview
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function armMobileIssueBuffer(projectRoot) {
|
|
137
|
+
const armed = runMobileIssueCommand(projectRoot, "issues-arm");
|
|
138
|
+
if (!armed) return false;
|
|
139
|
+
return runMobileIssueCommand(projectRoot, "issues-collect") !== null;
|
|
140
|
+
}
|
|
141
|
+
function collectMobileIssueBuffer(projectRoot) {
|
|
142
|
+
const result = runMobileIssueCommand(projectRoot, "issues-collect");
|
|
143
|
+
return isRecord(result) && Array.isArray(result.entries) ? result.entries : null;
|
|
144
|
+
}
|
|
145
|
+
function runMobileIssueCommand(projectRoot, command) {
|
|
146
|
+
const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
|
|
147
|
+
const result = spawnSync(process.execPath, [bridge, command], {
|
|
148
|
+
cwd: projectRoot,
|
|
149
|
+
env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
timeout: 1e4
|
|
152
|
+
});
|
|
153
|
+
if (result.status !== 0) return null;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(result.stdout.trim());
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function startExtensionConsoleCapture(source, projectRoot) {
|
|
161
|
+
const cdpPort = process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
162
|
+
if (!cdpPort) return void 0;
|
|
163
|
+
fs.mkdirSync(path.dirname(source.path), { recursive: true });
|
|
164
|
+
const before = safeStat(source.path)?.size ?? 0;
|
|
165
|
+
const script = path.join(runnerDir, "adapters", "extension", "console-tail.mjs");
|
|
166
|
+
const child = spawn(process.execPath, [script, "--cdp-port", cdpPort, "--log", source.path], {
|
|
167
|
+
cwd: projectRoot,
|
|
168
|
+
stdio: "ignore"
|
|
169
|
+
});
|
|
170
|
+
const attached = await waitForExtensionAttachment(source.path, before, child, 3e3);
|
|
171
|
+
if (attached) {
|
|
172
|
+
await delay(50);
|
|
173
|
+
return child;
|
|
174
|
+
}
|
|
175
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
|
176
|
+
return void 0;
|
|
177
|
+
}
|
|
178
|
+
async function waitForExtensionAttachment(logPath, offset, child, timeoutMs) {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) {
|
|
181
|
+
const stat = safeStat(logPath);
|
|
182
|
+
if (stat && stat.size > offset) {
|
|
183
|
+
const appended = readSlice(logPath, offset, Math.min(stat.size - offset, 64 * 1024));
|
|
184
|
+
if (appended.includes("[attached]")) return true;
|
|
185
|
+
}
|
|
186
|
+
await delay(50);
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
function delay(ms) {
|
|
191
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
192
|
+
}
|
|
193
|
+
function redactPreview(value) {
|
|
194
|
+
return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/\b(password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]").slice(0, MAX_PREVIEW_CHARS);
|
|
195
|
+
}
|
|
196
|
+
function dedupeFindings(findings) {
|
|
197
|
+
const byFingerprint = /* @__PURE__ */ new Map();
|
|
198
|
+
for (const finding of findings) {
|
|
199
|
+
const existing = byFingerprint.get(finding.fingerprint);
|
|
200
|
+
if (existing) existing.count += 1;
|
|
201
|
+
else byFingerprint.set(finding.fingerprint, { ...finding, count: 1 });
|
|
202
|
+
}
|
|
203
|
+
return [...byFingerprint.values()];
|
|
204
|
+
}
|
|
205
|
+
function countFindings(findings) {
|
|
206
|
+
const counts = { total: findings.length, warning: 0, error: 0, exception: 0 };
|
|
207
|
+
for (const finding of findings) counts[finding.level] += 1;
|
|
208
|
+
return counts;
|
|
209
|
+
}
|
|
210
|
+
function readSlice(filePath, offset, length) {
|
|
211
|
+
const fd = fs.openSync(filePath, "r");
|
|
212
|
+
try {
|
|
213
|
+
const buffer = Buffer.alloc(length);
|
|
214
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
|
|
215
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
216
|
+
} finally {
|
|
217
|
+
fs.closeSync(fd);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function safeStat(filePath) {
|
|
221
|
+
try {
|
|
222
|
+
return fs.statSync(filePath);
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function indexDiagnosticArtifact(manifestPath) {
|
|
228
|
+
const manifest = readJsonRecord(manifestPath);
|
|
229
|
+
if (!manifest) return;
|
|
230
|
+
const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
|
|
231
|
+
manifest.artifacts = [
|
|
232
|
+
...artifacts.filter((artifact) => !isRecord(artifact) || artifact.path !== "diagnostics.json"),
|
|
233
|
+
{
|
|
234
|
+
path: "diagnostics.json",
|
|
235
|
+
type: "json",
|
|
236
|
+
label: "Run-scoped application diagnostics",
|
|
237
|
+
category: "diagnostic"
|
|
238
|
+
}
|
|
239
|
+
];
|
|
240
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
241
|
+
`);
|
|
242
|
+
}
|
|
243
|
+
function indexDiagnosticSummary(summaryPath, diagnostics) {
|
|
244
|
+
const summary = readJsonRecord(summaryPath);
|
|
245
|
+
if (!summary) return;
|
|
246
|
+
summary.sideFindings = {
|
|
247
|
+
status: diagnostics.status,
|
|
248
|
+
nonBlocking: true,
|
|
249
|
+
counts: diagnostics.counts,
|
|
250
|
+
diagnosticsPath: "diagnostics.json"
|
|
251
|
+
};
|
|
252
|
+
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
|
|
253
|
+
`);
|
|
254
|
+
}
|
|
255
|
+
function readJsonRecord(filePath) {
|
|
256
|
+
try {
|
|
257
|
+
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
258
|
+
return isRecord(value) ? value : null;
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function isRecord(value) {
|
|
264
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
|
+
}
|
|
266
|
+
export {
|
|
267
|
+
beginRunDiagnostics,
|
|
268
|
+
collectRunDiagnostics,
|
|
269
|
+
finishRunDiagnostics,
|
|
270
|
+
stopRunDiagnostics
|
|
271
|
+
};
|
package/docs/CLI-SPEC.md
CHANGED
|
@@ -366,6 +366,8 @@ Installs the cached Runway iOS dev client onto a prepared mobile slot. It does n
|
|
|
366
366
|
|
|
367
367
|
**Validates first (adapter-aware):** Before touching any device, `run` validates the recipe: action existence in the adapter's manifest, platform support for each action, and fixture preconditions. Validation errors exit 5 with a structured error list. `--plan` stops here — prints the plan and exits, no device touched. Without `--plan`, validation failures are fatal before any execution begins.
|
|
368
368
|
|
|
369
|
+
**Run-scoped diagnostics:** Mobile combines its bounded in-app issue buffer with application-log bytes appended while the recipe executes; Extension owns a CDP console subscription for the run. The bounded, redacted result is written to `diagnostics.json`, indexed as a diagnostic artifact, and summarized as non-blocking `sideFindings`; it never changes recipe pass/fail and does not claim causality. Core is headless, so this is N/A.
|
|
370
|
+
|
|
369
371
|
> **Wave-2 status:** adapter-aware validation (`validateRecipeDocument` +
|
|
370
372
|
> `validateRecipeWithManifest`, shared helper `validateRecipeAdapterAware`) is
|
|
371
373
|
> wired for **`run --plan`** and **`call`** at the pinned deps (`@farmslot/protocol`
|
|
@@ -559,9 +561,9 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
|
|
|
559
561
|
|
|
560
562
|
### `actions` (ROUTES-NOW → DISCOVER verb)
|
|
561
563
|
|
|
562
|
-
**Synopsis:** `mm-harness actions --adapter <p> [--json] [--
|
|
564
|
+
**Synopsis:** `mm-harness actions --adapter <p> [--json] [--categories | --category <name> | --action <name>]`
|
|
563
565
|
|
|
564
|
-
**PRIMARY (agent):** `mm-harness actions --adapter mobile --json`
|
|
566
|
+
**PRIMARY (agent):** `mm-harness actions --adapter mobile --categories --json`, then one `--category` or `--action` query.
|
|
565
567
|
|
|
566
568
|
**`--json` output shape** (grounded — `actions --adapter core --json` confirmed):
|
|
567
569
|
```json
|
|
@@ -571,6 +573,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
|
|
|
571
573
|
{
|
|
572
574
|
"name": "metamask.perps.read_positions",
|
|
573
575
|
"kind": "custom",
|
|
576
|
+
"category": "perps",
|
|
574
577
|
"description": "Read live Perps positions...",
|
|
575
578
|
"fields": ["account", "action", "market", "markets", "mode", "selector", "side", "symbol", "symbols", "timeout_ms"],
|
|
576
579
|
"examples": [{ "node": { "action": "metamask.perps.read_positions", "account": "...", "symbol": "BTC" } }]
|
|
@@ -578,7 +581,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
|
|
|
578
581
|
]
|
|
579
582
|
}
|
|
580
583
|
```
|
|
581
|
-
`kind` is `"official"` (engine built-ins) or `"custom"` (MetaMask adapter actions). `fields` lists every accepted parameter name. `examples[].node` is a copy-pasteable recipe node.
|
|
584
|
+
`kind` is `"official"` (engine built-ins) or `"custom"` (MetaMask adapter actions). `category` is derived from the durable action namespace (`ui.*` → `ui`, `metamask.perps.*` → `perps`). `fields` lists every accepted parameter name. `examples[].node` is a copy-pasteable recipe node.
|
|
582
585
|
|
|
583
586
|
**Human form:** `mm-harness actions --adapter mobile` — one line per action: `<name> (<kind>) <description> fields=<f1,f2,...>`.
|
|
584
587
|
|
|
@@ -586,12 +589,13 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
|
|
|
586
589
|
|---|---|---|---|---|---|
|
|
587
590
|
| `--adapter <p>` | mobile\|extension\|core | auto-detect | `RECIPE_HARNESS_PLATFORM` | both | Target adapter · [DEFAULT-GAP] required today |
|
|
588
591
|
| `--action <name>` | string | all | — | agent | Filter to one action; full schema + all examples |
|
|
589
|
-
| `--
|
|
592
|
+
| `--categories` | bool | false | — | agent | Return only sorted category names and action counts |
|
|
593
|
+
| `--category <name>` | string | all | — | agent | Return actions from one namespace-derived category |
|
|
590
594
|
| `--action-manifest <path>` | path | bundled | — | agent | Override manifest |
|
|
591
595
|
| `--raw` | bool | false | — | agent | Dump the underlying action manifest JSON (protocol version, registry version, all entries in raw registry format — same output as `manifest --json` today; replaces the retired `manifest` verb) |
|
|
592
596
|
| `--json` | bool | false | — | **agent PRIMARY** | Full schema + fields + examples per action |
|
|
593
597
|
|
|
594
|
-
**[DISCOVERY-GAP]:** No
|
|
598
|
+
**[DISCOVERY-GAP]:** No keyword search across descriptions or fields. Flow discovery remains separate.
|
|
595
599
|
**Exit:** 0 / non-zero on engine error.
|
|
596
600
|
**Maps-to:** A:`actions` (ROUTES-NOW); C/D:`actions` (ABSORB-LATER).
|
|
597
601
|
|
package/docs/MENTAL-MODEL.md
CHANGED
|
@@ -189,14 +189,14 @@ The composition loop: `actions --json` → `call <action>` (try one via real eng
|
|
|
189
189
|
| | |
|
|
190
190
|
|---|---|
|
|
191
191
|
| **Human form** | `mm-harness actions --adapter mobile` · `mm-harness call unlock` · `mm-harness flows` |
|
|
192
|
-
| **Agent PRIMARY** | `mm-harness actions --adapter mobile --json
|
|
192
|
+
| **Agent PRIMARY** | `mm-harness actions --adapter mobile --categories --json`, then `--category <name>` or one focused `--action <name>` schema |
|
|
193
193
|
| | `mm-harness call metamask.perps.read_positions --arg symbol=BTC --adapter core --json` → `{ action, resolvedAction, args, trace, evidence, recovered, mutations, exitCode, schemaVersion }` (same shape as `run --json`, one-node subset) |
|
|
194
194
|
| | `mm-harness flows --json` → `{ sources, flows: [{ ref, source, file, description?, requiredParams?, shadows?, lastVerified? }] }` |
|
|
195
195
|
| **Plan before run** | `mm-harness run recipe.json --plan --json` → `{ plan[], validation: { status, findings }, schemaVersion }` (adapter-aware; exit 5 on errors) |
|
|
196
196
|
|
|
197
197
|
| Today (real) | → mm-harness | Status |
|
|
198
198
|
|---|---|---|
|
|
199
|
-
| `metamask-recipe actions --adapter …` | `mm-harness actions --adapter …` | ROUTES-NOW ·
|
|
199
|
+
| `metamask-recipe actions --adapter …` | `mm-harness actions --adapter …` | ROUTES-NOW · auto-detects in a checkout · bounded category/action views shipped · [DISCOVERY-GAP] no keyword search |
|
|
200
200
|
| `metamask-recipe flows list\|promote` | `mm-harness flows [\|promote]` | ROUTES-NOW · `flows` = browse the reusable flow library (compose, don't rewrite); `promote` publishes a proven flow up a tier · bare `flows` = list · [DISCOVERY-GAP] no keyword filter, no per-flow body fetch, no platform filter |
|
|
201
201
|
| `mm-recipe`/`mme-recipe` `actions`/`doctor` (auto adapter) | same `mm-harness` verbs | ABSORB-LATER — porcelain supplies missing adapter default |
|
|
202
202
|
| hook layer `run-action app.unlock` | `mm-harness call unlock` (fuzzy: `unlock` → `metamask.wallet.unlock`) — **one-node recipe via real engine path** | REAL (wave 2) · ambiguous → exit 2 listing candidates |
|