@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 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
- printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
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, spawnSync } from "node:child_process";
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 fd = fs.openSync(logPath, "w");
247
- try {
248
- const result = spawnSync(command, args, {
249
- cwd: options.cwd,
250
- env: options.env ?? process.env,
251
- stdio: ["ignore", fd, fd],
252
- ...options.timeoutMs === void 0 ? {} : { timeout: options.timeoutMs }
253
- });
254
- if (result.error) {
255
- const timedOut = result.error.code === "ETIMEDOUT";
256
- fs.writeSync(
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 !== "fail" && portHolder.listening) {
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
- const liveSmokeCode = spawnToLog(
450
- path.join(artifacts, "logs/runner-live-smoke.log"),
451
- runnerBin,
452
- [
453
- "run",
454
- liveSmokeRecipe,
455
- "--adapter",
456
- "mobile",
457
- "--target",
458
- target,
459
- ...deviceArgs,
460
- "--watcher-port",
461
- port,
462
- "--artifacts-dir",
463
- path.join(artifacts, "runner-live-smoke"),
464
- "--json"
465
- ],
466
- {
467
- cwd: target,
468
- env: {
469
- ...process.env,
470
- METAMASK_RECIPE_AUTO_HUD: "0",
471
- IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
472
- ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
473
- ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
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,
@@ -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
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
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
- const token = inheritedToken || randomUUID();
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
- if (!inheritedToken) process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = token;
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) fs.rmSync(lockPath, { force: true });
31
- if (!inheritedToken && process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN === token) {
32
- delete process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
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" && processAlive(owner.pid)) {
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" && processAlive(owner.pid)) {
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" && processAlive(claimedOwner.pid)) {
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
  };
@@ -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);
@@ -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 file = commandJournalPath(target, runtimeDir);
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 file = commandJournalPath(target, runtimeDir);
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- return path.resolve(
179
- optionValue(argv, "--target") ?? process.cwd()
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