@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
|
@@ -12,6 +12,7 @@ const MARK_SCRIPT = require2.resolve(
|
|
|
12
12
|
const { resolveChecklistPaths } = require2(
|
|
13
13
|
"@farmslot/agent-runtime/scripts/checklist-target.cjs"
|
|
14
14
|
);
|
|
15
|
+
const { readSubtaskIndex, SubtaskRefusal } = require2("@farmslot/agent-runtime/scripts/subtask-unit.cjs");
|
|
15
16
|
const USAGE = "mm-harness checklist <mark <task-dir> <step> | closeout <task-dir>> [options]";
|
|
16
17
|
const CHECKLIST_VALUE_OPTIONS = /* @__PURE__ */ new Set([
|
|
17
18
|
"--reason",
|
|
@@ -129,10 +130,10 @@ function checklistStepRequiresDiffPass(checklist, stepNumber) {
|
|
|
129
130
|
const step = enumerateChecklistCheckboxes(checklist).find((item) => item.stepNumber === stepNumber);
|
|
130
131
|
return step !== void 0 && /mm-harness check diff/u.test(step.rawLabel);
|
|
131
132
|
}
|
|
132
|
-
function checkDiffGateReady(taskDir, step) {
|
|
133
|
+
function checkDiffGateReady(taskDir, step, parentChecklist) {
|
|
133
134
|
const stepNumber = numericMarkStep(step);
|
|
134
135
|
if (stepNumber === null) return true;
|
|
135
|
-
const
|
|
136
|
+
const taskPath = parentChecklist ? path.join(taskDir, parentChecklist) : resolveChecklistPaths(taskDir).taskPath;
|
|
136
137
|
let checklist = "";
|
|
137
138
|
try {
|
|
138
139
|
checklist = fs.readFileSync(taskPath, "utf8");
|
|
@@ -203,6 +204,16 @@ async function handleChecklist(argv) {
|
|
|
203
204
|
return EXIT.usage;
|
|
204
205
|
}
|
|
205
206
|
if (preserveTerminalSignal(resolvedTaskDir, step)) return EXIT.ok;
|
|
207
|
+
if (step === "sub" && forwarded[2] === "complete") {
|
|
208
|
+
try {
|
|
209
|
+
const unit = readSubtaskIndex(resolvedTaskDir)?.units.find((unit2) => unit2.id === forwarded[1]);
|
|
210
|
+
if (unit && !checkDiffGateReady(resolvedTaskDir, String(unit.parent.stepNumber), unit.parent.checklist)) return EXIT.runtime;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!(error instanceof SubtaskRefusal)) throw error;
|
|
213
|
+
console.error(error.message);
|
|
214
|
+
return error.code;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
206
217
|
if (!checkDiffGateReady(resolvedTaskDir, step)) return EXIT.runtime;
|
|
207
218
|
teachRecipeCompletion(resolvedTaskDir, step);
|
|
208
219
|
const result = spawnSync(
|
|
@@ -559,13 +559,12 @@ async function fixturesFinalize(adapter, target, options, json) {
|
|
|
559
559
|
}
|
|
560
560
|
return exitCode;
|
|
561
561
|
}
|
|
562
|
-
function fixturesSync(adapter, target, json) {
|
|
562
|
+
async function fixturesSync(adapter, target, json) {
|
|
563
563
|
const mmHarnessBin = path.join(runnerDir, "bin/mm-harness");
|
|
564
|
-
const installResult =
|
|
564
|
+
const installResult = await spawnScriptStreaming(
|
|
565
565
|
mmHarnessBin,
|
|
566
566
|
["install", "--adapter", adapter, "--target", target],
|
|
567
|
-
target
|
|
568
|
-
json
|
|
567
|
+
target
|
|
569
568
|
);
|
|
570
569
|
if (installResult.status !== 0) {
|
|
571
570
|
if (!json) {
|
|
@@ -578,7 +577,7 @@ function fixturesSync(adapter, target, json) {
|
|
|
578
577
|
const syncArgs = ["--target", target];
|
|
579
578
|
if (process.env.CDP_PORT) syncArgs.push("--cdp-port", process.env.CDP_PORT);
|
|
580
579
|
if (process.env.RECIPE_SLOT_ID) syncArgs.push("--slot-id", process.env.RECIPE_SLOT_ID);
|
|
581
|
-
const syncResult =
|
|
580
|
+
const syncResult = await spawnScriptStreaming(syncFixtureSh, syncArgs, target);
|
|
582
581
|
if (syncResult.status !== 0 || fixtureSummary(target, adapter).status !== "ready") {
|
|
583
582
|
if (!json) console.error("\u2717 no usable wallet fixture source was found.\n Next: mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
|
|
584
583
|
return EXIT.runtime;
|
package/dist/commands/prepare.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { acquireCheckoutLock, trackCheckoutChild } from "../checkout-lock.js";
|
|
2
|
+
import { recordCommandStage } from "../command-journal.js";
|
|
1
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
|
|
2
5
|
import fs from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import {
|
|
@@ -168,18 +171,19 @@ function spawnStep(executable, args, target) {
|
|
|
168
171
|
// stream a recorded readiness step captures.
|
|
169
172
|
env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
|
|
170
173
|
});
|
|
174
|
+
if (child.pid) trackCheckoutChild(target, child.pid);
|
|
171
175
|
let stdout = "";
|
|
172
|
-
|
|
176
|
+
const stderr = new OperationOutputTail();
|
|
173
177
|
child.stdout?.setEncoding("utf8");
|
|
174
178
|
child.stderr?.setEncoding("utf8");
|
|
175
179
|
child.stdout?.on("data", (chunk) => {
|
|
176
180
|
stdout += chunk;
|
|
177
181
|
});
|
|
178
182
|
child.stderr?.on("data", (chunk) => {
|
|
179
|
-
stderr
|
|
183
|
+
stderr.append(chunk);
|
|
180
184
|
});
|
|
181
|
-
child.on("error", (error) => resolve({ error, status: null, stdout, stderr }));
|
|
182
|
-
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
185
|
+
child.on("error", (error) => resolve({ error, status: null, stdout, stderr: stderr.toString() }));
|
|
186
|
+
child.on("close", (status) => resolve({ status, stdout, stderr: stderr.toString() }));
|
|
183
187
|
});
|
|
184
188
|
}
|
|
185
189
|
async function runStep(executable, target, artifactsDir, id, args, { json, onSpawn }) {
|
|
@@ -305,6 +309,22 @@ function resolvePlatform(requested, target) {
|
|
|
305
309
|
);
|
|
306
310
|
}
|
|
307
311
|
async function handlePrepare(argv) {
|
|
312
|
+
const { options } = parseArgs(argv, "prepare");
|
|
313
|
+
const target = targetPath(options);
|
|
314
|
+
if (!fs.existsSync(target)) return handlePrepareLocked(argv);
|
|
315
|
+
const lock = acquireCheckoutLock(target, "prepare");
|
|
316
|
+
if ("message" in lock) {
|
|
317
|
+
if (optionFlag(options, "json")) console.log(JSON.stringify({ status: "fail", error: { code: "SANDBOX_BUSY", message: lock.message }, exitCode: EXIT.runtime }));
|
|
318
|
+
else console.error(`${lock.message} Next: inspect the owning operation with mm-harness status --watch`);
|
|
319
|
+
return EXIT.runtime;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return await handlePrepareLocked(argv);
|
|
323
|
+
} finally {
|
|
324
|
+
lock.release();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function handlePrepareLocked(argv) {
|
|
308
328
|
const { options } = parseArgs(argv, "prepare");
|
|
309
329
|
const json = optionFlag(options, "json");
|
|
310
330
|
const target = targetPath(options);
|
|
@@ -372,6 +392,7 @@ async function handlePrepare(argv) {
|
|
|
372
392
|
const step = await runStep(executable, target, artifactsDir, id, args, {
|
|
373
393
|
json,
|
|
374
394
|
onSpawn: (stepStartedAt) => {
|
|
395
|
+
recordCommandStage(id);
|
|
375
396
|
activeStep = { id, startedAt: new Date(stepStartedAt).toISOString() };
|
|
376
397
|
writeProgress();
|
|
377
398
|
view?.start(id, stepStartedAt);
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
importRecipeHarness,
|
|
22
22
|
importRecipeProtocol,
|
|
23
|
-
runnerDir
|
|
23
|
+
runnerDir,
|
|
24
|
+
recipeRuntimePath
|
|
24
25
|
} from "../paths.js";
|
|
25
26
|
import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
|
|
26
27
|
import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
|
|
@@ -277,7 +278,11 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
|
|
|
277
278
|
recipeDocument: recipeDocument ?? recipe,
|
|
278
279
|
...absoluteRecipePath ? { recipePath: absoluteRecipePath } : {},
|
|
279
280
|
...librarySources ? { librarySources } : {},
|
|
280
|
-
excludedProductRoots: [
|
|
281
|
+
excludedProductRoots: [
|
|
282
|
+
absoluteArtifactsDir,
|
|
283
|
+
recipeRuntimePath(projectRoot, "operations"),
|
|
284
|
+
recipeRuntimePath(projectRoot, "lock-members")
|
|
285
|
+
],
|
|
281
286
|
helperPaths: commandHelperPaths(recipeDocument ?? recipe, projectRoot)
|
|
282
287
|
};
|
|
283
288
|
const startProvenance = await captureExecutionProvenance(
|
|
@@ -416,6 +421,7 @@ function runtimeRecipeDocument(recipe, absoluteRecipePath) {
|
|
|
416
421
|
function recipeProcessEnvironment() {
|
|
417
422
|
const env = { ...process.env };
|
|
418
423
|
delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
|
|
424
|
+
delete env.MM_HARNESS_OPERATION_ID;
|
|
419
425
|
return env;
|
|
420
426
|
}
|
|
421
427
|
function executionLibrarySources(sources, invocationTrust) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { checkoutBusyOut, spawnScriptStreaming } from "./shared.js";
|
|
2
|
+
import { acquireCheckoutLock } from "../checkout-lock.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import {
|
|
@@ -23,7 +24,18 @@ import {
|
|
|
23
24
|
usageError
|
|
24
25
|
} from "./parse-args.js";
|
|
25
26
|
import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
|
|
26
|
-
async function handleRuntimeLaunch(
|
|
27
|
+
async function handleRuntimeLaunch(args) {
|
|
28
|
+
const target = targetPath(args.options);
|
|
29
|
+
if (optionFlag(args.options, "help") || !fs.existsSync(target)) return handleRuntimeLaunchLocked(args);
|
|
30
|
+
const lock = acquireCheckoutLock(target, "runtime-launch");
|
|
31
|
+
if ("message" in lock) return checkoutBusyOut(optionFlag(args.options, "json"), "runtime-launch", lock.message, lock.path);
|
|
32
|
+
try {
|
|
33
|
+
return await handleRuntimeLaunchLocked(args);
|
|
34
|
+
} finally {
|
|
35
|
+
lock.release();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function handleRuntimeLaunchLocked({ options }) {
|
|
27
39
|
if (optionFlag(options, "help")) {
|
|
28
40
|
console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
|
|
29
41
|
|
|
@@ -186,11 +198,11 @@ async function handleRuntimeLaunch({ options }) {
|
|
|
186
198
|
cdpPort
|
|
187
199
|
}));
|
|
188
200
|
}
|
|
189
|
-
const result =
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
const result = await spawnScriptStreaming(command[0], command.slice(1), target, {
|
|
202
|
+
forward: jsonMode ? "stderr" : "original",
|
|
203
|
+
stdin: jsonMode ? "ignore" : "inherit",
|
|
204
|
+
capture: false,
|
|
205
|
+
stage: mode
|
|
194
206
|
});
|
|
195
207
|
const summaryPath = path.join(artifactsDir, "summary.json");
|
|
196
208
|
const summary = readJsonIfExists(summaryPath);
|
package/dist/commands/shared.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { trackCheckoutChild } from "../checkout-lock.js";
|
|
2
|
+
import { recordCommandOutput, recordCommandStage } from "../command-journal.js";
|
|
1
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
|
|
2
6
|
import path from "node:path";
|
|
3
7
|
import { detectAdapter } from "../harness.js";
|
|
4
8
|
import { colorHumanMessage } from "../cli-color.js";
|
|
@@ -81,6 +85,7 @@ function spawnScript(script, args, cwd, json, env) {
|
|
|
81
85
|
return { status: 1, output: message };
|
|
82
86
|
}
|
|
83
87
|
const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
|
|
88
|
+
recordCommandStage(path.basename(isNodeScript ? args[0] : script));
|
|
84
89
|
const result = spawnSync(invokeBin, spawnArgs, {
|
|
85
90
|
cwd,
|
|
86
91
|
encoding: "utf8",
|
|
@@ -97,6 +102,7 @@ function spawnScript(script, args, cwd, json, env) {
|
|
|
97
102
|
return { status: 1, output: message };
|
|
98
103
|
}
|
|
99
104
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
105
|
+
recordCommandOutput(output);
|
|
100
106
|
if (!json && output) process.stderr.write(output);
|
|
101
107
|
return { status: result.status ?? 1, output };
|
|
102
108
|
}
|
|
@@ -122,7 +128,7 @@ function spawnInherit(script, args, cwd) {
|
|
|
122
128
|
return result.signal === "SIGINT" || result.signal === "SIGTERM" ? 0 : 1;
|
|
123
129
|
}
|
|
124
130
|
function spawnScriptStreaming(script, args, cwd, options) {
|
|
125
|
-
const spawnOptions = options && ("env" in options || "timeoutMs" in options) ? options : { env: options };
|
|
131
|
+
const spawnOptions = options && ("env" in options || "timeoutMs" in options || "forward" in options || "capture" in options || "stdin" in options || "stage" in options) ? options : { env: options };
|
|
126
132
|
const isNodeScript = script === process.execPath && args.length > 0;
|
|
127
133
|
const stem = isNodeScript ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase() : path.basename(script).replace(/[^A-Za-z0-9]/gu, "_").toUpperCase();
|
|
128
134
|
const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
|
|
@@ -131,26 +137,41 @@ function spawnScriptStreaming(script, args, cwd, options) {
|
|
|
131
137
|
if (shellLeafMissing(bin)) {
|
|
132
138
|
const message = `leaf could not start: ${path.basename(bin)} (ENOENT)
|
|
133
139
|
Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
|
|
134
|
-
process.stderr.write(`${message}
|
|
140
|
+
if (spawnOptions.forward !== "none") process.stderr.write(`${message}
|
|
135
141
|
`);
|
|
136
142
|
return Promise.resolve({ status: 1, output: message });
|
|
137
143
|
}
|
|
138
144
|
const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
recordCommandStage(spawnOptions.stage ?? path.basename(isNodeScript ? args[0] : script));
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const ownsProcessGroup = process.platform !== "win32";
|
|
141
148
|
const child = spawn(invokeBin, spawnArgs, {
|
|
142
149
|
cwd,
|
|
143
150
|
env: spawnOptions.env ? { ...process.env, ...spawnOptions.env } : process.env,
|
|
144
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
+
stdio: [spawnOptions.stdin ?? "ignore", "pipe", "pipe"],
|
|
145
152
|
detached: ownsProcessGroup
|
|
146
153
|
});
|
|
147
|
-
let
|
|
154
|
+
let untrack;
|
|
155
|
+
try {
|
|
156
|
+
untrack = child.pid ? trackCheckoutChild(cwd, child.pid, ownsProcessGroup) : void 0;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
child.once("error", reject);
|
|
159
|
+
child.once("close", () => reject(error));
|
|
160
|
+
try {
|
|
161
|
+
if (ownsProcessGroup && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
162
|
+
else child.kill("SIGKILL");
|
|
163
|
+
} catch (killError) {
|
|
164
|
+
if (killError.code !== "ESRCH") reject(killError);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const captured = new OperationOutputTail();
|
|
148
169
|
let settled = false;
|
|
149
170
|
let didTimeout = false;
|
|
150
171
|
let escalation;
|
|
151
172
|
let forwardedParentSignal;
|
|
152
173
|
let parentSignalFallback;
|
|
153
|
-
const parentSignals = ["SIGINT", "SIGTERM"];
|
|
174
|
+
const parentSignals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
|
|
154
175
|
const removeParentSignalHandlers = () => {
|
|
155
176
|
for (const signal of parentSignals) process.removeListener(signal, parentSignalHandlers[signal]);
|
|
156
177
|
};
|
|
@@ -182,7 +203,7 @@ function spawnScriptStreaming(script, args, cwd, options) {
|
|
|
182
203
|
forwardedParentSignal = signal;
|
|
183
204
|
signalChildTree(signal);
|
|
184
205
|
removeParentSignalHandlers();
|
|
185
|
-
parentSignalFallback = setTimeout(() => {
|
|
206
|
+
if (Number(spawnOptions.timeoutMs) > 0) parentSignalFallback = setTimeout(() => {
|
|
186
207
|
signalChildTree("SIGKILL");
|
|
187
208
|
exitFromForwardedSignal();
|
|
188
209
|
}, 1e3);
|
|
@@ -194,31 +215,35 @@ function spawnScriptStreaming(script, args, cwd, options) {
|
|
|
194
215
|
const timer = Number.isFinite(timeoutMs) && Number(timeoutMs) > 0 ? setTimeout(() => {
|
|
195
216
|
didTimeout = true;
|
|
196
217
|
const message = `leaf timed out after ${String(timeoutMs)}ms: ${path.basename(script)}`;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
captured.append(`
|
|
219
|
+
${message}
|
|
220
|
+
`);
|
|
221
|
+
if (spawnOptions.forward !== "none") process.stderr.write(`${message}
|
|
200
222
|
`);
|
|
201
223
|
signalChildTree("SIGTERM");
|
|
202
224
|
escalation = setTimeout(() => {
|
|
203
225
|
signalChildTree("SIGKILL");
|
|
204
|
-
finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
|
|
226
|
+
finish({ status: 1, output: captured.toString(), timedOut: true, timeoutMs: Number(timeoutMs) });
|
|
205
227
|
}, 1e3);
|
|
206
228
|
}, Number(timeoutMs)) : void 0;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
229
|
+
const decoders = [new StringDecoder("utf8"), new StringDecoder("utf8")];
|
|
230
|
+
const tee = (chunk, stdout = false) => {
|
|
231
|
+
if (spawnOptions.capture !== false) captured.append(decoders[stdout ? 0 : 1].write(chunk));
|
|
232
|
+
recordCommandOutput(chunk);
|
|
233
|
+
if (spawnOptions.forward !== "none") {
|
|
234
|
+
(stdout && spawnOptions.forward === "original" ? process.stdout : process.stderr).write(chunk);
|
|
235
|
+
}
|
|
211
236
|
};
|
|
212
|
-
child.stdout?.on("data", tee);
|
|
237
|
+
child.stdout?.on("data", (chunk) => tee(chunk, true));
|
|
213
238
|
child.stderr?.on("data", tee);
|
|
214
239
|
child.on("error", (error) => {
|
|
215
240
|
const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
|
|
216
241
|
const code = error.code ?? "ESPAWN";
|
|
217
242
|
const message = `leaf could not start: ${leaf} (${code})
|
|
218
243
|
Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) \u2014 the shell leaf is missing or not executable`;
|
|
219
|
-
process.stderr.write(`${message}
|
|
244
|
+
if (spawnOptions.forward !== "none") process.stderr.write(`${message}
|
|
220
245
|
`);
|
|
221
|
-
finish({ status: 1, output: message });
|
|
246
|
+
finish({ status: 1, output: message, error });
|
|
222
247
|
});
|
|
223
248
|
child.on("exit", () => {
|
|
224
249
|
if (forwardedParentSignal) {
|
|
@@ -227,13 +252,15 @@ function spawnScriptStreaming(script, args, cwd, options) {
|
|
|
227
252
|
}
|
|
228
253
|
if (!didTimeout) return;
|
|
229
254
|
signalChildTree("SIGKILL");
|
|
230
|
-
finish({ status: 1, output, timedOut: true, timeoutMs: Number(timeoutMs) });
|
|
255
|
+
finish({ status: 1, output: captured.toString(), timedOut: true, timeoutMs: Number(timeoutMs) });
|
|
231
256
|
});
|
|
232
257
|
child.on("close", (status) => {
|
|
258
|
+
if (status !== null) untrack?.();
|
|
259
|
+
if (spawnOptions.capture !== false) captured.append(decoders.map((decoder) => decoder.end()).join(""));
|
|
233
260
|
if (didTimeout) signalChildTree("SIGKILL");
|
|
234
261
|
finish({
|
|
235
262
|
status: didTimeout ? 1 : status ?? 1,
|
|
236
|
-
output,
|
|
263
|
+
output: captured.toString(),
|
|
237
264
|
...didTimeout ? { timedOut: true, timeoutMs: Number(timeoutMs) } : {}
|
|
238
265
|
});
|
|
239
266
|
});
|
|
@@ -118,6 +118,7 @@ function renderTaskView(view, title, explicitTask = false) {
|
|
|
118
118
|
last.ageMs !== void 0 && `${formatDuration(last.ageMs)} ago`
|
|
119
119
|
]));
|
|
120
120
|
}
|
|
121
|
+
lines.push(...renderOperations(view));
|
|
121
122
|
lines.push("");
|
|
122
123
|
if (!view.activity.ongoing) {
|
|
123
124
|
lines.push(out("warn", "no ongoing task detected"));
|
|
@@ -190,6 +191,12 @@ function changedLines(previous, next) {
|
|
|
190
191
|
];
|
|
191
192
|
}
|
|
192
193
|
const lines = [];
|
|
194
|
+
for (const operation of next.operations) {
|
|
195
|
+
const before2 = previous.operations.find((entry) => entry.id === operation.id);
|
|
196
|
+
if (!before2 || before2.status !== operation.status || before2.stage !== operation.stage || before2.ownerAlive !== operation.ownerAlive) {
|
|
197
|
+
lines.push(`operation ${operation.command} ${operation.status}${operation.stage ? ` \xB7 ${operation.stage}` : ""} \xB7 log ${operation.logPath}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
193
200
|
for (const step of next.prepare?.steps ?? []) {
|
|
194
201
|
if (!previous.prepare?.steps.some((prior) => prior.id === step.id && prior.status === step.status)) {
|
|
195
202
|
lines.push(`prepare ${step.id} ${step.status} (${formatDuration(step.durationMs)})`);
|
|
@@ -285,6 +292,22 @@ function shortUdid(udid) {
|
|
|
285
292
|
function joinParts(parts, separator = " \xB7 ") {
|
|
286
293
|
return parts.filter((part) => Boolean(part)).join(separator);
|
|
287
294
|
}
|
|
295
|
+
function renderOperations(view) {
|
|
296
|
+
const active = view.operations.filter((operation) => operation.status === "running" && operation.ownerAlive);
|
|
297
|
+
const selected = active.length ? active : view.operations.slice(-1);
|
|
298
|
+
return selected.flatMap((operation) => {
|
|
299
|
+
const elapsed = Date.parse(operation.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString()) - Date.parse(operation.startedAt);
|
|
300
|
+
const state = operation.status === "running" ? operation.ownerAlive ? "owner alive" : "owner exited; outcome unknown" : operation.status;
|
|
301
|
+
const age = operation.lastOutputAt ? formatDuration(Date.now() - Date.parse(operation.lastOutputAt)) + " ago" : "none recorded";
|
|
302
|
+
const quoted = "'" + operation.logPath.replace(/'/g, "'\\''") + "'";
|
|
303
|
+
return [
|
|
304
|
+
`${operation.parentId ? " " : ""}operation ${operation.command} \xB7 ${state} \xB7 ${formatDuration(elapsed)} elapsed`,
|
|
305
|
+
...operation.stage ? [` stage ${operation.stage}`] : [],
|
|
306
|
+
` last output ${age}`,
|
|
307
|
+
` follow: tail -n 50 -F ${quoted}`
|
|
308
|
+
];
|
|
309
|
+
});
|
|
310
|
+
}
|
|
288
311
|
export {
|
|
289
312
|
changedLines,
|
|
290
313
|
formatDuration,
|
|
@@ -215,7 +215,7 @@ function gitSourceFingerprint(topLevel, pathspecs, head) {
|
|
|
215
215
|
}
|
|
216
216
|
function sourcePathspecs(git, excludedRoots, includedRoots) {
|
|
217
217
|
const exclusions = excludedRoots.flatMap((root) => {
|
|
218
|
-
const relative = path.relative(git.topLevel, path.resolve(root));
|
|
218
|
+
const relative = path.relative(git.topLevel, fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root));
|
|
219
219
|
return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? [`:(exclude)${relative}`] : [];
|
|
220
220
|
});
|
|
221
221
|
const inclusions = includedRoots.length > 0 ? includedRoots.map((root) => path.join(git.pathspec === "." ? "" : git.pathspec, root)) : [git.pathspec];
|
package/dist/harness.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnScriptStreaming } from "./commands/shared.js";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import { shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
|
|
5
6
|
import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
|
|
6
7
|
import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
|
|
7
8
|
import { colorHumanMessage } from "./cli-color.js";
|
|
@@ -367,15 +368,10 @@ async function handleHarness(argv) {
|
|
|
367
368
|
}
|
|
368
369
|
return 1;
|
|
369
370
|
}
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const result = spawnSync(dispatchBin, dispatchArgs, {
|
|
375
|
-
// Default: stream child output verbatim (byte-identical to the skill path).
|
|
376
|
-
// --json: route child output to our stderr so stdout carries only the summary.
|
|
377
|
-
stdio: quiet ? "ignore" : json ? ["inherit", 2, "inherit"] : "inherit",
|
|
378
|
-
env: process.env
|
|
371
|
+
const result = await spawnScriptStreaming(dispatch.command, [...dispatch.prefixArgs, ...forwardArgs], target, {
|
|
372
|
+
forward: quiet ? "none" : json ? "stderr" : "original",
|
|
373
|
+
stdin: quiet ? "ignore" : "inherit",
|
|
374
|
+
capture: false
|
|
379
375
|
});
|
|
380
376
|
if (result.error) {
|
|
381
377
|
if (quiet) {
|
package/dist/json-stream.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordCommandStage } from "./command-journal.js";
|
|
1
2
|
class JsonStreamWriter {
|
|
2
3
|
enabled;
|
|
3
4
|
command;
|
|
@@ -32,9 +33,11 @@ class JsonStreamWriter {
|
|
|
32
33
|
}));
|
|
33
34
|
}
|
|
34
35
|
phase(phase, fields = {}) {
|
|
36
|
+
recordCommandStage(phase);
|
|
35
37
|
this.emit("phase", { phase, ...fields });
|
|
36
38
|
}
|
|
37
39
|
node(nodeId, action, status) {
|
|
40
|
+
if (status === "running") recordCommandStage(`${nodeId}: ${action}`);
|
|
38
41
|
this.emit("node", { nodeId, action, status });
|
|
39
42
|
}
|
|
40
43
|
mutation(mutation) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recordCommandOutput } from "./command-journal.js";
|
|
2
|
+
import { trackCheckoutChild } from "./checkout-lock.js";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -95,6 +97,7 @@ function runProcess(command, args, options) {
|
|
|
95
97
|
env: options.env,
|
|
96
98
|
stdio: [options.stdin == null ? "ignore" : "pipe", "pipe", "pipe"]
|
|
97
99
|
});
|
|
100
|
+
if (child.pid) trackCheckoutChild(options.cwd, child.pid);
|
|
98
101
|
if (options.stdin != null) {
|
|
99
102
|
child.stdin.on("error", (error) => {
|
|
100
103
|
if (error.code === "EPIPE" || settled) return;
|
|
@@ -107,9 +110,11 @@ function runProcess(command, args, options) {
|
|
|
107
110
|
let stderr = "";
|
|
108
111
|
child.stdout.on("data", (chunk) => {
|
|
109
112
|
stdout += chunk;
|
|
113
|
+
recordCommandOutput(chunk);
|
|
110
114
|
});
|
|
111
115
|
child.stderr.on("data", (chunk) => {
|
|
112
116
|
stderr += chunk;
|
|
117
|
+
recordCommandOutput(chunk);
|
|
113
118
|
});
|
|
114
119
|
const timeout = options.timeoutMs ? setTimeout(() => {
|
|
115
120
|
if (settled) return;
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -245,6 +245,8 @@ Example:
|
|
|
245
245
|
Example:
|
|
246
246
|
mm-harness checklist mark temp/tasks/recipe-cook/<task> start
|
|
247
247
|
mm-harness checklist mark temp/tasks/recipe-cook/<task> 1
|
|
248
|
+
mm-harness checklist mark temp/tasks/recipe-cook/<task> sub start qa-cases --step 2 --from inputs/qa-cases.md
|
|
249
|
+
mm-harness checklist mark temp/tasks/recipe-cook/<task> sub qa-cases 1
|
|
248
250
|
mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last
|
|
249
251
|
mm-harness checklist closeout temp/tasks/recipe-cook/<task> --share`
|
|
250
252
|
},
|
|
@@ -506,7 +508,7 @@ Example:
|
|
|
506
508
|
replace it, and an interrupted process remains recorded as verdict=running.
|
|
507
509
|
|
|
508
510
|
--target <path> Checkout path (default: cwd)
|
|
509
|
-
--runtime-dir <dir> Runtime dir containing
|
|
511
|
+
--runtime-dir <dir> Runtime dir containing command operation records
|
|
510
512
|
--json Machine-readable envelope
|
|
511
513
|
|
|
512
514
|
Example:
|
package/dist/task-view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOperations, processIdentity } from "@farmslot/recipe-harness/runtime/operation";
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -31,7 +32,12 @@ function findTaskDir(target) {
|
|
|
31
32
|
return latestTaskDir(path.join(target, "temp", "tasks"));
|
|
32
33
|
}
|
|
33
34
|
function collectTaskView(target, taskDir, now) {
|
|
35
|
+
target = fs.existsSync(target) ? fs.realpathSync(target) : path.resolve(target);
|
|
36
|
+
taskDir = taskDir && (fs.existsSync(taskDir) ? fs.realpathSync(taskDir) : path.resolve(taskDir));
|
|
37
|
+
const taskIsRunning = taskDir && readJsonObject(path.join(taskDir, WORKER_SIGNAL_FILE))?.status === "running";
|
|
38
|
+
const operations = readOperations(path.join(target, recipeRuntimeDir(), "operations")).filter((operation) => !taskIsRunning || path.resolve(operation.logPath).startsWith(path.resolve(taskDir, "artifacts", "operations") + path.sep)).map((operation) => ({ ...operation, ownerAlive: operation.status === "running" && processIdentity(operation.pid) === operation.processStartedAt }));
|
|
34
39
|
const base = {
|
|
40
|
+
operations,
|
|
35
41
|
checkout: readCheckout(target),
|
|
36
42
|
isolation: readIsolation(target),
|
|
37
43
|
libraries: parseLibraryPathEnv(process.env.RECIPE_LIBRARY_PATH).map(
|
|
@@ -77,7 +83,8 @@ function collectTaskView(target, taskDir, now) {
|
|
|
77
83
|
parseTime(pickString(signal, "timestamp")),
|
|
78
84
|
...readMarkEvents(signal).map((event) => Date.parse(event.checkedAt)),
|
|
79
85
|
commandCounts ? commandAt : void 0,
|
|
80
|
-
prepare ? parseTime(prepare.updatedAt) : void 0
|
|
86
|
+
prepare ? parseTime(prepare.updatedAt) : void 0,
|
|
87
|
+
...operations.flatMap((operation) => [parseTime(operation.lastOutputAt), parseTime(operation.stageStartedAt), parseTime(operation.startedAt)])
|
|
81
88
|
],
|
|
82
89
|
branch: base.checkout.branch,
|
|
83
90
|
ticket,
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -149,6 +149,43 @@ bash tests/contract/run.sh
|
|
|
149
149
|
Runtime changes also require real affected-platform QA and a packed installation
|
|
150
150
|
test. Update `CHANGELOG.md` under `Unreleased` for user-visible behavior.
|
|
151
151
|
|
|
152
|
+
When upgrading the protocol or runtime, align their transitive consumers before
|
|
153
|
+
pinning them here. Farmslot's `workspace:*` dependencies become exact versions
|
|
154
|
+
when packed, so `agent-runtime` and `expo-recipe` can retain older copies until
|
|
155
|
+
republished. Verify the complete install with
|
|
156
|
+
`bash tests/contract/packaging-dist-install.test.sh` before releasing the harness.
|
|
157
|
+
|
|
152
158
|
Docs describe only current behavior. History belongs in the changelog and Git;
|
|
153
159
|
do not add handovers, audits, speculative roadmaps, or duplicate command guides
|
|
154
160
|
under `docs/`.
|
|
161
|
+
|
|
162
|
+
## Long command observations
|
|
163
|
+
|
|
164
|
+
`mm-harness status --watch` shows command ownership, stage, elapsed time, last
|
|
165
|
+
output, and a `tail -n 50 -F` command for each active operation. Records and logs
|
|
166
|
+
live under `temp/recipe/runtime/operations/`. During a running task, records and
|
|
167
|
+
logs also appear under its `artifacts/operations/` directory for Command Center.
|
|
168
|
+
|
|
169
|
+
Use `mm-harness last --json` to resolve the current journal path. Each invocation
|
|
170
|
+
writes its own journal; new commands no longer write `last-command.json`. The
|
|
171
|
+
reader still accepts that legacy file when no operation records exist. A running
|
|
172
|
+
parent remains visible while its child or a rejected competing command finishes.
|
|
173
|
+
|
|
174
|
+
An owner process being alive, recent log output, and a stage change are separate
|
|
175
|
+
observations. None proves application readiness. Reading status or following logs
|
|
176
|
+
does not refresh activity or take the checkout mutation lock.
|
|
177
|
+
|
|
178
|
+
The checkout keeps the latest 100 completed invocation records. Task-local mirrors
|
|
179
|
+
remain with their task artifacts. Streaming leaves keep full output on disk;
|
|
180
|
+
commands that need captured output for classification keep a bounded tail.
|
|
181
|
+
`fixtures sync --json` streams progress to stderr while reserving stdout for JSON.
|
|
182
|
+
Interactive harness leaves inherit stdin; their stdout/stderr are piped for logs,
|
|
183
|
+
so child TTY formatting may differ from a direct launch.
|
|
184
|
+
|
|
185
|
+
Streaming leaves run in tracked process groups. Ordinary interrupt, termination,
|
|
186
|
+
hangup and quit signals are forwarded. Non-timed leaves may finish their cleanup
|
|
187
|
+
without a new forced-kill deadline. SIGKILL cannot be forwarded: if the caller's
|
|
188
|
+
group is forcibly killed, its detached build may survive and the checkout remains
|
|
189
|
+
reserved until that recorded group exits. Inspect the recorded group before
|
|
190
|
+
manual cleanup. Group-ID reuse can conservatively retain an old crash lock; the
|
|
191
|
+
lock's recovery hint describes what to inspect.
|
|
@@ -159,6 +159,18 @@ export async function navigateOpaqueMobile(input, destination) {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Release builds may omit the mode toggle when Pro is disabled. The distinct
|
|
163
|
+
// visible market screen still proves its mode; a chooser or conflicting screen does not.
|
|
164
|
+
export function visiblePerpsModeScreen(ids, mode) {
|
|
165
|
+
const expectedScreen = mode === 'pro' ? IDS.proMarket : IDS.market;
|
|
166
|
+
const oppositeScreen = mode === 'pro' ? IDS.market : IDS.proMarket;
|
|
167
|
+
const expectedToggle = mode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
|
|
168
|
+
const oppositeToggle = mode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
|
|
169
|
+
if (ids.has(IDS.modeLite) || ids.has(IDS.modePro) || ids.has(oppositeScreen) || ids.has(oppositeToggle)) return undefined;
|
|
170
|
+
if (ids.has(expectedScreen)) return expectedScreen;
|
|
171
|
+
return ids.has(expectedToggle) ? expectedToggle : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
162
174
|
export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
|
|
163
175
|
if (!['lite', 'pro'].includes(perpsMode)) {
|
|
164
176
|
throw new Error('Opaque Mobile Perps mode must be lite or pro.');
|
|
@@ -218,7 +230,6 @@ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
|
|
|
218
230
|
|
|
219
231
|
try {
|
|
220
232
|
const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
|
|
221
|
-
const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
|
|
222
233
|
const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
|
|
223
234
|
const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
|
|
224
235
|
let expectedStreak = 0;
|
|
@@ -240,12 +251,13 @@ export async function ensureVisibleAndroidPerpsMode(input, perpsMode) {
|
|
|
240
251
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
241
252
|
continue;
|
|
242
253
|
}
|
|
243
|
-
|
|
254
|
+
const observedScreen = visiblePerpsModeScreen(ids, perpsMode);
|
|
255
|
+
if (observedScreen) {
|
|
244
256
|
expectedStreak += 1;
|
|
245
257
|
if (expectedStreak >= 2) {
|
|
246
258
|
return {
|
|
247
259
|
mode: perpsMode,
|
|
248
|
-
observedScreen
|
|
260
|
+
observedScreen,
|
|
249
261
|
provider: 'android-native-accessibility',
|
|
250
262
|
proofPath: 'visible-native-navigation',
|
|
251
263
|
};
|
|
@@ -294,7 +306,6 @@ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
|
|
|
294
306
|
if (!['lite', 'pro'].includes(perpsMode)) {
|
|
295
307
|
throw new Error('iOS Perps mode must be lite or pro.');
|
|
296
308
|
}
|
|
297
|
-
const expectedToggle = perpsMode === 'pro' ? IDS.modeTogglePro : IDS.modeToggleLite;
|
|
298
309
|
const currentToggle = perpsMode === 'pro' ? IDS.modeToggleLite : IDS.modeTogglePro;
|
|
299
310
|
const option = perpsMode === 'pro' ? IDS.modePro : IDS.modeLite;
|
|
300
311
|
const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
|
|
@@ -324,12 +335,13 @@ export async function ensureVisibleIosPerpsMode(input, perpsMode) {
|
|
|
324
335
|
const items = observed.observations?.['ui.visible']?.items ?? [];
|
|
325
336
|
const ids = new Set(items.map((item) => String(item.test_id ?? '')));
|
|
326
337
|
lastObserved = [...ids].filter(Boolean).slice(0, 20);
|
|
327
|
-
|
|
338
|
+
const observedScreen = visiblePerpsModeScreen(ids, perpsMode);
|
|
339
|
+
if (observedScreen) {
|
|
328
340
|
expectedStreak += 1;
|
|
329
341
|
if (expectedStreak >= 2) {
|
|
330
342
|
return {
|
|
331
343
|
mode: perpsMode,
|
|
332
|
-
observedScreen
|
|
344
|
+
observedScreen,
|
|
333
345
|
provider: 'ios-native-accessibility',
|
|
334
346
|
proofPath: 'visible-native-navigation',
|
|
335
347
|
};
|