mobile-automator 0.25.0 → 0.26.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/README.md +11 -0
- package/bin/mauto-session-daemon.js +85 -16
- package/package.json +1 -1
- package/src/cli.js +724 -59
- package/src/device/bridge.js +30 -0
- package/src/device/connection.js +23 -4
- package/src/device/crash-model.js +110 -0
- package/src/device/device-call.js +145 -0
- package/src/device/failure-probe.js +276 -0
- package/src/device/mobile-mcp-tools.js +64 -0
- package/src/device/resolve-connection.js +8 -10
- package/src/device/session-daemon.js +265 -51
- package/src/device/session-handle.js +53 -0
- package/src/device/session-log.js +5 -16
- package/src/guide/content/execute.agnostic.md +48 -5
- package/src/guide/content/execute.aware.md +42 -1
- package/src/guide/content/execute.invariants.md +1 -0
- package/src/observe/accepts.js +108 -0
- package/src/observe/event.js +241 -0
- package/src/observe/failure-capture.js +166 -0
- package/src/observe/flush.js +177 -0
- package/src/observe/paths.js +125 -0
- package/src/observe/recorder.js +178 -0
- package/src/observe/settings.js +51 -0
- package/src/observe/sinks/file.js +101 -0
- package/src/observe/sinks/stderr.js +20 -0
- package/src/observe/spool.js +199 -0
- package/src/observe/telemetry.js +143 -0
- package/src/observe/trace.js +174 -0
- package/src/observe/transport.js +169 -0
- package/src/observe/vocab.js +96 -0
- package/src/output/envelope.js +6 -0
- package/src/result/capability-catalog.js +14 -0
- package/src/result/store.js +98 -1
- package/src/schemas/config_schema.json +11 -0
- package/src/schemas/result_schema.json +55 -0
- package/src/setup/scaffold.js +27 -0
- package/src/util/log-rotate.js +34 -0
- package/src/workspace/discover.js +69 -0
package/src/cli.js
CHANGED
|
@@ -23,6 +23,19 @@ const guideEmitter = require('./guide/emitter');
|
|
|
23
23
|
const { ADAPTERS } = require('./init/adapters');
|
|
24
24
|
const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
|
|
25
25
|
const connection = require('./device/connection');
|
|
26
|
+
const { record } = require('./observe/recorder');
|
|
27
|
+
const { resolveRunId } = require('./observe/settings');
|
|
28
|
+
const { runTracePath } = require('./observe/paths');
|
|
29
|
+
const { readTrace, deriveRun, pruneRunTraces } = require('./observe/trace');
|
|
30
|
+
const { readHandle, readSessionId } = require('./device/session-handle');
|
|
31
|
+
const { captureOnFailure } = require('./observe/failure-capture');
|
|
32
|
+
const { crashTimestampMs } = require('./device/crash-model');
|
|
33
|
+
const { probeCrashes } = require('./device/failure-probe');
|
|
34
|
+
const observeTelemetry = require('./observe/telemetry');
|
|
35
|
+
const observeSpool = require('./observe/spool');
|
|
36
|
+
const observeTransport = require('./observe/transport');
|
|
37
|
+
const { makeFlusher } = require('./observe/flush');
|
|
38
|
+
const { findWorkspaceRoot, noWorkspaceFailure } = require('./workspace/discover');
|
|
26
39
|
|
|
27
40
|
// Commander throws (via exitOverride) for two very different reasons, and the
|
|
28
41
|
// split below is the ONLY thing that tells them apart.
|
|
@@ -625,6 +638,75 @@ function handleResultAddAssertion({ resultStoreFactory, projectRoot }, opts) {
|
|
|
625
638
|
return { envelope: ok({ run_id: runId, assertion: entry }, storeHint(store)), exitKind: 'ok' };
|
|
626
639
|
}
|
|
627
640
|
|
|
641
|
+
// Record an observed app crash.
|
|
642
|
+
function handleResultAddCrash({ resultStoreFactory, projectRoot }, opts = {}) {
|
|
643
|
+
const store = resultStoreFactory({
|
|
644
|
+
runId: opts.runId,
|
|
645
|
+
scenarioId: opts.scenarioId,
|
|
646
|
+
projectRoot,
|
|
647
|
+
});
|
|
648
|
+
const entry = store.addCrash({
|
|
649
|
+
crash_id: opts.crashId,
|
|
650
|
+
process: opts.process,
|
|
651
|
+
timestamp: opts.crashTimestamp,
|
|
652
|
+
step_id: opts.stepId,
|
|
653
|
+
excerpt: opts.excerpt,
|
|
654
|
+
report_path: opts.reportPath,
|
|
655
|
+
});
|
|
656
|
+
return { envelope: ok({ crash: entry }, storeHint(store)), exitKind: 'ok' };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Tolerance for calling a reported duration wrong.
|
|
660
|
+
//
|
|
661
|
+
// The measured value is a wall-clock span between two recorded events, so
|
|
662
|
+
// sub-second differences are rounding rather than disagreement, and 10% absorbs
|
|
663
|
+
// the honest gap between "the run" and "the part of the run mauto saw" —
|
|
664
|
+
// setup before the first verb, and finalize's own execution after the last.
|
|
665
|
+
//
|
|
666
|
+
// Symmetric on purpose. An UNDER-claim means the agent lost track of its own
|
|
667
|
+
// retries; an OVER-claim means it counted its planning as run time. Both are
|
|
668
|
+
// worth a human seeing, and one rule is two tests instead of four.
|
|
669
|
+
const DURATION_TOLERANCE_SECONDS = 1;
|
|
670
|
+
const DURATION_TOLERANCE_RATIO = 0.1;
|
|
671
|
+
|
|
672
|
+
// Turn the run's trace into the duration to record and the measurement block to
|
|
673
|
+
// record beside it. Total: an unreadable, absent or unmeasurable trace yields
|
|
674
|
+
// the reported value and a `measurements` block that says so, never an error.
|
|
675
|
+
function measureRun({ projectRoot, runId, reported }) {
|
|
676
|
+
const derived = deriveRun(readTrace(runTracePath(projectRoot, runId)));
|
|
677
|
+
const reportedSeconds = reported === undefined ? null : Number(reported);
|
|
678
|
+
const hasReported = reportedSeconds !== null && Number.isFinite(reportedSeconds);
|
|
679
|
+
|
|
680
|
+
const base = {
|
|
681
|
+
reported_duration_seconds: hasReported ? reportedSeconds : null,
|
|
682
|
+
trace_events: derived ? derived.trace_events : 0,
|
|
683
|
+
device_failures: derived ? derived.device_failures : 0,
|
|
684
|
+
trace_truncated: derived ? derived.trace_truncated : false,
|
|
685
|
+
failure_screenshots: derived ? derived.failure_screenshots : [],
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// No trace, or a trace too short to span any time. Falling back to the flag
|
|
689
|
+
// is 0.24.0's behaviour, and `source` records that it IS a fallback rather
|
|
690
|
+
// than dressing a self-report as a measurement.
|
|
691
|
+
if (!derived || derived.duration_seconds === null) {
|
|
692
|
+
return {
|
|
693
|
+
durationSeconds: hasReported ? reportedSeconds : 0,
|
|
694
|
+
measurements: { source: hasReported ? 'reported' : 'none', duration_disagreement: false, ...base },
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const measured = derived.duration_seconds;
|
|
699
|
+
const tolerance = Math.max(DURATION_TOLERANCE_SECONDS, measured * DURATION_TOLERANCE_RATIO);
|
|
700
|
+
return {
|
|
701
|
+
durationSeconds: measured,
|
|
702
|
+
measurements: {
|
|
703
|
+
source: 'trace',
|
|
704
|
+
duration_disagreement: hasReported && Math.abs(reportedSeconds - measured) > tolerance,
|
|
705
|
+
...base,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
628
710
|
function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectRoot }, opts) {
|
|
629
711
|
const { runId, scenarioId, status, duration } = opts;
|
|
630
712
|
if (!runId) {
|
|
@@ -643,11 +725,20 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
|
|
|
643
725
|
if (opts.apiLevel !== undefined) metadata.api_level = opts.apiLevel;
|
|
644
726
|
if (opts.environment !== undefined) metadata.environment = opts.environment;
|
|
645
727
|
|
|
728
|
+
// Measure BEFORE constructing the store: the trace is read-only here, and
|
|
729
|
+
// deriving first keeps the store's lock window as small as it already is.
|
|
730
|
+
const measured = measureRun({ projectRoot, runId, reported: duration });
|
|
731
|
+
|
|
646
732
|
const store = resultStoreFactory({ runId, scenarioId, projectRoot });
|
|
647
733
|
const result = store.finalize({
|
|
648
734
|
status,
|
|
649
|
-
|
|
735
|
+
// Derived, not trusted. `--duration` survives in
|
|
736
|
+
// measurements.reported_duration_seconds and, when it disagrees with the
|
|
737
|
+
// clock, in a typed state_context observation — the disagreement is a
|
|
738
|
+
// signal about the agent, so it is recorded rather than resolved.
|
|
739
|
+
durationSeconds: measured.durationSeconds,
|
|
650
740
|
metadata,
|
|
741
|
+
measurements: measured.measurements,
|
|
651
742
|
// Undefined when --summary is omitted; ResultStore.finalize's own
|
|
652
743
|
// `summary || <generated default>` already treats that as "no override",
|
|
653
744
|
// so no provided-keys-only guard is needed here (unlike metadata, whose
|
|
@@ -655,6 +746,12 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
|
|
|
655
746
|
summary: opts.summary,
|
|
656
747
|
});
|
|
657
748
|
|
|
749
|
+
// Retention, at the only moment a run is known to be over and the only place
|
|
750
|
+
// off the hot path. Never this run's own trace — it is the evidence behind
|
|
751
|
+
// every number just written. Best-effort by construction; pruneRunTraces
|
|
752
|
+
// swallows everything and returns a count.
|
|
753
|
+
pruneRunTraces(projectRoot, { except: runId });
|
|
754
|
+
|
|
658
755
|
// Auto-harvest into cross-session memory. This is best-effort: a memory
|
|
659
756
|
// failure must never fail an otherwise-successful finalize, so its warnings
|
|
660
757
|
// fold into the envelope hint instead of throwing.
|
|
@@ -691,7 +788,11 @@ function storeHint(store) {
|
|
|
691
788
|
// on success; only their ERROR paths (unknown topic/name) fall back to the
|
|
692
789
|
// `fail(...)` envelope + exit 3.
|
|
693
790
|
|
|
694
|
-
|
|
791
|
+
// `ancestorRoot` is the workspace discovery found ABOVE cwd, if any. setup
|
|
792
|
+
// still scaffolds in cwd — it is the one verb whose job is to create a
|
|
793
|
+
// workspace where the user stands — but a nested workspace silently shadows
|
|
794
|
+
// the ancestor for every later verb run below it, so say so.
|
|
795
|
+
function handleSetup({ projectRoot, ancestorRoot = null }, opts = {}) {
|
|
695
796
|
const raw = opts.mode === undefined ? 'aware' : String(opts.mode);
|
|
696
797
|
const mode = MODE_ALIASES[raw];
|
|
697
798
|
if (!mode) {
|
|
@@ -701,8 +802,24 @@ function handleSetup({ projectRoot }, opts = {}) {
|
|
|
701
802
|
};
|
|
702
803
|
}
|
|
703
804
|
const r = scaffold(projectRoot, { mode });
|
|
805
|
+
const hint = ancestorRoot
|
|
806
|
+
? `An existing workspace was found in an ancestor directory (${ancestorRoot}). This new workspace in ${projectRoot} shadows it for every mauto verb run from here or below; remove it if you meant to use the ancestor's.`
|
|
807
|
+
: null;
|
|
704
808
|
return {
|
|
705
|
-
envelope: ok(
|
|
809
|
+
envelope: ok(
|
|
810
|
+
{
|
|
811
|
+
created: r.created,
|
|
812
|
+
mode: r.mode,
|
|
813
|
+
// A NOTICE, not a prompt. mauto verbs are invoked by an agent, so there
|
|
814
|
+
// is nobody at the keyboard to answer a question — and an agent answering
|
|
815
|
+
// a consent question on a human's behalf is worse than not asking. It
|
|
816
|
+
// rides in the envelope (structured, in-band) rather than as a stderr
|
|
817
|
+
// banner, so the agent can surface it to the human it belongs to.
|
|
818
|
+
telemetry: { enabled: false, notice: observeTelemetry.CONSENT_NOTICE },
|
|
819
|
+
next: 'run `mauto guide setup`',
|
|
820
|
+
},
|
|
821
|
+
hint
|
|
822
|
+
),
|
|
706
823
|
exitKind: 'ok',
|
|
707
824
|
};
|
|
708
825
|
}
|
|
@@ -732,6 +849,87 @@ function handleConfigSet({ projectRoot }, key, rawValue) {
|
|
|
732
849
|
return { envelope: ok({ key, value }), exitKind: 'ok' };
|
|
733
850
|
}
|
|
734
851
|
|
|
852
|
+
// --- Slice 5: telemetry consent surface -----------------------------------
|
|
853
|
+
//
|
|
854
|
+
// `status` is the informed half of informed consent, and it is COMPUTED: the
|
|
855
|
+
// field lists come from EVENT_FIELDS at runtime, so the disclosure a user reads
|
|
856
|
+
// before opting in cannot drift from what the uploader actually sends. Prose
|
|
857
|
+
// that restates a catalog is prose that will eventually be wrong.
|
|
858
|
+
|
|
859
|
+
function telemetryReport({ projectRoot, env }, extra = {}) {
|
|
860
|
+
const decision = observeTelemetry.decideForProject(projectRoot, env);
|
|
861
|
+
return {
|
|
862
|
+
enabled: decision.enabled,
|
|
863
|
+
reason: decision.reason,
|
|
864
|
+
endpoint: observeTransport.endpointUrl(env),
|
|
865
|
+
fields_sent: observeTelemetry.sentFields(),
|
|
866
|
+
fields_never_sent: observeTelemetry.neverSentFields(),
|
|
867
|
+
spool: observeSpool.stats({ projectRoot, env }),
|
|
868
|
+
notice: observeTelemetry.CONSENT_NOTICE,
|
|
869
|
+
enable_with: 'mauto telemetry enable',
|
|
870
|
+
...extra,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function handleTelemetryStatus({ projectRoot, env = process.env }) {
|
|
875
|
+
return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Writes the durable intent, then reports the RESOLVED state — which may still
|
|
879
|
+
// be off, because an env kill switch wins. Reporting `enabled: true` here when
|
|
880
|
+
// MAUTO_TELEMETRY=0 is set would be a lie, and the config is not the place to
|
|
881
|
+
// resolve a conflict the environment owns.
|
|
882
|
+
function handleTelemetryEnable({ projectRoot, env = process.env }) {
|
|
883
|
+
observeTelemetry.setEnabled(projectRoot, true);
|
|
884
|
+
const data = telemetryReport({ projectRoot, env });
|
|
885
|
+
const hint =
|
|
886
|
+
data.reason === 'kill_switch'
|
|
887
|
+
? 'Telemetry is enabled in config.json but MAUTO_TELEMETRY=0 is set in this environment and overrides it.'
|
|
888
|
+
: data.reason === 'do_not_track'
|
|
889
|
+
? 'Telemetry is enabled in config.json but DO_NOT_TRACK is set in this environment and overrides it.'
|
|
890
|
+
: data.reason === 'no_token'
|
|
891
|
+
? 'Telemetry is enabled in config.json but this build ships no project token, so nothing will be sent.'
|
|
892
|
+
: undefined;
|
|
893
|
+
return { envelope: ok(data, hint), exitKind: 'ok' };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function handleTelemetryDisable({ projectRoot, env = process.env }) {
|
|
897
|
+
observeTelemetry.setEnabled(projectRoot, false);
|
|
898
|
+
return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// The explicit escape hatch. This is the ONE verb allowed a network round trip,
|
|
902
|
+
// because the user asked for one and is willing to wait for it — every other
|
|
903
|
+
// verb appends to the spool and exits. A machine that never runs a daemon
|
|
904
|
+
// drains here.
|
|
905
|
+
async function handleTelemetryFlush({ projectRoot, env = process.env, flusher }) {
|
|
906
|
+
const flush = flusher || makeFlusher({ projectRoot, env });
|
|
907
|
+
const r = await flush();
|
|
908
|
+
// exitKind stays 'ok': the envelope's own `ok` says the VERB executed — an
|
|
909
|
+
// undelivered spool is a non-error state by design (src/observe/spool.js).
|
|
910
|
+
// The flush's verdict — including permanently discarded events — is data:
|
|
911
|
+
// `ok` below, alongside the counts. Units: `kept` counts BATCHES (files);
|
|
912
|
+
// `sent`, `dropped` and `kept_events` count events.
|
|
913
|
+
const hint =
|
|
914
|
+
r.dropped > 0
|
|
915
|
+
? `${r.dropped} event${r.dropped === 1 ? '' : 's'} permanently discarded — the endpoint rejected them (revoked token, wrong host, or a non-retryable status). They will not be retried.`
|
|
916
|
+
: r.kept > 0
|
|
917
|
+
? `${r.kept} batch${r.kept === 1 ? '' : 'es'} (${r.kept_events} events) kept for retry — the next daemon drain or mauto telemetry flush will resend them.`
|
|
918
|
+
: undefined;
|
|
919
|
+
return {
|
|
920
|
+
envelope: ok({
|
|
921
|
+
skipped: r.skipped,
|
|
922
|
+
sent: r.sent,
|
|
923
|
+
dropped: r.dropped,
|
|
924
|
+
kept: r.kept,
|
|
925
|
+
kept_events: r.kept_events,
|
|
926
|
+
ok: r.ok,
|
|
927
|
+
spool: observeSpool.stats({ projectRoot, env }),
|
|
928
|
+
}, hint),
|
|
929
|
+
exitKind: 'ok',
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
735
933
|
// RAW content on success; fail envelope only when the topic is unknown.
|
|
736
934
|
function handleGuide({ projectRoot, emitter = guideEmitter, config = configManager }, topic) {
|
|
737
935
|
const cfg = config.load(projectRoot);
|
|
@@ -1118,6 +1316,134 @@ async function handleDevicesClear({
|
|
|
1118
1316
|
};
|
|
1119
1317
|
}
|
|
1120
1318
|
|
|
1319
|
+
// --- Crash diagnostics (slice 4) -----------------------------------------
|
|
1320
|
+
//
|
|
1321
|
+
// A DIAGNOSTIC verb, like `devices` / `session` / `memory` — deliberately NOT
|
|
1322
|
+
// in src/device/action-catalog.js, which holds exactly the scenario-schema
|
|
1323
|
+
// actions and whose lint guard asserts parity in both directions.
|
|
1324
|
+
|
|
1325
|
+
const CRASH_REPORT_HEAD_LINES = 200;
|
|
1326
|
+
const CRASH_LIST_HINT =
|
|
1327
|
+
'The device engine could not read crash reports. Run `mauto devices` to confirm the device is reachable; crash reporting needs the helper binary that ships with the device engine.';
|
|
1328
|
+
|
|
1329
|
+
// Default watermark: when a device session is live, its handle already records
|
|
1330
|
+
// started_at (src/device/session-daemon.js). Bind, do not copy — there is no new
|
|
1331
|
+
// timestamp artifact and no second source of truth for "when did this session
|
|
1332
|
+
// begin". No handle means no watermark, which means UNSCOPED, which the envelope
|
|
1333
|
+
// reports as `since: null` rather than pretending the list is recent.
|
|
1334
|
+
function sessionWatermark(projectRoot) {
|
|
1335
|
+
const handle = readHandle(projectRoot);
|
|
1336
|
+
const t = handle && handle.started_at;
|
|
1337
|
+
return typeof t === 'string' && Number.isFinite(Date.parse(t)) ? t : null;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Split a crash list around a watermark. Reports whose own time is unreadable
|
|
1341
|
+
// are neither included nor discarded — they are counted, so the caller can see
|
|
1342
|
+
// that the device returned something the tool could not place in time.
|
|
1343
|
+
function scopeCrashes(crashes, sinceMs) {
|
|
1344
|
+
if (sinceMs == null) return { crashes, unattributed: 0 };
|
|
1345
|
+
const inWindow = [];
|
|
1346
|
+
let unattributed = 0;
|
|
1347
|
+
for (const c of crashes) {
|
|
1348
|
+
const ms = crashTimestampMs(c);
|
|
1349
|
+
if (ms == null) unattributed += 1;
|
|
1350
|
+
else if (ms >= sinceMs) inWindow.push(c);
|
|
1351
|
+
}
|
|
1352
|
+
return { crashes: inWindow, unattributed };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async function handleCrashList({ deviceBridge, projectRoot }, opts = {}) {
|
|
1356
|
+
const sinceRaw = opts.since !== undefined ? opts.since : sessionWatermark(projectRoot);
|
|
1357
|
+
let sinceMs = null;
|
|
1358
|
+
if (sinceRaw != null) {
|
|
1359
|
+
sinceMs = Date.parse(sinceRaw);
|
|
1360
|
+
if (!Number.isFinite(sinceMs)) {
|
|
1361
|
+
return {
|
|
1362
|
+
envelope: fail(
|
|
1363
|
+
'invalid_input',
|
|
1364
|
+
`could not parse --since "${sinceRaw}" as a date`,
|
|
1365
|
+
'Pass an ISO 8601 timestamp, e.g. --since 2026-09-05T10:00:00Z.'
|
|
1366
|
+
),
|
|
1367
|
+
exitKind: 'invalid_input',
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
let all;
|
|
1373
|
+
try {
|
|
1374
|
+
all = await deviceBridge.listCrashes();
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
// "Could not look" is NOT "found none". Reporting ok:true with an empty
|
|
1377
|
+
// list here would be this slice's own bug with the sign flipped.
|
|
1378
|
+
return {
|
|
1379
|
+
envelope: fail(err.kind || 'device', err.message || String(err), err.hint || CRASH_LIST_HINT),
|
|
1380
|
+
exitKind: err.kind || 'device',
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const { crashes, unattributed } = scopeCrashes(all, sinceMs);
|
|
1385
|
+
record(
|
|
1386
|
+
{
|
|
1387
|
+
level: 'info',
|
|
1388
|
+
src: 'cli',
|
|
1389
|
+
event: 'crash.list',
|
|
1390
|
+
crash_count: Number.isFinite(crashes.length) ? crashes.length : undefined,
|
|
1391
|
+
},
|
|
1392
|
+
{ projectRoot }
|
|
1393
|
+
);
|
|
1394
|
+
return {
|
|
1395
|
+
envelope: ok({
|
|
1396
|
+
crashes,
|
|
1397
|
+
count: crashes.length,
|
|
1398
|
+
since: sinceMs == null ? null : sinceRaw,
|
|
1399
|
+
unattributed,
|
|
1400
|
+
}),
|
|
1401
|
+
exitKind: 'ok',
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async function handleCrashGet({ deviceBridge, fs: fsDep = fs }, id, opts = {}) {
|
|
1406
|
+
let report;
|
|
1407
|
+
try {
|
|
1408
|
+
report = await deviceBridge.getCrash(id);
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
return {
|
|
1411
|
+
envelope: fail(err.kind || 'device', err.message || String(err), err.hint || CRASH_LIST_HINT),
|
|
1412
|
+
exitKind: err.kind || 'device',
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// --out writes the FULL report and returns only its path. This is how a
|
|
1417
|
+
// tens-of-kilobytes native tombstone reaches a result file without passing
|
|
1418
|
+
// through the agent's context window.
|
|
1419
|
+
if (opts.out) {
|
|
1420
|
+
try {
|
|
1421
|
+
fsDep.writeFileSync(opts.out, report);
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
return {
|
|
1424
|
+
envelope: fail(
|
|
1425
|
+
'environment',
|
|
1426
|
+
`could not write the crash report to ${opts.out}: ${err.message}`,
|
|
1427
|
+
'Check the directory exists and is writable.'
|
|
1428
|
+
),
|
|
1429
|
+
exitKind: 'environment',
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
return { envelope: ok({ crash_id: id, path: opts.out, truncated: false }), exitKind: 'ok' };
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const lines = String(report).split('\n');
|
|
1436
|
+
const truncated = !opts.full && lines.length > CRASH_REPORT_HEAD_LINES;
|
|
1437
|
+
return {
|
|
1438
|
+
envelope: ok({
|
|
1439
|
+
crash_id: id,
|
|
1440
|
+
report: truncated ? lines.slice(0, CRASH_REPORT_HEAD_LINES).join('\n') : String(report),
|
|
1441
|
+
truncated,
|
|
1442
|
+
}),
|
|
1443
|
+
exitKind: 'ok',
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1121
1447
|
// ---------------------------------------------------------------------------
|
|
1122
1448
|
// Program wiring
|
|
1123
1449
|
// ---------------------------------------------------------------------------
|
|
@@ -1142,10 +1468,31 @@ function buildProgram(deps = {}) {
|
|
|
1142
1468
|
resultStoreFactory = (args) => new ResultStore(args),
|
|
1143
1469
|
// Factory for the cross-session memory store. Overridable in tests.
|
|
1144
1470
|
memoryStoreFactory = (args) => new MemoryStore(args),
|
|
1145
|
-
//
|
|
1146
|
-
|
|
1471
|
+
// Where the creating verbs (setup, init) write: always the directory the
|
|
1472
|
+
// user stands in, never a discovered ancestor. Defaults to an injected
|
|
1473
|
+
// projectRoot so tests that inject only a root keep writing there.
|
|
1474
|
+
cwd = deps.projectRoot !== undefined ? deps.projectRoot : process.cwd(),
|
|
1475
|
+
// The upward-discovery result `{ root, searched }` (#188): run() resolves
|
|
1476
|
+
// it once and passes it in, tests inject it wholesale, and a bare
|
|
1477
|
+
// buildProgram() discovers from cwd exactly as run() does. ONE fabrication
|
|
1478
|
+
// rule, stated once: a caller that injects ONLY a projectRoot is trusted
|
|
1479
|
+
// to know where the workspace is ({ root: projectRoot }) — the legacy
|
|
1480
|
+
// handler-test seam, and deliberately gate-arming. run() passes cwd +
|
|
1481
|
+
// workspace and NEVER projectRoot, so production cannot arm this branch
|
|
1482
|
+
// and silently disable the requireWorkspace gate.
|
|
1483
|
+
workspace = deps.projectRoot !== undefined
|
|
1484
|
+
? { root: deps.projectRoot, searched: [deps.projectRoot] }
|
|
1485
|
+
: findWorkspaceRoot(cwd),
|
|
1486
|
+
// Project root used to resolve mobile-automator/results/ — and, through the
|
|
1487
|
+
// emitters below, mobile-automator/.logs/. One root, one tree. The
|
|
1488
|
+
// discovered workspace when there is one; cwd otherwise, which is what the
|
|
1489
|
+
// tolerant verbs (guide, mcp, read-only device/session) have always used.
|
|
1490
|
+
projectRoot = workspace.root || cwd,
|
|
1491
|
+
// The exit paths. run() passes its own instance so the help/version and
|
|
1492
|
+
// fatal paths it owns share this program's resolved verb.
|
|
1493
|
+
emitters = makeEmitters({ projectRoot }),
|
|
1147
1494
|
// Sink used to emit the rendered envelope + drive the exit code.
|
|
1148
|
-
emit =
|
|
1495
|
+
emit = emitters.emit,
|
|
1149
1496
|
} = deps;
|
|
1150
1497
|
|
|
1151
1498
|
const program = new Command();
|
|
@@ -1184,6 +1531,31 @@ function buildProgram(deps = {}) {
|
|
|
1184
1531
|
});
|
|
1185
1532
|
program.configureOutput({ writeErr: () => {} });
|
|
1186
1533
|
|
|
1534
|
+
// Name the verb for the observability record, from commander's own resolution
|
|
1535
|
+
// rather than from argv. preAction fires only after a SUCCESSFUL parse and
|
|
1536
|
+
// only for a command that exists, which is what makes `verb` provably a name
|
|
1537
|
+
// out of the vocabulary this CLI ships — the property event.js's `sends: true`
|
|
1538
|
+
// classification asserts. Hooks registered on the root are inherited by every
|
|
1539
|
+
// subcommand, so one registration covers all of them.
|
|
1540
|
+
//
|
|
1541
|
+
// `actionCommand` is the innermost command (`get` in `config get mode`), so
|
|
1542
|
+
// walk up to the child of the root: the record names the verb, not its
|
|
1543
|
+
// subcommand, keeping the vocabulary the size of the verb list.
|
|
1544
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
1545
|
+
// Read --run-id off the INNERMOST command (e.g. `add-step`) before walking
|
|
1546
|
+
// up to name the verb (`result`). The three result verbs already require
|
|
1547
|
+
// it, so they correlate with no new flag and no environment variable; an
|
|
1548
|
+
// explicit flag beats the ambient MAUTO_RUN_ID because it is scoped to this
|
|
1549
|
+
// invocation and the environment is not.
|
|
1550
|
+
const opts = actionCommand.opts();
|
|
1551
|
+
emitters.setRunId(
|
|
1552
|
+
(typeof opts.runId === 'string' && opts.runId ? opts.runId : null) || resolveRunId(process.env)
|
|
1553
|
+
);
|
|
1554
|
+
let command = actionCommand;
|
|
1555
|
+
while (command.parent && command.parent.parent) command = command.parent;
|
|
1556
|
+
emitters.setVerb(command.name());
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1187
1559
|
const humanFlag = () => Boolean(program.opts().human);
|
|
1188
1560
|
|
|
1189
1561
|
// The single envelope boundary. Wrap every `.action(...)` callback so any
|
|
@@ -1203,6 +1575,20 @@ function buildProgram(deps = {}) {
|
|
|
1203
1575
|
}
|
|
1204
1576
|
};
|
|
1205
1577
|
|
|
1578
|
+
// Gate for verbs whose whole job is reading or writing the workspace. With
|
|
1579
|
+
// no workspace discovered, carrying on against cwd is what produced #188's
|
|
1580
|
+
// confident wrong answers — `ok:true` with a value silently absent, an
|
|
1581
|
+
// "empty" memory store, a result file written where nobody will look — and
|
|
1582
|
+
// `config set` would even create a stray workspace. Fail environment
|
|
1583
|
+
// (exit 5) naming where we looked, so the agent can recover.
|
|
1584
|
+
const requireWorkspace = (action) => (...args) => {
|
|
1585
|
+
if (!workspace.root) {
|
|
1586
|
+
emit(noWorkspaceFailure(workspace.searched), humanFlag());
|
|
1587
|
+
return undefined;
|
|
1588
|
+
}
|
|
1589
|
+
return action(...args);
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1206
1592
|
// Resolve which device a verb targets: explicit --device wins, else the
|
|
1207
1593
|
// persisted selection, else null (mobile-mcp auto-selects a single device).
|
|
1208
1594
|
// Keeping the no-selection fast path returning null preserves zero-config use.
|
|
@@ -1221,13 +1607,55 @@ function buildProgram(deps = {}) {
|
|
|
1221
1607
|
let bridge;
|
|
1222
1608
|
let close;
|
|
1223
1609
|
try {
|
|
1224
|
-
|
|
1610
|
+
// autostart: the daemon is a per-workspace artifact — its .session/ tree
|
|
1611
|
+
// and logs live under mobile-automator/. With no discovered workspace
|
|
1612
|
+
// there is nowhere legitimate to put one, so never autostart: the verb
|
|
1613
|
+
// connects one-shot and creates nothing. `ok:true` plus a phantom
|
|
1614
|
+
// workspace is the exact #188 pathology.
|
|
1615
|
+
({ bridge, close } = await deviceBridgeFactory({
|
|
1616
|
+
device,
|
|
1617
|
+
projectRoot,
|
|
1618
|
+
autostart: Boolean(workspace.root),
|
|
1619
|
+
}));
|
|
1620
|
+
// AFTER the connect, deliberately: the handle is written when the daemon
|
|
1621
|
+
// starts listening, so before this point it may legitimately not exist
|
|
1622
|
+
// yet. Best-effort — readSessionId never throws and returns null for an
|
|
1623
|
+
// absent or malformed handle, which is a normal state (a one-shot
|
|
1624
|
+
// fallback connection has no daemon at all).
|
|
1625
|
+
emitters.setSessionId(readSessionId(projectRoot));
|
|
1225
1626
|
} catch (err) {
|
|
1226
1627
|
emit(deviceFail(err), humanFlag());
|
|
1227
1628
|
return;
|
|
1228
1629
|
}
|
|
1229
1630
|
try {
|
|
1230
1631
|
const r = await fn(bridge);
|
|
1632
|
+
// The ONE window where a device failure can still be investigated: after
|
|
1633
|
+
// fn(bridge) has produced its verdict and before `finally` closes the
|
|
1634
|
+
// connection. The connect-failure catch above cannot do this — there is
|
|
1635
|
+
// no bridge there — which is why this is here and not wrapped around the
|
|
1636
|
+
// whole function.
|
|
1637
|
+
//
|
|
1638
|
+
// The return value of BOTH calls is deliberately unused. `r` reaches
|
|
1639
|
+
// emit() untouched (aside from probeCrashes's own in-place, additive
|
|
1640
|
+
// mutation of r.envelope) whether either helper succeeded, failed, or
|
|
1641
|
+
// never ran; neither may mask or replace the error the caller actually
|
|
1642
|
+
// asked about. Probe before screenshot: the probe has its own short,
|
|
1643
|
+
// gated deadline and must not sit behind the screenshot's device round
|
|
1644
|
+
// trip.
|
|
1645
|
+
await probeCrashes({
|
|
1646
|
+
bridge,
|
|
1647
|
+
envelope: r.envelope,
|
|
1648
|
+
verb: emitters.getVerb(),
|
|
1649
|
+
projectRoot,
|
|
1650
|
+
watermark: sessionWatermark(projectRoot),
|
|
1651
|
+
});
|
|
1652
|
+
await captureOnFailure({
|
|
1653
|
+
bridge,
|
|
1654
|
+
result: r,
|
|
1655
|
+
projectRoot,
|
|
1656
|
+
runId: emitters.getRunId(),
|
|
1657
|
+
verb: emitters.getVerb(),
|
|
1658
|
+
});
|
|
1231
1659
|
emit(r, humanFlag());
|
|
1232
1660
|
} finally {
|
|
1233
1661
|
if (typeof close === 'function') await close();
|
|
@@ -1382,7 +1810,7 @@ function buildProgram(deps = {}) {
|
|
|
1382
1810
|
[]
|
|
1383
1811
|
)
|
|
1384
1812
|
.option('--capture <name=value>', 'captured variable; repeatable', collect, [])
|
|
1385
|
-
.action(withEnvelope((opts) => {
|
|
1813
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1386
1814
|
const r = handleResultAddStep(
|
|
1387
1815
|
{ resultStoreFactory, projectRoot },
|
|
1388
1816
|
{
|
|
@@ -1398,7 +1826,7 @@ function buildProgram(deps = {}) {
|
|
|
1398
1826
|
}
|
|
1399
1827
|
);
|
|
1400
1828
|
emit(r, humanFlag());
|
|
1401
|
-
}));
|
|
1829
|
+
})));
|
|
1402
1830
|
|
|
1403
1831
|
result
|
|
1404
1832
|
.command('add-assertion')
|
|
@@ -1412,7 +1840,7 @@ function buildProgram(deps = {}) {
|
|
|
1412
1840
|
.option('--message <text>', 'human-readable justification for the verdict')
|
|
1413
1841
|
.option('--expected <v>', 'expected value')
|
|
1414
1842
|
.option('--actual <v>', 'observed value')
|
|
1415
|
-
.action(withEnvelope((opts) => {
|
|
1843
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1416
1844
|
const r = handleResultAddAssertion(
|
|
1417
1845
|
{ resultStoreFactory, projectRoot },
|
|
1418
1846
|
{
|
|
@@ -1428,7 +1856,35 @@ function buildProgram(deps = {}) {
|
|
|
1428
1856
|
}
|
|
1429
1857
|
);
|
|
1430
1858
|
emit(r, humanFlag());
|
|
1431
|
-
}));
|
|
1859
|
+
})));
|
|
1860
|
+
|
|
1861
|
+
result
|
|
1862
|
+
.command('add-crash')
|
|
1863
|
+
.description('Record an app crash observed during the run')
|
|
1864
|
+
.requiredOption('--run-id <id>', 'run identifier (run_YYYYMMDD_HHMMSS)')
|
|
1865
|
+
.option('--scenario-id <id>', 'scenario identifier')
|
|
1866
|
+
.requiredOption('--crash-id <id>', 'device-local report id (from `mauto crash list`)')
|
|
1867
|
+
.option('--step-id <id>', 'step in flight when the crash was observed')
|
|
1868
|
+
.option('--process <name>', 'package/bundle identifier of the process that died')
|
|
1869
|
+
.option('--crash-timestamp <iso>', "the device's own time for the report")
|
|
1870
|
+
.option('--excerpt <text>', 'head of the crash report (stored capped at 2000 chars)')
|
|
1871
|
+
.option('--report-path <path>', 'path to the full report written by `mauto crash get --out`')
|
|
1872
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1873
|
+
const r = handleResultAddCrash(
|
|
1874
|
+
{ resultStoreFactory, projectRoot },
|
|
1875
|
+
{
|
|
1876
|
+
runId: opts.runId,
|
|
1877
|
+
scenarioId: opts.scenarioId,
|
|
1878
|
+
crashId: opts.crashId,
|
|
1879
|
+
stepId: opts.stepId,
|
|
1880
|
+
process: opts.process,
|
|
1881
|
+
crashTimestamp: opts.crashTimestamp,
|
|
1882
|
+
excerpt: opts.excerpt,
|
|
1883
|
+
reportPath: opts.reportPath,
|
|
1884
|
+
}
|
|
1885
|
+
);
|
|
1886
|
+
emit(r, humanFlag());
|
|
1887
|
+
})));
|
|
1432
1888
|
|
|
1433
1889
|
result
|
|
1434
1890
|
.command('finalize')
|
|
@@ -1442,7 +1898,7 @@ function buildProgram(deps = {}) {
|
|
|
1442
1898
|
.option('--api-level <v>', 'OS API level / version')
|
|
1443
1899
|
.option('--environment <v>', "target environment (e.g. 'staging')")
|
|
1444
1900
|
.option('--summary <text>', 'override the generated one-line result summary')
|
|
1445
|
-
.action(withEnvelope((opts) => {
|
|
1901
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1446
1902
|
const r = handleResultFinalize(
|
|
1447
1903
|
{ resultStoreFactory, memoryStoreFactory, projectRoot },
|
|
1448
1904
|
{
|
|
@@ -1458,7 +1914,7 @@ function buildProgram(deps = {}) {
|
|
|
1458
1914
|
}
|
|
1459
1915
|
);
|
|
1460
1916
|
emit(r, humanFlag());
|
|
1461
|
-
}));
|
|
1917
|
+
})));
|
|
1462
1918
|
|
|
1463
1919
|
// --- Slice 3 verbs --------------------------------------------------------
|
|
1464
1920
|
|
|
@@ -1467,7 +1923,7 @@ function buildProgram(deps = {}) {
|
|
|
1467
1923
|
// anything else is an envelope error and goes through the normal emit path.
|
|
1468
1924
|
const emitMaybeRaw = (r) => {
|
|
1469
1925
|
if (r.raw !== undefined && r.exitKind === 'ok') {
|
|
1470
|
-
emitRaw(r.raw, r.exitKind);
|
|
1926
|
+
emitters.emitRaw(r.raw, r.exitKind);
|
|
1471
1927
|
} else {
|
|
1472
1928
|
emit(r, humanFlag());
|
|
1473
1929
|
}
|
|
@@ -1478,7 +1934,10 @@ function buildProgram(deps = {}) {
|
|
|
1478
1934
|
.description('Scaffold the workspace (mobile-automator/) and write a config')
|
|
1479
1935
|
.option('--mode <mode>', 'aware (platform-aware, default) | agnostic (platform-agnostic)')
|
|
1480
1936
|
.action(withEnvelope((opts) => {
|
|
1481
|
-
|
|
1937
|
+
// cwd, not the discovered root: setup creates a workspace where the user
|
|
1938
|
+
// stands. A discovered root that is not cwd is an ancestor worth naming.
|
|
1939
|
+
const ancestorRoot = workspace.root && workspace.root !== cwd ? workspace.root : null;
|
|
1940
|
+
const r = handleSetup({ projectRoot: cwd, ancestorRoot }, { mode: opts.mode });
|
|
1482
1941
|
emit(r, humanFlag());
|
|
1483
1942
|
}));
|
|
1484
1943
|
|
|
@@ -1486,11 +1945,11 @@ function buildProgram(deps = {}) {
|
|
|
1486
1945
|
config
|
|
1487
1946
|
.command('get <key>')
|
|
1488
1947
|
.description('Get a dotted-path config value')
|
|
1489
|
-
.action(withEnvelope((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag())));
|
|
1948
|
+
.action(withEnvelope(requireWorkspace((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag()))));
|
|
1490
1949
|
config
|
|
1491
1950
|
.command('set <key> <value>')
|
|
1492
1951
|
.description('Set a dotted-path config value (coerced to its declared type per `mauto schema config`)')
|
|
1493
|
-
.action(withEnvelope((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag())));
|
|
1952
|
+
.action(withEnvelope(requireWorkspace((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag()))));
|
|
1494
1953
|
|
|
1495
1954
|
program
|
|
1496
1955
|
.command('guide <topic>')
|
|
@@ -1516,28 +1975,28 @@ function buildProgram(deps = {}) {
|
|
|
1516
1975
|
.description('Print RAW memory markdown (run-history[, app-knowledge, preferences])')
|
|
1517
1976
|
.option('--kind <k>', 'run-history | app-knowledge | preferences')
|
|
1518
1977
|
.option('--scenario <id>', 'filter run-history to one scenario')
|
|
1519
|
-
.action(withEnvelope((opts) =>
|
|
1978
|
+
.action(withEnvelope(requireWorkspace((opts) =>
|
|
1520
1979
|
emitMaybeRaw(
|
|
1521
1980
|
handleMemoryShow({ memoryStoreFactory, projectRoot }, { kind: opts.kind, scenario: opts.scenario })
|
|
1522
1981
|
)
|
|
1523
|
-
));
|
|
1982
|
+
)));
|
|
1524
1983
|
|
|
1525
1984
|
memory
|
|
1526
1985
|
.command('add <text>')
|
|
1527
1986
|
.description('Record an agent-authored memory entry (app-knowledge or preferences)')
|
|
1528
1987
|
.requiredOption('--kind <k>', 'app-knowledge | preferences')
|
|
1529
|
-
.action(withEnvelope((text, opts) =>
|
|
1988
|
+
.action(withEnvelope(requireWorkspace((text, opts) =>
|
|
1530
1989
|
emit(handleMemoryAdd({ memoryStoreFactory, projectRoot }, { kind: opts.kind, text }), humanFlag())
|
|
1531
|
-
));
|
|
1990
|
+
)));
|
|
1532
1991
|
|
|
1533
1992
|
memory
|
|
1534
1993
|
.command('forget')
|
|
1535
1994
|
.description('Remove agent-authored memory entries whose text contains a substring')
|
|
1536
1995
|
.requiredOption('--kind <k>', 'app-knowledge | preferences')
|
|
1537
1996
|
.requiredOption('--match <substr>', 'remove entries containing this substring')
|
|
1538
|
-
.action(withEnvelope((opts) =>
|
|
1997
|
+
.action(withEnvelope(requireWorkspace((opts) =>
|
|
1539
1998
|
emit(handleMemoryForget({ memoryStoreFactory, projectRoot }, { kind: opts.kind, match: opts.match }), humanFlag())
|
|
1540
|
-
));
|
|
1999
|
+
)));
|
|
1541
2000
|
|
|
1542
2001
|
// --- Slice 7: vendor init + MCP prompts server ---------------------------
|
|
1543
2002
|
|
|
@@ -1546,7 +2005,9 @@ function buildProgram(deps = {}) {
|
|
|
1546
2005
|
.description('Install native Agent Skills (+ slash commands/rules + MCP entry) for an agent')
|
|
1547
2006
|
.requiredOption('--agent <name>', 'claude | cursor | gemini | copilot | agents | all')
|
|
1548
2007
|
.action(withEnvelope((opts) => {
|
|
1549
|
-
|
|
2008
|
+
// cwd, as before discovery existed: init writes host files where the
|
|
2009
|
+
// user invoked it.
|
|
2010
|
+
const r = handleInit({ projectRoot: cwd }, opts.agent);
|
|
1550
2011
|
emit(r, humanFlag());
|
|
1551
2012
|
}));
|
|
1552
2013
|
|
|
@@ -1561,10 +2022,10 @@ function buildProgram(deps = {}) {
|
|
|
1561
2022
|
.description('Start the device session daemon (subsequent verbs reuse its connection)')
|
|
1562
2023
|
.option('--device <id>', 'pin the daemon to a target device id')
|
|
1563
2024
|
.option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
|
|
1564
|
-
.action(withEnvelope(async (opts) => {
|
|
2025
|
+
.action(withEnvelope(requireWorkspace(async (opts) => {
|
|
1565
2026
|
const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
|
|
1566
2027
|
emit(r, humanFlag());
|
|
1567
|
-
}));
|
|
2028
|
+
})));
|
|
1568
2029
|
|
|
1569
2030
|
session
|
|
1570
2031
|
.command('status')
|
|
@@ -1596,11 +2057,11 @@ function buildProgram(deps = {}) {
|
|
|
1596
2057
|
devices
|
|
1597
2058
|
.command('use <id>')
|
|
1598
2059
|
.description('Persist a device selection so subsequent verbs reuse it (--device still overrides per-call)')
|
|
1599
|
-
.action(withEnvelope((id) =>
|
|
2060
|
+
.action(withEnvelope(requireWorkspace((id) =>
|
|
1600
2061
|
connectBridge(null, (bridge) =>
|
|
1601
2062
|
handleDevicesUse({ deviceBridge: bridge, projectRoot }, id)
|
|
1602
2063
|
)
|
|
1603
|
-
));
|
|
2064
|
+
)));
|
|
1604
2065
|
|
|
1605
2066
|
devices
|
|
1606
2067
|
.command('clear')
|
|
@@ -1610,6 +2071,69 @@ function buildProgram(deps = {}) {
|
|
|
1610
2071
|
emit(r, humanFlag());
|
|
1611
2072
|
}));
|
|
1612
2073
|
|
|
2074
|
+
const crash = program
|
|
2075
|
+
.command('crash')
|
|
2076
|
+
.description('Diagnostics: crash reports currently readable on the device');
|
|
2077
|
+
|
|
2078
|
+
crash
|
|
2079
|
+
.command('list')
|
|
2080
|
+
.description('List crash reports, scoped to the current device session by default')
|
|
2081
|
+
.option('--device <id>', 'target device id')
|
|
2082
|
+
.option('--since <iso>', 'only reports at or after this ISO timestamp (default: session start)')
|
|
2083
|
+
.action(withEnvelope((opts) =>
|
|
2084
|
+
connectBridge(resolveVerbDevice(opts.device), (bridge) =>
|
|
2085
|
+
handleCrashList(
|
|
2086
|
+
{ deviceBridge: bridge, projectRoot },
|
|
2087
|
+
opts.since === undefined ? {} : { since: opts.since }
|
|
2088
|
+
)
|
|
2089
|
+
)
|
|
2090
|
+
));
|
|
2091
|
+
|
|
2092
|
+
crash
|
|
2093
|
+
.command('get <id>')
|
|
2094
|
+
.description('Fetch one crash report (head by default)')
|
|
2095
|
+
.option('--device <id>', 'target device id')
|
|
2096
|
+
.option('--full', 'return the whole report instead of its head')
|
|
2097
|
+
.option('--out <path>', 'write the full report to a file and return its path')
|
|
2098
|
+
.action(withEnvelope((id, opts) =>
|
|
2099
|
+
connectBridge(resolveVerbDevice(opts.device), (bridge) =>
|
|
2100
|
+
handleCrashGet({ deviceBridge: bridge }, id, { full: opts.full, out: opts.out })
|
|
2101
|
+
)
|
|
2102
|
+
));
|
|
2103
|
+
|
|
2104
|
+
// --- Slice 5: telemetry ---------------------------------------------------
|
|
2105
|
+
|
|
2106
|
+
const telemetryCmd = program
|
|
2107
|
+
.command('telemetry')
|
|
2108
|
+
.description('Inspect and control anonymous usage telemetry (off by default)');
|
|
2109
|
+
|
|
2110
|
+
// requireWorkspace, same as config get/set: telemetry status/enable/disable
|
|
2111
|
+
// all read or write mobile-automator/config.json, and flush reads/claims
|
|
2112
|
+
// mobile-automator/.logs/telemetry.spool, so all four have #188's exact
|
|
2113
|
+
// failure mode without the gate — status would report the SAME
|
|
2114
|
+
// not_configured/no_token answer for "no workspace here" as for "a real,
|
|
2115
|
+
// just-unconfigured workspace", and enable/disable would create a stray
|
|
2116
|
+
// mobile-automator/ wherever the caller happened to be standing.
|
|
2117
|
+
telemetryCmd
|
|
2118
|
+
.command('status')
|
|
2119
|
+
.description('Report whether telemetry is on, what would be sent, and what is queued')
|
|
2120
|
+
.action(withEnvelope(requireWorkspace(() => emit(handleTelemetryStatus({ projectRoot }), humanFlag()))));
|
|
2121
|
+
|
|
2122
|
+
telemetryCmd
|
|
2123
|
+
.command('enable')
|
|
2124
|
+
.description('Turn on anonymous usage telemetry')
|
|
2125
|
+
.action(withEnvelope(requireWorkspace(() => emit(handleTelemetryEnable({ projectRoot }), humanFlag()))));
|
|
2126
|
+
|
|
2127
|
+
telemetryCmd
|
|
2128
|
+
.command('disable')
|
|
2129
|
+
.description('Turn off anonymous usage telemetry')
|
|
2130
|
+
.action(withEnvelope(requireWorkspace(() => emit(handleTelemetryDisable({ projectRoot }), humanFlag()))));
|
|
2131
|
+
|
|
2132
|
+
telemetryCmd
|
|
2133
|
+
.command('flush')
|
|
2134
|
+
.description('Upload any spooled telemetry now instead of waiting for the daemon')
|
|
2135
|
+
.action(withEnvelope(requireWorkspace(async () => emit(await handleTelemetryFlush({ projectRoot }), humanFlag()))));
|
|
2136
|
+
|
|
1613
2137
|
program
|
|
1614
2138
|
.command('mcp')
|
|
1615
2139
|
.description('Run the MCP prompts server (stdio) exposing the mauto workflows as prompts')
|
|
@@ -1623,18 +2147,6 @@ function buildProgram(deps = {}) {
|
|
|
1623
2147
|
return program;
|
|
1624
2148
|
}
|
|
1625
2149
|
|
|
1626
|
-
function defaultEmit({ envelope, exitKind }, human) {
|
|
1627
|
-
process.stdout.write(render(envelope, { human }) + '\n');
|
|
1628
|
-
process.exit(exitCodeFor(exitKind));
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
// Print raw content (markdown / JSON schema / text) verbatim — no envelope
|
|
1632
|
-
// wrapping — then exit. Used by guide/schema/bootstrap success paths.
|
|
1633
|
-
function emitRaw(content, exitKind) {
|
|
1634
|
-
process.stdout.write(content.endsWith('\n') ? content : content + '\n');
|
|
1635
|
-
process.exit(exitCodeFor(exitKind));
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
2150
|
// Keep the stack of a genuinely-unexpected error on STDERR so a crash stays
|
|
1639
2151
|
// debuggable. The stdout envelope (the agent's contract) is untouched, and the
|
|
1640
2152
|
// expected/diagnosed classes (invalid_input, environment) carry an actionable
|
|
@@ -1645,19 +2157,146 @@ function diagnose(err, exitKind) {
|
|
|
1645
2157
|
}
|
|
1646
2158
|
}
|
|
1647
2159
|
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
process.
|
|
2160
|
+
// The exit paths, bound to ONE invocation's resolved dependencies. Per
|
|
2161
|
+
// invocation rather than module-scope because every input they need is: as
|
|
2162
|
+
// module-level functions, finish() could not see the projectRoot buildProgram()
|
|
2163
|
+
// was handed, so it reached around the program's own DI to process.cwd() and
|
|
2164
|
+
// wrote the record of a verb into a different tree than the verb ran against.
|
|
2165
|
+
function makeEmitters({ projectRoot = process.cwd() } = {}) {
|
|
2166
|
+
// The top-level command commander actually RESOLVED, or null when parsing
|
|
2167
|
+
// never got that far (a parse failure, or help/version). Set via setVerb()
|
|
2168
|
+
// from buildProgram()'s preAction hook.
|
|
2169
|
+
//
|
|
2170
|
+
// Deliberately not `process.argv[2]`: event.js marks `verb` as `sends: true`
|
|
2171
|
+
// on the grounds that it is "a fixed vocabulary we ship", and slice 5 ships
|
|
2172
|
+
// it to a third party on that justification. argv[2] is arbitrary user text —
|
|
2173
|
+
// `mauto --human config get mode` recorded `--human`.
|
|
2174
|
+
let resolvedVerb = null;
|
|
2175
|
+
|
|
2176
|
+
// The run this invocation belongs to, and the daemon lifetime it used.
|
|
2177
|
+
//
|
|
2178
|
+
// Both follow resolvedVerb's pattern for resolvedVerb's reason: finish() is
|
|
2179
|
+
// closed over this invocation's state, and every exit path reaches it
|
|
2180
|
+
// through commander's own call stack, so there is nowhere else to put them.
|
|
2181
|
+
//
|
|
2182
|
+
// resolvedRunId is NOT validated here. It becomes a filename in exactly one
|
|
2183
|
+
// place — runTracePath — which refuses anything unsafe and returns null, so
|
|
2184
|
+
// a second predicate here could only disagree with that one.
|
|
2185
|
+
let resolvedRunId = null;
|
|
2186
|
+
// Set ONLY by connectBridge, and only after a successful connect. That is
|
|
2187
|
+
// what makes `session_id` on a CLI event mean "this verb reached the device
|
|
2188
|
+
// through that daemon".
|
|
2189
|
+
let resolvedSessionId = null;
|
|
2190
|
+
|
|
2191
|
+
// THE process-ending path: every way this CLI terminates goes through here,
|
|
2192
|
+
// so "is this invocation observable" has exactly one answer. Only `mauto mcp`
|
|
2193
|
+
// is outside it — it serves until its client disconnects and then returns,
|
|
2194
|
+
// with no termination to record.
|
|
2195
|
+
//
|
|
2196
|
+
// One function rather than four copies of a ten-line record() literal. Two of
|
|
2197
|
+
// the four got written, and the classes left unrecorded were parse failures
|
|
2198
|
+
// (#146) — the crashes this instrumentation exists to see — and help/version.
|
|
2199
|
+
//
|
|
2200
|
+
// `text` is optional: commander writes help/version itself, so that path has
|
|
2201
|
+
// an invocation to record but nothing left to print.
|
|
2202
|
+
//
|
|
2203
|
+
// dur_ms uses process.uptime() (seconds since THIS process started, no state
|
|
2204
|
+
// needed). A Date.now() stamped at require time would measure from whenever
|
|
2205
|
+
// cli.js was loaded, which is not process start for anything that requires it
|
|
2206
|
+
// first (a test harness, bin/mauto.js's own guards).
|
|
2207
|
+
function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
|
|
2208
|
+
// The flag-resolved id when a command parsed, the environment otherwise: a
|
|
2209
|
+
// parse failure reaches no preAction hook and so has no flag, but it is
|
|
2210
|
+
// still part of the run and is exactly the class of failure (#146) this
|
|
2211
|
+
// instrumentation exists to see.
|
|
2212
|
+
const runId = resolvedRunId || resolveRunId(process.env);
|
|
2213
|
+
// null for an id that cannot safely name a file. Every hostile
|
|
2214
|
+
// MAUTO_RUN_ID lands here as "no trace" — but NOT as "no run_id below":
|
|
2215
|
+
// the raw id is still recorded on the mauto.ndjson line, so a refused id
|
|
2216
|
+
// is provably distinguishable from one never exported at all (no field).
|
|
2217
|
+
const tracePath = runTracePath(projectRoot, runId);
|
|
2218
|
+
|
|
2219
|
+
// Record BEFORE the write: process.exit() below is immediate.
|
|
2220
|
+
const fields = {
|
|
2221
|
+
event: 'verb.end',
|
|
2222
|
+
// `info`, not `warn`-on-failure: the failure is carried by `ok` and
|
|
2223
|
+
// `error_kind`, and the LEVEL only decides routing. At `warn` every
|
|
2224
|
+
// failed verb would clear the stderr threshold and echo the envelope the
|
|
2225
|
+
// CLI just wrote. stderr stays a human's channel; the file sink
|
|
2226
|
+
// (threshold `info`) keeps the forensic record either way.
|
|
2227
|
+
level: 'info',
|
|
2228
|
+
src: 'cli',
|
|
2229
|
+
// Absent, not guessed, when no command resolved. makeEvent drops
|
|
2230
|
+
// undefined fields, so the line carries no `verb` rather than a lie.
|
|
2231
|
+
verb: resolvedVerb || undefined,
|
|
2232
|
+
// Recorded even when the id could not name a trace file: the
|
|
2233
|
+
// correlation still belongs in mauto.ndjson, where it is the only thing
|
|
2234
|
+
// tying this line to a run. sends:false in the event catalog.
|
|
2235
|
+
run_id: runId || undefined,
|
|
2236
|
+
session_id: resolvedSessionId || undefined,
|
|
2237
|
+
ok,
|
|
2238
|
+
error_kind: errorKind,
|
|
2239
|
+
exit_code: exitCode,
|
|
2240
|
+
dur_ms: Math.round(process.uptime() * 1000),
|
|
2241
|
+
};
|
|
2242
|
+
record(fields, { projectRoot, tracePath: tracePath || undefined });
|
|
2243
|
+
if (text) process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
2244
|
+
process.exit(exitCode);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
const fromEnvelope = (envelope, exitKind, human) => ({
|
|
2248
|
+
text: render(envelope, { human }),
|
|
2249
|
+
exitKind,
|
|
2250
|
+
ok: Boolean(envelope && envelope.ok),
|
|
2251
|
+
errorKind: envelope && envelope.error ? envelope.error.kind : undefined,
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
return {
|
|
2255
|
+
finish,
|
|
2256
|
+
setVerb: (verb) => {
|
|
2257
|
+
resolvedVerb = verb;
|
|
2258
|
+
},
|
|
2259
|
+
setRunId: (runId) => {
|
|
2260
|
+
resolvedRunId = runId;
|
|
2261
|
+
},
|
|
2262
|
+
setSessionId: (sessionId) => {
|
|
2263
|
+
resolvedSessionId = sessionId;
|
|
2264
|
+
},
|
|
2265
|
+
// Read-only counterparts to the setters above, for the one other call site
|
|
2266
|
+
// that needs this invocation's resolved run id / verb: connectBridge's
|
|
2267
|
+
// screenshot-on-failure hook. Both values are already committed by the
|
|
2268
|
+
// preAction hook by the time any verb's fn(bridge) can have returned, so
|
|
2269
|
+
// there is nothing to resolve here — only to expose.
|
|
2270
|
+
getRunId: () => resolvedRunId,
|
|
2271
|
+
getVerb: () => resolvedVerb,
|
|
2272
|
+
|
|
2273
|
+
emit: ({ envelope, exitKind }, human) => finish(fromEnvelope(envelope, exitKind, human)),
|
|
2274
|
+
|
|
2275
|
+
// Raw content (markdown / JSON schema / text) verbatim, no envelope
|
|
2276
|
+
// wrapping. Used by the guide/schema/bootstrap success paths.
|
|
2277
|
+
emitRaw: (content, exitKind) => finish({ text: content, exitKind, ok: true }),
|
|
2278
|
+
|
|
2279
|
+
// Last-resort emit for errors that escape BEFORE any action runs, so the
|
|
2280
|
+
// in-program withEnvelope boundary can't see them — a throw inside
|
|
2281
|
+
// buildProgram, or a stray unhandled rejection. One JSON envelope on stdout
|
|
2282
|
+
// + the mapped exit code, never a raw stack trace.
|
|
2283
|
+
emitFatal: (err, human = false) => {
|
|
2284
|
+
const { envelope, exitKind } = toEnvelope(err);
|
|
2285
|
+
diagnose(err, exitKind);
|
|
2286
|
+
finish(fromEnvelope(envelope, exitKind, human));
|
|
2287
|
+
},
|
|
2288
|
+
};
|
|
1659
2289
|
}
|
|
1660
2290
|
|
|
2291
|
+
// bin/mauto.js registers the exported emitFatal as a PROCESS-wide
|
|
2292
|
+
// unhandledRejection handler, which can fire before run() has built anything —
|
|
2293
|
+
// so it needs a module-level way to reach the invocation in flight. A pointer to
|
|
2294
|
+
// the per-invocation instance, not a second copy of its state: an unhandled
|
|
2295
|
+
// rejection is exactly the crash class this instrumentation exists to see, and
|
|
2296
|
+
// it should carry the verb and projectRoot the invocation actually resolved.
|
|
2297
|
+
let activeEmitters = null;
|
|
2298
|
+
const emitFatal = (err, human = false) => (activeEmitters || makeEmitters()).emitFatal(err, human);
|
|
2299
|
+
|
|
1661
2300
|
async function run(argv) {
|
|
1662
2301
|
// Belt-and-suspenders around the in-program withEnvelope boundary: a throw in
|
|
1663
2302
|
// buildProgram() (e.g. the ScenarioValidator default-param compiling a corrupt
|
|
@@ -1666,21 +2305,40 @@ async function run(argv) {
|
|
|
1666
2305
|
// `unhandledRejection` guard lives in bin/mauto.js (the process entry point),
|
|
1667
2306
|
// not here, so calling run() in tests never installs a global exit-on-reject
|
|
1668
2307
|
// listener that would leak across the suite.
|
|
2308
|
+
//
|
|
2309
|
+
// The workspace is resolved ONCE, here, and the same root reaches both the
|
|
2310
|
+
// emitters (the .logs/ file sink and run traces) and every handler — and,
|
|
2311
|
+
// through connection.js, the session daemon's MAUTO_SESSION_PROJECT_ROOT.
|
|
2312
|
+
// No workspace found falls back to cwd for the verbs that tolerate that;
|
|
2313
|
+
// buildProgram fails the ones that cannot (#188).
|
|
2314
|
+
const cwd = process.cwd();
|
|
2315
|
+
const workspace = findWorkspaceRoot(cwd);
|
|
2316
|
+
const projectRoot = workspace.root || cwd;
|
|
2317
|
+
const emitters = makeEmitters({ projectRoot });
|
|
2318
|
+
activeEmitters = emitters;
|
|
1669
2319
|
try {
|
|
1670
|
-
|
|
2320
|
+
// No projectRoot here, deliberately: buildProgram derives it as
|
|
2321
|
+
// `workspace.root || cwd`, and an injected projectRoot is the test seam
|
|
2322
|
+
// that FABRICATES a workspace — passing one would arm that branch and
|
|
2323
|
+
// silently disable the requireWorkspace gate.
|
|
2324
|
+
const program = buildProgram({ emitters, cwd, workspace });
|
|
1671
2325
|
await program.parseAsync(argv);
|
|
1672
2326
|
} catch (err) {
|
|
1673
2327
|
// Help/version are NOT errors: commander already wrote the human-readable
|
|
1674
|
-
// text to stdout
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
2328
|
+
// text to stdout, so they get no envelope wrapped around them (#146). Same
|
|
2329
|
+
// COMMANDER_NON_FAILURES set toEnvelope classifies against, so "which
|
|
2330
|
+
// commander outcomes aren't failures" has exactly one answer.
|
|
2331
|
+
//
|
|
2332
|
+
// They still exit through finish(), with no text of their own to print.
|
|
2333
|
+
// They are real invocations, and slice 5 computes rates off this stream —
|
|
2334
|
+
// omitting a whole invocation class skews every one of those denominators.
|
|
2335
|
+
// `exitCode` comes from commander rather than a kind because commander
|
|
2336
|
+
// already computed one: `.help({error:true})` yields 1, not 0.
|
|
1677
2337
|
if (err && err.name === 'CommanderError' && COMMANDER_NON_FAILURES.has(err.code)) {
|
|
1678
|
-
|
|
1679
|
-
// invoked with `{error:true}` computes 1.
|
|
1680
|
-
process.exit(err.exitCode || 0);
|
|
2338
|
+
emitters.finish({ exitCode: err.exitCode || 0, ok: true });
|
|
1681
2339
|
return;
|
|
1682
2340
|
}
|
|
1683
|
-
emitFatal(err, argv.slice(2).includes('--human'));
|
|
2341
|
+
emitters.emitFatal(err, argv.slice(2).includes('--human'));
|
|
1684
2342
|
}
|
|
1685
2343
|
}
|
|
1686
2344
|
|
|
@@ -1707,6 +2365,7 @@ module.exports = {
|
|
|
1707
2365
|
handleAssert,
|
|
1708
2366
|
handleResultAddStep,
|
|
1709
2367
|
handleResultAddAssertion,
|
|
2368
|
+
handleResultAddCrash,
|
|
1710
2369
|
handleResultFinalize,
|
|
1711
2370
|
handleSetup,
|
|
1712
2371
|
handleConfigGet,
|
|
@@ -1724,4 +2383,10 @@ module.exports = {
|
|
|
1724
2383
|
handleDevices,
|
|
1725
2384
|
handleDevicesUse,
|
|
1726
2385
|
handleDevicesClear,
|
|
2386
|
+
handleTelemetryStatus,
|
|
2387
|
+
handleTelemetryEnable,
|
|
2388
|
+
handleTelemetryDisable,
|
|
2389
|
+
handleTelemetryFlush,
|
|
2390
|
+
handleCrashList,
|
|
2391
|
+
handleCrashGet,
|
|
1727
2392
|
};
|