@deeeed/metamask-harness 0.67.1 → 0.68.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 +10 -0
- package/adapters/mobile/metro-config.cjs +4 -0
- package/adapters/mobile/start-metro.sh +14 -3
- package/dist/adapters/mobile/prepare.js +5 -3
- package/dist/adapters/mobile/runtime-decision.js +3 -1
- package/dist/adapters/mobile/verify.js +56 -102
- package/dist/checkout-lock.js +62 -10
- package/dist/command-contract.js +1 -0
- package/dist/command-journal.js +52 -7
- package/dist/commands/checklist.js +13 -2
- package/dist/commands/fixtures.js +4 -5
- package/dist/commands/prepare.js +25 -4
- package/dist/commands/run-engine.js +8 -2
- package/dist/commands/runtime-launch.js +19 -7
- package/dist/commands/shared.js +48 -21
- package/dist/commands/status-watch.js +23 -0
- package/dist/execution-provenance.js +1 -1
- package/dist/harness.js +7 -11
- package/dist/json-stream.js +3 -0
- package/dist/live-adapter-contract.js +5 -0
- package/dist/mm-harness-cli.js +3 -1
- package/dist/task-view.js +8 -1
- package/docs/CONTRIBUTING.md +37 -0
- package/library/actions/mobile/ui/native-navigation.mjs +18 -6
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.68.0 - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Verify Mobile readiness with one combined status/bridge recipe, retaining device-discovery retries and RPC checks; recognize Lite screens when the release hides the Pro toggle.
|
|
10
|
+
- Restart Metro when build environment inputs change and select the matching transform-cache generation without clearing caches.
|
|
11
|
+
- Forward child checklist progress through the public CLI while retaining the owning step's diff-validation gate.
|
|
12
|
+
- Keep operation and checkout ownership records readable under restrictive umasks, and exclude observation updates from product provenance.
|
|
13
|
+
- Retain command-specific logs and stage observations in `status --watch`; preserve nested operation and checkout ownership across long builds. Pin protocol 0.33.0, recipe-harness 0.21.1, agent-runtime 0.15.1 and expo-recipe 0.12.2 for a consistent runtime dependency tree.
|
|
14
|
+
|
|
5
15
|
## 0.67.1 - 2026-09-29
|
|
6
16
|
|
|
7
17
|
### Fixed
|
|
@@ -84,8 +84,12 @@ module.exports = async function harnessMetroConfig(baseConfig) {
|
|
|
84
84
|
`^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
|
|
85
85
|
blockListFlags(existingBlockList),
|
|
86
86
|
);
|
|
87
|
+
// Inline environment values affect transformed JavaScript. Keep separate
|
|
88
|
+
// cache generations for those inputs instead of deleting the shared cache.
|
|
89
|
+
const envFingerprint = process.env.MM_HARNESS_METRO_ENV_FINGERPRINT;
|
|
87
90
|
return {
|
|
88
91
|
...productConfig,
|
|
92
|
+
...(envFingerprint ? { cacheVersion: `${productConfig.cacheVersion ?? ''}:mm-env:${envFingerprint}` } : {}),
|
|
89
93
|
resolver: {
|
|
90
94
|
...productConfig.resolver,
|
|
91
95
|
blockList: appendBlockList(existingBlockList, tempPattern),
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
# Inputs:
|
|
11
11
|
# --target <metamask-mobile dir> (default $PWD)
|
|
12
12
|
# --port <number> (default: WATCHER_PORT env, else METRO_PORT, else 8081)
|
|
13
|
+
# --restart restart Metro while retaining its transform cache
|
|
13
14
|
# --clear reset Metro bundler cache before starting
|
|
14
15
|
# Outputs:
|
|
15
16
|
# Progress on stderr; exit 0 Metro ready; 1 failed to start; 2 bad args.
|
|
@@ -20,16 +21,19 @@ set -uo pipefail
|
|
|
20
21
|
TARGET="$PWD"
|
|
21
22
|
PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
|
|
22
23
|
CLEAR=false
|
|
24
|
+
RESTART=false
|
|
23
25
|
|
|
24
26
|
while [ "$#" -gt 0 ]; do
|
|
25
27
|
case "$1" in
|
|
26
28
|
--target) TARGET="$2"; shift 2 ;;
|
|
27
29
|
--port) PORT="$2"; shift 2 ;;
|
|
28
30
|
--clear) CLEAR=true; shift ;;
|
|
31
|
+
--restart) RESTART=true; shift ;;
|
|
29
32
|
-h|--help)
|
|
30
|
-
printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--clear]\n'
|
|
33
|
+
printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--restart] [--clear]\n'
|
|
31
34
|
printf ' --target MetaMask Mobile checkout directory\n'
|
|
32
35
|
printf ' --port Metro port (default: WATCHER_PORT env or 8081)\n'
|
|
36
|
+
printf ' --restart restart while preserving the transform cache\n'
|
|
33
37
|
printf ' --clear restart with cleared cache\n'
|
|
34
38
|
exit 0
|
|
35
39
|
;;
|
|
@@ -60,6 +64,9 @@ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
|
|
|
60
64
|
export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
|
|
61
65
|
export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
|
|
62
66
|
MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
|
|
67
|
+
if [ -n "${MM_HARNESS_METRO_ENV_FINGERPRINT:-}" ]; then
|
|
68
|
+
MOBILE_METRO_REQUIRED_ENV="$MOBILE_METRO_REQUIRED_ENV MM_HARNESS_METRO_ENV_FINGERPRINT"
|
|
69
|
+
fi
|
|
63
70
|
|
|
64
71
|
# RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
|
|
65
72
|
# sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
|
|
@@ -212,8 +219,12 @@ fi
|
|
|
212
219
|
|
|
213
220
|
# Decide whether to (re)start or skip.
|
|
214
221
|
if metro_ready; then
|
|
215
|
-
if [ "$CLEAR" = true ]; then
|
|
216
|
-
|
|
222
|
+
if [ "$CLEAR" = true ] || [ "$RESTART" = true ]; then
|
|
223
|
+
if [ "$CLEAR" = true ]; then
|
|
224
|
+
printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
|
|
225
|
+
else
|
|
226
|
+
printf 'Restarting Metro on port %s with its transform cache preserved\n' "$PORT" >&2
|
|
227
|
+
fi
|
|
217
228
|
stop_metro_listener || exit 1
|
|
218
229
|
rm -f "$PID_FILE"
|
|
219
230
|
elif metro_listener_valid; then
|
|
@@ -270,7 +270,8 @@ async function dispatchActionSequence(requestedActions, target, platform, json,
|
|
|
270
270
|
platform,
|
|
271
271
|
json,
|
|
272
272
|
preflightMode,
|
|
273
|
-
watcherPort
|
|
273
|
+
watcherPort,
|
|
274
|
+
pendingMetroEnvFingerprint
|
|
274
275
|
);
|
|
275
276
|
if (result.status !== 0) return result;
|
|
276
277
|
if (action.id === "yarn-setup") recordDepsBaseline(resolved);
|
|
@@ -378,7 +379,7 @@ function enableIosAccessibility(json) {
|
|
|
378
379
|
return false;
|
|
379
380
|
}
|
|
380
381
|
}
|
|
381
|
-
async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort) {
|
|
382
|
+
async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort, metroEnvFingerprint) {
|
|
382
383
|
const cwd = action.cwd ?? target;
|
|
383
384
|
switch (action.id) {
|
|
384
385
|
case "yarn-setup": {
|
|
@@ -396,7 +397,8 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
|
|
|
396
397
|
return spawnScriptStreaming(
|
|
397
398
|
leaf,
|
|
398
399
|
withWatcherPort(["--target", cwd, ...extra], watcherPort),
|
|
399
|
-
target
|
|
400
|
+
target,
|
|
401
|
+
{ MM_HARNESS_METRO_ENV_FINGERPRINT: metroEnvFingerprint ?? mobileMetroEnvCheck(path.resolve(target)).fingerprint }
|
|
400
402
|
);
|
|
401
403
|
}
|
|
402
404
|
case "start-console-forwarder": {
|
|
@@ -184,7 +184,9 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
184
184
|
env.status === "missing" ? "Metro environment has no successful bundle baseline; restart Metro to load the current environment without clearing its transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart Metro without clearing its transform cache. Pass --clear-metro only if stale transforms are observed.",
|
|
185
185
|
...report.reasons
|
|
186
186
|
],
|
|
187
|
-
actions: report.decision === "ready" ? launchActions(resolved) : report.actions
|
|
187
|
+
actions: (report.decision === "ready" ? launchActions(resolved) : report.actions).map(
|
|
188
|
+
(action) => action.id === "start-metro" && !action.argv?.includes("--clear") && !action.argv?.includes("--restart") ? { ...action, argv: [...action.argv ?? [], "--restart"] } : action
|
|
189
|
+
)
|
|
188
190
|
};
|
|
189
191
|
}
|
|
190
192
|
function writeDepsNote(resolved) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFileSync
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { spawnScriptStreaming } from "../../commands/shared.js";
|
|
5
6
|
import { readRuntimeContextField } from "../../harness.js";
|
|
6
7
|
import { recipeHarnessPath, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "../../paths.js";
|
|
7
8
|
const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
|
|
@@ -11,19 +12,6 @@ const AUTO_START_TRUE = ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on",
|
|
|
11
12
|
const AUTO_START_FALSE = ["0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", ""];
|
|
12
13
|
const AUTO_START_REFUSAL = "Mobile auto-start is not allowed from product-local scripts. Start or prepare the app through the runner/slot runtime, then rerun verify with --no-auto-start.";
|
|
13
14
|
const EVM_READINESS_EXPRESSION = "(async function(){try{var engine=globalThis.Engine;var controller=engine&&engine.context&&engine.context.NetworkController;if(!controller||typeof controller.getSelectedNetworkClient!=='function')throw new Error('NetworkController selected client is unavailable');var client=controller.getSelectedNetworkClient();if(!client||!client.provider||typeof client.provider.request!=='function')throw new Error('selected EVM provider is unavailable');var result=await client.provider.request({method:'eth_blockNumber'});if(typeof result!=='string'||!/^0x[0-9a-f]+$/i.test(result))throw new Error('eth_blockNumber returned an invalid result');return {ok:true,result:result};}catch(error){return {ok:false,error:error&&(error.message||String(error))};}})()";
|
|
14
|
-
const STATUS_SMOKE_RECIPE = `{
|
|
15
|
-
"$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
|
|
16
|
-
"title": "Mobile runner status smoke",
|
|
17
|
-
"description": "Checks that the installed runner can read Mobile app status before live verification.",
|
|
18
|
-
"workflow": {
|
|
19
|
-
"entry": "status",
|
|
20
|
-
"nodes": {
|
|
21
|
-
"status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
|
|
22
|
-
"done": { "action": "end", "status": "pass" }
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
`;
|
|
27
15
|
const LIVE_SMOKE_RECIPE = `{
|
|
28
16
|
"$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
|
|
29
17
|
"title": "Mobile v1 runner live bridge smoke",
|
|
@@ -241,30 +229,21 @@ function mobilePortHolder(port) {
|
|
|
241
229
|
note: "HTTP /status probing is skipped during live verify because the React Native bridge is the authoritative readiness path for Mobile."
|
|
242
230
|
};
|
|
243
231
|
}
|
|
244
|
-
function spawnToLog(logPath, command, args, options = {}) {
|
|
232
|
+
async function spawnToLog(logPath, command, args, options = {}) {
|
|
245
233
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
fd,
|
|
258
|
-
timedOut ? `Timed out after ${(options.timeoutMs ?? 0) / 1e3}s: ${[command, ...args].join(" ")}
|
|
259
|
-
` : `${result.error.message}
|
|
260
|
-
`
|
|
261
|
-
);
|
|
262
|
-
return timedOut ? 124 : 127;
|
|
263
|
-
}
|
|
264
|
-
return result.status ?? 1;
|
|
265
|
-
} finally {
|
|
266
|
-
fs.closeSync(fd);
|
|
234
|
+
const result = await spawnScriptStreaming(command, args, options.cwd ?? process.cwd(), {
|
|
235
|
+
env: Object.fromEntries(Object.entries(options.env ?? process.env).filter((entry) => entry[1] !== void 0)),
|
|
236
|
+
timeoutMs: options.timeoutMs,
|
|
237
|
+
forward: "none",
|
|
238
|
+
stage: path.basename(logPath, ".log")
|
|
239
|
+
});
|
|
240
|
+
fs.writeFileSync(logPath, result.output);
|
|
241
|
+
if (result.error) {
|
|
242
|
+
fs.appendFileSync(logPath, `${result.error.message}
|
|
243
|
+
`);
|
|
244
|
+
return 127;
|
|
267
245
|
}
|
|
246
|
+
return result.timedOut ? 124 : result.status;
|
|
268
247
|
}
|
|
269
248
|
function readJsonFile(file) {
|
|
270
249
|
try {
|
|
@@ -372,42 +351,6 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
372
351
|
deviceTarget = platform === "android" ? resolveJsEnvValue(target, "ADB_SERIAL") || resolveJsEnvValue(target, "ANDROID_DEVICE") : resolveJsEnvValue(target, "IOS_SIMULATOR");
|
|
373
352
|
}
|
|
374
353
|
const deviceArgs = deviceTarget ? ["--device", deviceTarget] : [];
|
|
375
|
-
const statusSmokeRecipe = path.join(artifacts, "mobile-status-smoke.recipe.json");
|
|
376
|
-
const precheckLog = path.join(artifacts, "logs/app-status-precheck.log");
|
|
377
|
-
let liveRuntimeOk = false;
|
|
378
|
-
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
379
|
-
fs.writeFileSync(statusSmokeRecipe, STATUS_SMOKE_RECIPE);
|
|
380
|
-
const code = spawnToLog(precheckLog, runnerBin, [
|
|
381
|
-
"run",
|
|
382
|
-
statusSmokeRecipe,
|
|
383
|
-
"--adapter",
|
|
384
|
-
"mobile",
|
|
385
|
-
"--target",
|
|
386
|
-
target,
|
|
387
|
-
...deviceArgs,
|
|
388
|
-
"--watcher-port",
|
|
389
|
-
port,
|
|
390
|
-
"--artifacts-dir",
|
|
391
|
-
path.join(artifacts, "runner-status-smoke"),
|
|
392
|
-
"--json"
|
|
393
|
-
], { cwd: target });
|
|
394
|
-
if (code === 0) {
|
|
395
|
-
liveRuntimeOk = true;
|
|
396
|
-
break;
|
|
397
|
-
}
|
|
398
|
-
const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
|
|
399
|
-
fs.readFileSync(precheckLog, "utf8")
|
|
400
|
-
);
|
|
401
|
-
if (attempt < 5 && deviceNotFound) {
|
|
402
|
-
io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
|
|
403
|
-
await sleep(1e3);
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
if (!liveRuntimeOk && autoStart) io.err(AUTO_START_REFUSAL);
|
|
409
|
-
checks.push(liveRuntimeOk ? { name: "mobile runtime controllable precheck", status: "pass" } : { name: "mobile runtime controllable precheck", status: "fail", detail: "see logs/app-status-precheck.log" });
|
|
410
|
-
if (!liveRuntimeOk) status = "fail";
|
|
411
354
|
fixtureStatus = mobileFixtureStatus(target);
|
|
412
355
|
fs.writeFileSync(path.join(artifacts, "logs/fixture-status.json"), `${JSON.stringify(fixtureStatus)}
|
|
413
356
|
`);
|
|
@@ -423,12 +366,12 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
423
366
|
portHolder = mobilePortHolder(port);
|
|
424
367
|
fs.writeFileSync(path.join(artifacts, "logs/port-holder.json"), `${JSON.stringify(portHolder)}
|
|
425
368
|
`);
|
|
426
|
-
if (status
|
|
369
|
+
if (!checks.some((check) => check.status === "fail") && portHolder.listening) {
|
|
427
370
|
const waiter = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
|
|
428
371
|
if (!fs.existsSync(waiter)) {
|
|
429
372
|
addNote("Mobile bridge settle skipped: wait-for-bridge.sh not found under the runner adapters; reinstall the runner.");
|
|
430
373
|
} else {
|
|
431
|
-
const waiterCode = spawnToLog(
|
|
374
|
+
const waiterCode = await spawnToLog(
|
|
432
375
|
path.join(artifacts, "logs/wait-for-bridge.log"),
|
|
433
376
|
"bash",
|
|
434
377
|
[waiter, "--target", target, "--port", port, "--platform", platform],
|
|
@@ -446,34 +389,45 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
446
389
|
fs.writeFileSync(liveSmokeRecipe, LIVE_SMOKE_RECIPE);
|
|
447
390
|
const iosSimulatorResolved = resolveJsEnvValue(target, "IOS_SIMULATOR");
|
|
448
391
|
const adbSerialResolved = resolveJsEnvValue(target, "ADB_SERIAL");
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
392
|
+
let liveSmokeCode = 1;
|
|
393
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
394
|
+
liveSmokeCode = await spawnToLog(
|
|
395
|
+
path.join(artifacts, "logs/runner-live-smoke.log"),
|
|
396
|
+
runnerBin,
|
|
397
|
+
[
|
|
398
|
+
"run",
|
|
399
|
+
liveSmokeRecipe,
|
|
400
|
+
"--adapter",
|
|
401
|
+
"mobile",
|
|
402
|
+
"--target",
|
|
403
|
+
target,
|
|
404
|
+
...deviceArgs,
|
|
405
|
+
"--watcher-port",
|
|
406
|
+
port,
|
|
407
|
+
"--artifacts-dir",
|
|
408
|
+
path.join(artifacts, "runner-live-smoke"),
|
|
409
|
+
"--json"
|
|
410
|
+
],
|
|
411
|
+
{
|
|
412
|
+
cwd: target,
|
|
413
|
+
env: {
|
|
414
|
+
...process.env,
|
|
415
|
+
METAMASK_RECIPE_AUTO_HUD: "0",
|
|
416
|
+
IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
|
|
417
|
+
ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
|
|
418
|
+
ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
|
|
419
|
+
}
|
|
474
420
|
}
|
|
475
|
-
|
|
476
|
-
|
|
421
|
+
);
|
|
422
|
+
if (liveSmokeCode === 0) break;
|
|
423
|
+
const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
|
|
424
|
+
fs.readFileSync(path.join(artifacts, "logs/runner-live-smoke.log"), "utf8")
|
|
425
|
+
);
|
|
426
|
+
if (!deviceNotFound || attempt === 5) break;
|
|
427
|
+
io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
|
|
428
|
+
await sleep(1e3);
|
|
429
|
+
}
|
|
430
|
+
if (liveSmokeCode !== 0 && autoStart) io.err(AUTO_START_REFUSAL);
|
|
477
431
|
if (liveSmokeCode === 0) {
|
|
478
432
|
checks.push({ name: "runner v1 live bridge smoke", status: "pass" });
|
|
479
433
|
} else {
|
|
@@ -488,7 +442,7 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
488
442
|
fs.writeFileSync(evmLog, `Mobile bridge is missing at ${bridge}
|
|
489
443
|
`);
|
|
490
444
|
} else {
|
|
491
|
-
const evmCode = spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
|
|
445
|
+
const evmCode = await spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
|
|
492
446
|
timeoutMs: 25e3,
|
|
493
447
|
env: {
|
|
494
448
|
...process.env,
|
package/dist/checkout-lock.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureRuntimeDirectory, processIdentity } from "@farmslot/recipe-harness/runtime/operation";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -5,15 +6,19 @@ import { recipeRuntimeDir } from "./paths.js";
|
|
|
5
6
|
function acquireCheckoutLock(target, operation) {
|
|
6
7
|
const repoRoot = fs.realpathSync(target);
|
|
7
8
|
const lockPath = path.join(repoRoot, recipeRuntimeDir(), "sandbox.lock");
|
|
8
|
-
|
|
9
|
+
ensureRuntimeDirectory(path.dirname(lockPath));
|
|
9
10
|
const inheritedToken = process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
|
|
10
11
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
11
12
|
try {
|
|
13
|
+
const identity = processIdentity(process.pid);
|
|
14
|
+
if (!identity) throw new Error("Cannot identify checkout lock owner process");
|
|
12
15
|
const fd = fs.openSync(lockPath, "wx", 384);
|
|
13
|
-
|
|
16
|
+
fs.fchmodSync(fd, 384);
|
|
17
|
+
const token = randomUUID();
|
|
14
18
|
const owner = {
|
|
15
19
|
schemaVersion: 1,
|
|
16
20
|
pid: process.pid,
|
|
21
|
+
processStartedAt: identity,
|
|
17
22
|
operation,
|
|
18
23
|
repoRoot,
|
|
19
24
|
token,
|
|
@@ -22,30 +27,38 @@ function acquireCheckoutLock(target, operation) {
|
|
|
22
27
|
fs.writeFileSync(fd, `${JSON.stringify(owner, null, 2)}
|
|
23
28
|
`);
|
|
24
29
|
fs.closeSync(fd);
|
|
25
|
-
|
|
30
|
+
process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = token;
|
|
26
31
|
return {
|
|
27
32
|
path: lockPath,
|
|
28
33
|
release: () => {
|
|
29
34
|
const current = readOwner(lockPath);
|
|
30
|
-
if (current?.pid === process.pid && current.token === token
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
if (current?.pid === process.pid && current.token === token && !childrenAlive(current)) {
|
|
36
|
+
fs.rmSync(lockPath, { force: true });
|
|
37
|
+
fs.rmSync(path.join(path.dirname(lockPath), "lock-members", token), { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
if (process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN === token) {
|
|
40
|
+
if (inheritedToken) process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = inheritedToken;
|
|
41
|
+
else delete process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
44
|
};
|
|
36
45
|
} catch (error) {
|
|
37
46
|
if (error.code !== "EEXIST") throw error;
|
|
38
47
|
const owner = readOwner(lockPath);
|
|
39
|
-
if (inheritedToken && owner?.token === inheritedToken && owner.repoRoot === repoRoot && typeof owner.pid === "number" &&
|
|
48
|
+
if (inheritedToken && owner?.token === inheritedToken && owner.repoRoot === repoRoot && typeof owner.pid === "number" && ownerAlive(owner)) {
|
|
49
|
+
trackCheckoutChild(repoRoot, process.pid);
|
|
40
50
|
return { path: lockPath, release: () => void 0 };
|
|
41
51
|
}
|
|
42
|
-
if (owner && typeof owner.pid === "number" &&
|
|
52
|
+
if (owner && typeof owner.pid === "number" && ownerAlive(owner)) {
|
|
43
53
|
return {
|
|
44
54
|
path: lockPath,
|
|
45
55
|
owner,
|
|
46
56
|
message: `checkout sandbox is busy with ${String(owner.operation ?? "another operation")} (pid ${owner.pid}).`
|
|
47
57
|
};
|
|
48
58
|
}
|
|
59
|
+
if (!owner || childrenAlive(owner)) {
|
|
60
|
+
return { path: lockPath, owner, message: `checkout sandbox owner is unreadable or has surviving child processes. Inspect ${lockPath} and its lock-members directory; remove the lock only after verifying all recorded processes have exited.` };
|
|
61
|
+
}
|
|
49
62
|
claimStaleLock(lockPath, owner);
|
|
50
63
|
}
|
|
51
64
|
}
|
|
@@ -61,12 +74,14 @@ function claimStaleLock(lockPath, expectedOwner) {
|
|
|
61
74
|
}
|
|
62
75
|
try {
|
|
63
76
|
const claimedOwner = readOwner(claimedPath);
|
|
64
|
-
if (!sameOwner(claimedOwner, expectedOwner) || claimedOwner && typeof claimedOwner.pid === "number" &&
|
|
77
|
+
if (!sameOwner(claimedOwner, expectedOwner) || claimedOwner && typeof claimedOwner.pid === "number" && (ownerAlive(claimedOwner) || childrenAlive(claimedOwner))) {
|
|
65
78
|
try {
|
|
66
79
|
fs.linkSync(claimedPath, lockPath);
|
|
67
80
|
} catch (error) {
|
|
68
81
|
if (error.code !== "EEXIST") throw error;
|
|
69
82
|
}
|
|
83
|
+
} else if (typeof claimedOwner?.token === "string" && /^[a-f0-9-]{36}$/.test(claimedOwner.token)) {
|
|
84
|
+
fs.rmSync(path.join(path.dirname(lockPath), "lock-members", claimedOwner.token), { recursive: true, force: true });
|
|
70
85
|
}
|
|
71
86
|
} finally {
|
|
72
87
|
fs.rmSync(claimedPath, { force: true });
|
|
@@ -84,6 +99,42 @@ function readOwner(lockPath) {
|
|
|
84
99
|
return null;
|
|
85
100
|
}
|
|
86
101
|
}
|
|
102
|
+
function ownerAlive(owner) {
|
|
103
|
+
if (typeof owner.pid !== "number") return true;
|
|
104
|
+
if (typeof owner.processStartedAt === "string") return processIdentity(owner.pid) === owner.processStartedAt;
|
|
105
|
+
return processAlive(owner.pid);
|
|
106
|
+
}
|
|
107
|
+
function childrenAlive(owner) {
|
|
108
|
+
if (typeof owner.repoRoot !== "string" || typeof owner.token !== "string" || !/^[a-f0-9-]{36}$/.test(owner.token)) return false;
|
|
109
|
+
const directory = path.join(owner.repoRoot, recipeRuntimeDir(), "lock-members", owner.token);
|
|
110
|
+
if (!fs.existsSync(directory)) return false;
|
|
111
|
+
return fs.readdirSync(directory).filter((name) => name.endsWith(".json")).some((name) => {
|
|
112
|
+
const child = readOwner(path.join(directory, name));
|
|
113
|
+
if (child && typeof child.processGroupId === "number" && child.processGroupId > 0) return processAlive(-child.processGroupId);
|
|
114
|
+
return !child || typeof child.pid !== "number" || typeof child.processStartedAt !== "string" || child.pid !== owner.pid && processIdentity(child.pid) === child.processStartedAt;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function trackCheckoutChild(target, pid, ownsProcessGroup = false) {
|
|
118
|
+
const repoRoot = fs.realpathSync(target);
|
|
119
|
+
const lockPath = path.join(repoRoot, recipeRuntimeDir(), "sandbox.lock");
|
|
120
|
+
const owner = readOwner(lockPath);
|
|
121
|
+
if (!owner || owner.token !== process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN || typeof owner.token !== "string" || !/^[a-f0-9-]{36}$/.test(owner.token)) return () => void 0;
|
|
122
|
+
const identity = processIdentity(pid);
|
|
123
|
+
if (!identity && !ownsProcessGroup) return () => void 0;
|
|
124
|
+
const directory = path.join(repoRoot, recipeRuntimeDir(), "lock-members", owner.token);
|
|
125
|
+
ensureRuntimeDirectory(directory);
|
|
126
|
+
const file = path.join(directory, `${pid}.json`);
|
|
127
|
+
const previous = readOwner(file);
|
|
128
|
+
const group = ownsProcessGroup || previous?.processStartedAt === identity && previous?.processGroupId === pid;
|
|
129
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
130
|
+
fs.writeFileSync(temporary, JSON.stringify({ pid, processStartedAt: identity, ...group ? { processGroupId: pid } : {} }), { mode: 384 });
|
|
131
|
+
fs.chmodSync(temporary, 384);
|
|
132
|
+
fs.renameSync(temporary, file);
|
|
133
|
+
return () => {
|
|
134
|
+
const current = readOwner(file);
|
|
135
|
+
if (current?.pid === pid && current.processStartedAt === identity) fs.rmSync(file, { force: true });
|
|
136
|
+
};
|
|
137
|
+
}
|
|
87
138
|
function processAlive(pid) {
|
|
88
139
|
try {
|
|
89
140
|
process.kill(pid, 0);
|
|
@@ -93,5 +144,6 @@ function processAlive(pid) {
|
|
|
93
144
|
}
|
|
94
145
|
}
|
|
95
146
|
export {
|
|
96
|
-
acquireCheckoutLock
|
|
147
|
+
acquireCheckoutLock,
|
|
148
|
+
trackCheckoutChild
|
|
97
149
|
};
|
package/dist/command-contract.js
CHANGED
|
@@ -425,6 +425,7 @@ function validatePublicInvocation(argv, examples = {}) {
|
|
|
425
425
|
}
|
|
426
426
|
const { name, contract } = resolved;
|
|
427
427
|
const tokens = argv.slice(1);
|
|
428
|
+
if (name === "checklist" && tokens[0] === "mark" && tokens[1] && !tokens[1].startsWith("-") && tokens[2] === "sub") return null;
|
|
428
429
|
const positionals = [];
|
|
429
430
|
const seenOptions = /* @__PURE__ */ new Set();
|
|
430
431
|
const validOptions = Object.keys(contract.options);
|
package/dist/command-journal.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { OperationRecord, readOperations, processIdentity, ensureRuntimeDirectory } from "@farmslot/recipe-harness/runtime/operation";
|
|
5
|
+
import { latestTaskDir } from "@farmslot/agent-runtime";
|
|
4
6
|
import { recipeRuntimeDir } from "./paths.js";
|
|
5
7
|
const COMMAND_JOURNAL_FILE = "last-command.json";
|
|
6
8
|
const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
|
|
9
|
+
"prepare",
|
|
7
10
|
"call",
|
|
8
11
|
"check",
|
|
9
12
|
"checklist",
|
|
@@ -12,6 +15,7 @@ const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
12
15
|
"fixtures",
|
|
13
16
|
"install",
|
|
14
17
|
"launch",
|
|
18
|
+
"runtime-launch",
|
|
15
19
|
"provision",
|
|
16
20
|
"recipe-quality",
|
|
17
21
|
"run",
|
|
@@ -25,6 +29,8 @@ async function withCommandJournal(command, argv, execute) {
|
|
|
25
29
|
if (!JOURNALED_COMMANDS.has(command)) return execute();
|
|
26
30
|
const handle = tryBeginCommandJournal(command, argv);
|
|
27
31
|
const previous = activeCommandJournal;
|
|
32
|
+
const parentId = process.env.MM_HARNESS_OPERATION_ID;
|
|
33
|
+
if (handle) process.env.MM_HARNESS_OPERATION_ID = handle.operation.value.id;
|
|
28
34
|
activeCommandJournal = handle;
|
|
29
35
|
try {
|
|
30
36
|
const exitCode = await execute();
|
|
@@ -35,8 +41,16 @@ async function withCommandJournal(command, argv, execute) {
|
|
|
35
41
|
throw error;
|
|
36
42
|
} finally {
|
|
37
43
|
activeCommandJournal = previous;
|
|
44
|
+
if (parentId) process.env.MM_HARNESS_OPERATION_ID = parentId;
|
|
45
|
+
else delete process.env.MM_HARNESS_OPERATION_ID;
|
|
38
46
|
}
|
|
39
47
|
}
|
|
48
|
+
function recordCommandStage(stage) {
|
|
49
|
+
activeCommandJournal?.operation.stage(stage);
|
|
50
|
+
}
|
|
51
|
+
function recordCommandOutput(chunk) {
|
|
52
|
+
activeCommandJournal?.operation.output(chunk);
|
|
53
|
+
}
|
|
40
54
|
function recordCommandEvidence(...evidence) {
|
|
41
55
|
const handle = activeCommandJournal;
|
|
42
56
|
if (!handle || evidence.length === 0) return;
|
|
@@ -58,7 +72,12 @@ function commandJournalPath(target, runtimeDir) {
|
|
|
58
72
|
return path.join(path.resolve(target), relative, COMMAND_JOURNAL_FILE);
|
|
59
73
|
}
|
|
60
74
|
function readCommandJournal(target, runtimeDir) {
|
|
61
|
-
const
|
|
75
|
+
const legacyFile = commandJournalPath(target, runtimeDir);
|
|
76
|
+
const operations = readOperations(path.join(path.dirname(legacyFile), "operations"));
|
|
77
|
+
const live = operations.filter((operation) => operation.status === "running" && processIdentity(operation.pid) === operation.processStartedAt);
|
|
78
|
+
const roots = operations.filter((operation) => !operation.parentId).sort((a, b) => (a.finishedAt ?? a.startedAt).localeCompare(b.finishedAt ?? b.startedAt));
|
|
79
|
+
const selected = live.find((operation) => !operation.parentId) ?? live.at(-1) ?? roots.at(-1) ?? operations.at(-1);
|
|
80
|
+
const file = selected ? path.join(path.dirname(legacyFile), "operations", `${selected.id}.journal.json`) : legacyFile;
|
|
62
81
|
let descriptor;
|
|
63
82
|
try {
|
|
64
83
|
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
@@ -113,12 +132,34 @@ function redactCommandArgs(argv) {
|
|
|
113
132
|
}
|
|
114
133
|
return redacted;
|
|
115
134
|
}
|
|
135
|
+
function pruneCompletedJournals(directory, operations) {
|
|
136
|
+
const completed = operations.filter((operation) => operation.status !== "running");
|
|
137
|
+
for (const operation of completed.slice(0, -100)) {
|
|
138
|
+
for (const suffix of [".json", ".journal.json", ".log"]) {
|
|
139
|
+
fs.rmSync(path.join(directory, `${operation.id}${suffix}`), { force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
116
143
|
function tryBeginCommandJournal(command, argv) {
|
|
117
144
|
try {
|
|
118
145
|
const target = invocationTarget(argv);
|
|
119
146
|
if (!fs.existsSync(target)) return void 0;
|
|
120
147
|
const runtimeDir = optionValue(argv, "--runtime-dir");
|
|
121
|
-
const
|
|
148
|
+
const legacyFile = commandJournalPath(target, runtimeDir);
|
|
149
|
+
const taskDir = latestTaskDir(path.join(target, "temp", "tasks"));
|
|
150
|
+
const signalFile = taskDir && path.join(taskDir, "SIGNAL.json");
|
|
151
|
+
const liveTask = signalFile && fs.existsSync(signalFile) && JSON.parse(fs.readFileSync(signalFile, "utf8")).status === "running";
|
|
152
|
+
const directory = path.join(path.dirname(legacyFile), "operations");
|
|
153
|
+
const operations = readOperations(directory, (error) => process.stderr.write(`mm-harness: ignoring invalid prior operation: ${error.message}
|
|
154
|
+
`));
|
|
155
|
+
pruneCompletedJournals(directory, operations);
|
|
156
|
+
const inherited = process.env.MM_HARNESS_OPERATION_ID;
|
|
157
|
+
const parent = inherited && operations.find((operation2) => operation2.id === inherited && operation2.target === target && operation2.status === "running" && processIdentity(operation2.pid) === operation2.processStartedAt);
|
|
158
|
+
const operation = new OperationRecord(directory, command, target, {
|
|
159
|
+
...parent ? { parentId: parent.id } : {},
|
|
160
|
+
...liveTask ? { mirrorDirectory: path.join(taskDir, "artifacts", "operations") } : {}
|
|
161
|
+
});
|
|
162
|
+
const file = path.join(directory, `${operation.value.id}.journal.json`);
|
|
122
163
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
123
164
|
const record = {
|
|
124
165
|
schemaVersion: 1,
|
|
@@ -132,7 +173,7 @@ function tryBeginCommandJournal(command, argv) {
|
|
|
132
173
|
finishedAt: null
|
|
133
174
|
};
|
|
134
175
|
writeAtomic(file, record);
|
|
135
|
-
return { file, record };
|
|
176
|
+
return { file, record, operation };
|
|
136
177
|
} catch (error) {
|
|
137
178
|
process.stderr.write(
|
|
138
179
|
`mm-harness: resumability journal unavailable: ${errorMessage(error)}
|
|
@@ -155,14 +196,17 @@ function tryFinishCommandJournal(handle, exitCode) {
|
|
|
155
196
|
`mm-harness: resumability journal could not finalize: ${errorMessage(error)}
|
|
156
197
|
`
|
|
157
198
|
);
|
|
199
|
+
} finally {
|
|
200
|
+
handle.operation.finish(exitCode);
|
|
158
201
|
}
|
|
159
202
|
}
|
|
160
203
|
function writeAtomic(file, record) {
|
|
161
|
-
|
|
204
|
+
ensureRuntimeDirectory(path.dirname(file));
|
|
162
205
|
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
163
206
|
let descriptor;
|
|
164
207
|
try {
|
|
165
208
|
descriptor = fs.openSync(temporary, "wx", 384);
|
|
209
|
+
fs.fchmodSync(descriptor, 384);
|
|
166
210
|
fs.writeFileSync(descriptor, `${JSON.stringify(record, null, 2)}
|
|
167
211
|
`);
|
|
168
212
|
fs.fsyncSync(descriptor);
|
|
@@ -175,9 +219,8 @@ function writeAtomic(file, record) {
|
|
|
175
219
|
}
|
|
176
220
|
}
|
|
177
221
|
function invocationTarget(argv) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
);
|
|
222
|
+
const target = path.resolve(optionValue(argv, "--target") ?? process.cwd());
|
|
223
|
+
return fs.existsSync(target) ? fs.realpathSync(target) : target;
|
|
181
224
|
}
|
|
182
225
|
function evidencePaths(argv) {
|
|
183
226
|
const values = /* @__PURE__ */ new Set();
|
|
@@ -257,6 +300,8 @@ export {
|
|
|
257
300
|
isSensitiveKey,
|
|
258
301
|
readCommandJournal,
|
|
259
302
|
recordCommandEvidence,
|
|
303
|
+
recordCommandOutput,
|
|
304
|
+
recordCommandStage,
|
|
260
305
|
redactCommandArgs,
|
|
261
306
|
redactStructuredValue,
|
|
262
307
|
withCommandJournal
|