mobile-automator 0.24.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 +27 -4
- package/bin/mauto-session-daemon.js +85 -16
- package/package.json +4 -3
- 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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The READ side of a run trace: turn the NDJSON a run left behind into the
|
|
4
|
+
// handful of facts `result finalize` is allowed to state as measured.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately separate from the sink that writes it, because the two have
|
|
7
|
+
// opposite constraints. Writing happens on every verb and must be as close to
|
|
8
|
+
// free as an append can be. Reading happens once, in finalize, and can afford
|
|
9
|
+
// to parse the whole file. Nothing here is ever called on the hot path — which
|
|
10
|
+
// is also why the directory prune lives here rather than in the sink.
|
|
11
|
+
|
|
12
|
+
const realFs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const { atCap } = require('./sinks/file');
|
|
16
|
+
const { logsDir, runTracePath, RUN_TRACE_PREFIX } = require('./paths');
|
|
17
|
+
|
|
18
|
+
// A device failure in the envelope's own taxonomy. `deviceFail` (src/cli.js) is
|
|
19
|
+
// the ONLY producer of these two kinds, so counting them needs no list of which
|
|
20
|
+
// verbs are device verbs — a list that would have to be maintained by hand
|
|
21
|
+
// alongside connectBridge and would be wrong the first time someone forgot.
|
|
22
|
+
const DEVICE_FAILURE_KINDS = new Set(['device', 'timeout']);
|
|
23
|
+
|
|
24
|
+
// How many run traces .logs/ keeps. See the plan's bounded-measurement analysis:
|
|
25
|
+
// a trace is capped individually, but the DIRECTORY is one file per run forever.
|
|
26
|
+
const KEEP_RUN_TRACES = 20;
|
|
27
|
+
|
|
28
|
+
// Parse NDJSON leniently. A trailing partial line is NORMAL, not corruption: a
|
|
29
|
+
// SIGKILLed process can be interrupted mid-append, and the trace of a run that
|
|
30
|
+
// died is the trace most worth reading. Unparseable lines are skipped.
|
|
31
|
+
function parseTrace(text) {
|
|
32
|
+
const events = [];
|
|
33
|
+
for (const line of String(text).split('\n')) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
try {
|
|
36
|
+
const e = JSON.parse(line);
|
|
37
|
+
if (e && typeof e === 'object') events.push(e);
|
|
38
|
+
} catch (_) {
|
|
39
|
+
/* partial or corrupt line; the rest of the trace is still evidence */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return events;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readTrace(tracePath, { fs = realFs } = {}) {
|
|
46
|
+
if (!tracePath) return null;
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
text = fs.readFileSync(tracePath, 'utf8');
|
|
50
|
+
} catch (_) {
|
|
51
|
+
// No trace for this run: an ordinary, expected state (MAUTO_RUN_ID unset,
|
|
52
|
+
// logging silenced, no workspace, or an id that could not name a file).
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return { events: parseTrace(text), truncated: atCap(tracePath, { fs }) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function millis(event) {
|
|
59
|
+
const t = event && event.ts ? Date.parse(event.ts) : NaN;
|
|
60
|
+
return Number.isFinite(t) ? t : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The facts a trace can PROVE, and deliberately nothing else.
|
|
64
|
+
//
|
|
65
|
+
// Absent by design: per-step attempt counts. The design asks for "retry counts
|
|
66
|
+
// … counted from repeated verb events against the same target", and the trace
|
|
67
|
+
// has no target — `--at 100,250` is not a recorded field and must not become
|
|
68
|
+
// one, because coordinates and element labels are exactly the free text the
|
|
69
|
+
// redaction catalog exists to keep off the wire. Step boundaries could be
|
|
70
|
+
// inferred from where the `result add-step` events fall, but that is only sound
|
|
71
|
+
// if the agent records each step immediately after performing it; an agent that
|
|
72
|
+
// batches its add-step calls at the end would file every device call under step
|
|
73
|
+
// one. Silently mis-attributing retries is the same confident wrongness this
|
|
74
|
+
// slice exists to remove.
|
|
75
|
+
//
|
|
76
|
+
// So this counts what needs no inference — run-wide device failures, each of
|
|
77
|
+
// which is an attempt that did not take. finalize COMPARES that against what
|
|
78
|
+
// the agent said rather than overwriting the per-step numbers.
|
|
79
|
+
function deriveRun(trace) {
|
|
80
|
+
if (!trace || !Array.isArray(trace.events) || trace.events.length === 0) return null;
|
|
81
|
+
|
|
82
|
+
const truncated = Boolean(trace.truncated);
|
|
83
|
+
const device_failures = trace.events.filter(
|
|
84
|
+
(e) => e.event === 'verb.end' && e.ok === false && DEVICE_FAILURE_KINDS.has(e.error_kind)
|
|
85
|
+
).length;
|
|
86
|
+
const failure_screenshots = trace.events
|
|
87
|
+
.filter((e) => e.event === 'screenshot.on_failure' && typeof e.path === 'string')
|
|
88
|
+
.map((e) => e.path);
|
|
89
|
+
|
|
90
|
+
// reduce, not Math.min(...stamps): a 1 MiB trace is a few thousand lines
|
|
91
|
+
// today, but spreading an array into a call is an argument-count limit
|
|
92
|
+
// waiting to be hit by a future, larger cap.
|
|
93
|
+
let first = null;
|
|
94
|
+
let last = null;
|
|
95
|
+
for (const event of trace.events) {
|
|
96
|
+
const t = millis(event);
|
|
97
|
+
if (t === null) continue;
|
|
98
|
+
if (first === null || t < first) first = t;
|
|
99
|
+
if (last === null || t > last) last = t;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Two stamps or nothing. A single-event trace spans zero time, and writing 0
|
|
103
|
+
// down as a MEASURED duration would be a confident lie where "we did not
|
|
104
|
+
// measure" is the truth — finalize falls back to the reported value.
|
|
105
|
+
const measurable = first !== null && last !== null && last > first;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
// Milliseconds to seconds: the trace's resolution is 1ms, and rounding to
|
|
109
|
+
// whole seconds would report every fast run as 0.
|
|
110
|
+
duration_seconds: measurable ? (last - first) / 1000 : null,
|
|
111
|
+
trace_events: trace.events.length,
|
|
112
|
+
device_failures,
|
|
113
|
+
trace_truncated: truncated,
|
|
114
|
+
failure_screenshots,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Keep the `keep` most recent run traces; delete the rest.
|
|
119
|
+
//
|
|
120
|
+
// Called at finalize and NOWHERE else. Off the hot path — a readdir per
|
|
121
|
+
// `mauto tap` would put directory scanning inside the tightest loop the tool
|
|
122
|
+
// has — and finalize is the only moment a run is known to be over. Keying the
|
|
123
|
+
// prune on OTHER runs' traces also handles the crashed-run case: a run that
|
|
124
|
+
// never finalized leaves an orphan, which is simply an older file the next
|
|
125
|
+
// successful finalize sweeps up.
|
|
126
|
+
//
|
|
127
|
+
// The trace of the run being finalized is always excluded. Deleting it there is
|
|
128
|
+
// the one option that is clearly wrong: it is the evidence behind every number
|
|
129
|
+
// finalize just wrote, destroyed at exactly the moment someone starts asking
|
|
130
|
+
// where duration_seconds came from.
|
|
131
|
+
//
|
|
132
|
+
// Deletion is confined to `run-*.ndjson` in the resolved logs dir — files this
|
|
133
|
+
// tool created, under a name it owns. Best-effort throughout: pruning a log
|
|
134
|
+
// directory must never be the reason a finalize fails.
|
|
135
|
+
function pruneRunTraces(projectRoot, { keep = KEEP_RUN_TRACES, except, env = process.env, fs = realFs } = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const dir = logsDir(projectRoot, env);
|
|
138
|
+
const exceptPath = except ? runTracePath(projectRoot, except, env) : null;
|
|
139
|
+
const exceptName = exceptPath ? path.basename(exceptPath) : null;
|
|
140
|
+
|
|
141
|
+
const traces = fs
|
|
142
|
+
.readdirSync(dir)
|
|
143
|
+
.filter((n) => n.startsWith(RUN_TRACE_PREFIX) && n.endsWith('.ndjson') && n !== exceptName)
|
|
144
|
+
.map((name) => {
|
|
145
|
+
const full = path.join(dir, name);
|
|
146
|
+
let mtime = 0;
|
|
147
|
+
try {
|
|
148
|
+
mtime = fs.statSync(full).mtimeMs;
|
|
149
|
+
} catch (_) {
|
|
150
|
+
/* raced with another process; treat as oldest */
|
|
151
|
+
}
|
|
152
|
+
return { full, mtime };
|
|
153
|
+
})
|
|
154
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
155
|
+
|
|
156
|
+
// keep - 1: the run being finalized is excluded from `traces` above and
|
|
157
|
+
// occupies one of the retained slots.
|
|
158
|
+
let removed = 0;
|
|
159
|
+
for (const trace of traces.slice(Math.max(0, keep - 1))) {
|
|
160
|
+
try {
|
|
161
|
+
fs.unlinkSync(trace.full);
|
|
162
|
+
removed += 1;
|
|
163
|
+
} catch (_) {
|
|
164
|
+
/* raced, or already gone */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return removed;
|
|
168
|
+
} catch (_) {
|
|
169
|
+
// No .logs/ yet, or an unreadable directory. Pruning is never load-bearing.
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { readTrace, deriveRun, pruneRunTraces, KEEP_RUN_TRACES, DEVICE_FAILURE_KINDS };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The ONE network path in the shipped tree. tests/lint/telemetry-transport-
|
|
4
|
+
// isolation.test.js fails the build if a second one appears anywhere in src/
|
|
5
|
+
// or bin/, because the privacy contract ("telemetryPayload() is the only thing
|
|
6
|
+
// that builds a network payload") is only true while there is only one wire.
|
|
7
|
+
//
|
|
8
|
+
// PostHog's documented plain-HTTP capture API, no SDK. An SDK would cost
|
|
9
|
+
// cold-start time on every one of the dozens of process spawns a scenario
|
|
10
|
+
// makes, and would add supply-chain surface to a project already carrying
|
|
11
|
+
// high-severity advisories (#161). The whole transport is one JSON body.
|
|
12
|
+
//
|
|
13
|
+
// This module RESHAPES a spool line into PostHog's envelope. It never adds a
|
|
14
|
+
// fact: everything in `properties` came out of telemetryPayload(), which
|
|
15
|
+
// iterates the field catalog rather than the event.
|
|
16
|
+
|
|
17
|
+
// EU cloud by default. No user content reaches the wire at all (the catalog
|
|
18
|
+
// guarantees that), but app package names are the sensitive class this design
|
|
19
|
+
// is shaped around, so the region is a cheap belt on top of the braces.
|
|
20
|
+
const DEFAULT_HOST = 'https://eu.i.posthog.com';
|
|
21
|
+
|
|
22
|
+
// A PostHog project API key is WRITE-ONLY and public by design — it authorises
|
|
23
|
+
// capture and nothing else, and PostHog embeds it in the <script> tag of every
|
|
24
|
+
// site that uses them. Shipping it is therefore correct; fetching it at flush
|
|
25
|
+
// time would mean a network round trip before the network round trip, a server
|
|
26
|
+
// we must keep alive forever, and no actual protection (anyone can read it out
|
|
27
|
+
// of `npm pack`). See "Why the project token ships in the package" in
|
|
28
|
+
// docs/plans/2026-09-05-observability-slice-5-plan.md.
|
|
29
|
+
//
|
|
30
|
+
// Until a maintainer pastes the real value, hasToken() is false and telemetry
|
|
31
|
+
// resolves DISABLED with reason `no_token`. A fork or a mid-slice build can
|
|
32
|
+
// therefore never post, even with telemetry.enabled true in its config.
|
|
33
|
+
const TOKEN_PLACEHOLDER = 'phc_REPLACE_ME';
|
|
34
|
+
const PROJECT_TOKEN = 'phc_xD3rE7kJ76yIcVgBKfkarm6cXiNaiEEbSL8q4R5rpBi';
|
|
35
|
+
|
|
36
|
+
// One event has one identity and no person. distinct_id is a constant and
|
|
37
|
+
// $process_person_profile is false, so PostHog creates no person profile at
|
|
38
|
+
// all: aggregate verb counts and error rates still work, and there is no
|
|
39
|
+
// stable per-machine identifier anywhere in the system to correlate on.
|
|
40
|
+
const ANON_DISTINCT_ID = 'mauto-anonymous';
|
|
41
|
+
|
|
42
|
+
// A hung POST must never pin the daemon or delay its idle reap.
|
|
43
|
+
const POST_TIMEOUT_MS = 5000;
|
|
44
|
+
|
|
45
|
+
// Keys that become top-level wire fields rather than properties, so they are
|
|
46
|
+
// not sent twice under two names.
|
|
47
|
+
const WIRE_KEYS = ['event', 'ts', 'msg_id'];
|
|
48
|
+
|
|
49
|
+
function resolveToken(env = process.env) {
|
|
50
|
+
return (env && env.MAUTO_TELEMETRY_TOKEN) || PROJECT_TOKEN;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasToken(env = process.env) {
|
|
54
|
+
const t = resolveToken(env);
|
|
55
|
+
return Boolean(t) && t !== TOKEN_PLACEHOLDER;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function endpointUrl(env = process.env) {
|
|
59
|
+
const host = ((env && env.MAUTO_TELEMETRY_HOST) || DEFAULT_HOST).replace(/\/+$/, '');
|
|
60
|
+
return `${host}/batch/`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toWireEvent(payload) {
|
|
64
|
+
const properties = { distinct_id: ANON_DISTINCT_ID, $process_person_profile: false };
|
|
65
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
66
|
+
if (WIRE_KEYS.includes(k)) continue;
|
|
67
|
+
properties[k] = v;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
event: payload.event,
|
|
71
|
+
timestamp: payload.ts,
|
|
72
|
+
uuid: payload.msg_id,
|
|
73
|
+
properties,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Bounds a promise with a deadline without ever leaving a live timer behind.
|
|
78
|
+
// The timer is deliberately left REF'D (not unref()'d): src/device/
|
|
79
|
+
// failure-probe.js shipped the unref'd version of exactly this and it was a
|
|
80
|
+
// real bug — an unref'd timer never fires when nothing else is keeping the
|
|
81
|
+
// event loop alive (a one-shot verb, or the daemon between device calls), so
|
|
82
|
+
// the race never settles and the caller hangs forever waiting on a deadline
|
|
83
|
+
// that was never going to arrive. Keeping it ref'd costs nothing the unref
|
|
84
|
+
// was trying to save: the `finally` below already guarantees the timer cannot
|
|
85
|
+
// outlive the race on either branch, which is the actual guarantee "don't
|
|
86
|
+
// leave a live timer behind" was after. The AbortController is passed to
|
|
87
|
+
// fetch as a cooperative cancel; the race is the actual bound, so a fetchImpl
|
|
88
|
+
// that ignores the signal still cannot hang the caller.
|
|
89
|
+
async function withDeadline(promise, ms, controller) {
|
|
90
|
+
let timer;
|
|
91
|
+
const deadline = new Promise((resolve) => {
|
|
92
|
+
timer = setTimeout(() => {
|
|
93
|
+
controller.abort();
|
|
94
|
+
resolve({ timedOut: true });
|
|
95
|
+
}, ms);
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([promise.then((response) => ({ response })), deadline]);
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolves — never rejects. `retry` distinguishes "try this batch again later"
|
|
105
|
+
// from "this batch will never succeed":
|
|
106
|
+
//
|
|
107
|
+
// 4xx except 429 permanent. A malformed body or a revoked token would
|
|
108
|
+
// otherwise retry forever and the spool would never drain.
|
|
109
|
+
// 429, 5xx, throw retryable. Rate limiting, an outage, DNS, a captive portal.
|
|
110
|
+
// timeout retryable. Indistinguishable from a slow network from here.
|
|
111
|
+
async function postBatch(payloads, { fetchImpl, env = process.env, timeoutMs = POST_TIMEOUT_MS } = {}) {
|
|
112
|
+
if (!Array.isArray(payloads) || payloads.length === 0) return { ok: true, retry: false, status: 0 };
|
|
113
|
+
|
|
114
|
+
// telemetryPayload() drops a value that fails its accepts() check, so a
|
|
115
|
+
// payload can arrive here with no `event` (an unrecognised event name was
|
|
116
|
+
// dropped). Posting that fragment under an invented event name would be
|
|
117
|
+
// worse than dropping it; skip it instead of pretending it is a real event.
|
|
118
|
+
const usable = payloads.filter((p) => p && p.event);
|
|
119
|
+
if (usable.length === 0) return { ok: true, retry: false, status: 0 };
|
|
120
|
+
|
|
121
|
+
if (!hasToken(env)) return { ok: false, retry: false, status: 0 };
|
|
122
|
+
|
|
123
|
+
const doFetch = fetchImpl === undefined ? globalThis.fetch : fetchImpl;
|
|
124
|
+
// Node 18 has global fetch; a stripped or exotic runtime might not. Not
|
|
125
|
+
// having a transport is a permanent condition for this process, not a
|
|
126
|
+
// retryable one, so the batch is dropped rather than accumulated forever.
|
|
127
|
+
if (typeof doFetch !== 'function') return { ok: false, retry: false, status: 0 };
|
|
128
|
+
|
|
129
|
+
const body = JSON.stringify({
|
|
130
|
+
api_key: resolveToken(env),
|
|
131
|
+
batch: usable.map(toWireEvent),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
try {
|
|
136
|
+
const outcome = await withDeadline(
|
|
137
|
+
doFetch(endpointUrl(env), {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body,
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
}),
|
|
143
|
+
timeoutMs,
|
|
144
|
+
controller,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (outcome.timedOut) return { ok: false, retry: true, status: 0 };
|
|
148
|
+
|
|
149
|
+
const status = outcome.response.status;
|
|
150
|
+
if (status >= 200 && status < 300) return { ok: true, retry: false, status };
|
|
151
|
+
if (status === 429 || status >= 500) return { ok: false, retry: true, status };
|
|
152
|
+
return { ok: false, retry: false, status };
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// Network-level: DNS, TLS, abort/timeout, captive portal. All retryable,
|
|
155
|
+
// and none of them is ever allowed to escape into the caller.
|
|
156
|
+
return { ok: false, retry: true, status: 0 };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
DEFAULT_HOST,
|
|
162
|
+
TOKEN_PLACEHOLDER,
|
|
163
|
+
POST_TIMEOUT_MS,
|
|
164
|
+
ANON_DISTINCT_ID,
|
|
165
|
+
resolveToken,
|
|
166
|
+
hasToken,
|
|
167
|
+
endpointUrl,
|
|
168
|
+
postBatch,
|
|
169
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The closed vocabularies telemetry's `oneOf` checks are built from
|
|
4
|
+
// (src/observe/accepts.js, applied at the wire by telemetryPayload).
|
|
5
|
+
//
|
|
6
|
+
// Hard-coded rather than derived from the code that produces the values, on
|
|
7
|
+
// purpose: derived-at-load would make the check agree with whatever the code
|
|
8
|
+
// does, including a bug that records caller text. tests/lint/telemetry-vocab-drift.test.js
|
|
9
|
+
// pins each list to its producer in BOTH directions instead, so a new event,
|
|
10
|
+
// verb or reason is noticed rather than silently dropped from telemetry.
|
|
11
|
+
//
|
|
12
|
+
// Arrays are frozen: the entries are ordinary indexed properties, so the freeze
|
|
13
|
+
// is real (unlike a frozen Set — see src/device/mobile-mcp-tools.js).
|
|
14
|
+
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
const OS_NAMES = Object.freeze([
|
|
18
|
+
'aix',
|
|
19
|
+
'darwin',
|
|
20
|
+
'freebsd',
|
|
21
|
+
'linux',
|
|
22
|
+
'openbsd',
|
|
23
|
+
'sunos',
|
|
24
|
+
'win32',
|
|
25
|
+
'android',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Every `event:` name the source records. Pinned by the drift guard.
|
|
29
|
+
const EVENT_NAMES = Object.freeze([
|
|
30
|
+
'verb.end',
|
|
31
|
+
'call.start',
|
|
32
|
+
'call.end',
|
|
33
|
+
'screenshot.on_failure',
|
|
34
|
+
'screenshot.capture_failed',
|
|
35
|
+
'crash.list',
|
|
36
|
+
'crash.detected',
|
|
37
|
+
'crash.probe_clear',
|
|
38
|
+
'crash.probe_unscoped',
|
|
39
|
+
'crash.probe_failed',
|
|
40
|
+
'crash.probe_timeout',
|
|
41
|
+
'daemon.start',
|
|
42
|
+
'daemon.stop',
|
|
43
|
+
'daemon.start_failure',
|
|
44
|
+
'daemon.listen_failure',
|
|
45
|
+
'daemon.connect_failure',
|
|
46
|
+
'daemon.lock_conflict',
|
|
47
|
+
'daemon.undeliverable',
|
|
48
|
+
// Emitted by the flush path, which lands in a later slice-5 task; declared
|
|
49
|
+
// now because its event shape is already exercised by unit tests.
|
|
50
|
+
'telemetry.flush',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// The values cli.js's preAction hook can record as `verb`: the TOP-LEVEL
|
|
54
|
+
// command commander resolved (`config get` records `config`). Commander's
|
|
55
|
+
// implicit `help` command, `--help` and `-V` run no preAction hook, so they
|
|
56
|
+
// record no verb at all and are deliberately absent.
|
|
57
|
+
const VERB_NAMES = Object.freeze([
|
|
58
|
+
'elements',
|
|
59
|
+
'screenshot',
|
|
60
|
+
'validate',
|
|
61
|
+
'tap',
|
|
62
|
+
'type',
|
|
63
|
+
'swipe',
|
|
64
|
+
'press',
|
|
65
|
+
'long-press',
|
|
66
|
+
'double-tap',
|
|
67
|
+
'launch',
|
|
68
|
+
'install',
|
|
69
|
+
'uninstall',
|
|
70
|
+
'open-url',
|
|
71
|
+
'orientation',
|
|
72
|
+
'assert',
|
|
73
|
+
'result',
|
|
74
|
+
'setup',
|
|
75
|
+
'config',
|
|
76
|
+
'guide',
|
|
77
|
+
'schema',
|
|
78
|
+
'bootstrap',
|
|
79
|
+
'memory',
|
|
80
|
+
'init',
|
|
81
|
+
'session',
|
|
82
|
+
'devices',
|
|
83
|
+
'crash',
|
|
84
|
+
'mcp',
|
|
85
|
+
'telemetry',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// Why the daemon stopped. 'crash' is documented but not yet produced.
|
|
89
|
+
const STOP_REASONS = Object.freeze(['idle', 'signal', 'shutdown', 'crash', 'explicit']);
|
|
90
|
+
|
|
91
|
+
// Node/libuv errno names for this platform, plus our own ELOCKED (a lock
|
|
92
|
+
// conflict, recorded by src/device/session-daemon.js). Names only: the numeric codes an MCP
|
|
93
|
+
// server reports are not in this set and so never pass.
|
|
94
|
+
const ERROR_CODES = Object.freeze([...Object.keys(os.constants.errno), 'ELOCKED']);
|
|
95
|
+
|
|
96
|
+
module.exports = { OS_NAMES, EVENT_NAMES, VERB_NAMES, STOP_REASONS, ERROR_CODES };
|
package/src/output/envelope.js
CHANGED
|
@@ -36,6 +36,11 @@ const KIND_TO_CODE = {
|
|
|
36
36
|
partial: 1,
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
// The closed set of error.kind values. Single source: src/observe/event.js
|
|
40
|
+
// validates the telemetry `error_kind` field against exactly this list, so a
|
|
41
|
+
// new kind added to KIND_TO_CODE is accepted there without a second edit.
|
|
42
|
+
const ERROR_KINDS = Object.freeze(Object.keys(KIND_TO_CODE));
|
|
43
|
+
|
|
39
44
|
function ok(data, hint = null) {
|
|
40
45
|
const env = { ok: true, data, schema_version: SCHEMA_VERSION };
|
|
41
46
|
// A success can still carry an advisory hint (e.g. recovered corruption).
|
|
@@ -99,6 +104,7 @@ function render(envelope, { human = false } = {}) {
|
|
|
99
104
|
module.exports = {
|
|
100
105
|
SCHEMA_VERSION,
|
|
101
106
|
EXIT,
|
|
107
|
+
ERROR_KINDS,
|
|
102
108
|
ok,
|
|
103
109
|
fail,
|
|
104
110
|
exitCodeFor,
|
|
@@ -92,6 +92,11 @@ const RESULT_CAPABILITIES = {
|
|
|
92
92
|
},
|
|
93
93
|
duration: {
|
|
94
94
|
verb: 'finalize',
|
|
95
|
+
// --duration is now the REPORTED value, not the recorded one. When a run
|
|
96
|
+
// trace exists, finalize writes the measured duration to duration_seconds
|
|
97
|
+
// and keeps this flag's value in measurements.reported_duration_seconds,
|
|
98
|
+
// flagging a disagreement rather than silently discarding either number.
|
|
99
|
+
// The store's write site is unchanged — the caller derives the value.
|
|
95
100
|
flags: ['--duration'],
|
|
96
101
|
store: 'finalize',
|
|
97
102
|
writes: 'duration_seconds: Number(durationSeconds) || 0',
|
|
@@ -114,6 +119,14 @@ const RESULT_CAPABILITIES = {
|
|
|
114
119
|
writeCheck: 'behavioral',
|
|
115
120
|
schemaPointer: '/properties/summary',
|
|
116
121
|
},
|
|
122
|
+
crash_record: {
|
|
123
|
+
verb: 'add-crash',
|
|
124
|
+
flags: ['--crash-id', '--process', '--crash-timestamp', '--excerpt', '--report-path'],
|
|
125
|
+
store: 'addCrash',
|
|
126
|
+
writes: 'this._crashes.push(entry)',
|
|
127
|
+
writeCheck: 'substring',
|
|
128
|
+
schemaPointer: '/properties/crashes',
|
|
129
|
+
},
|
|
117
130
|
};
|
|
118
131
|
|
|
119
132
|
// Flags that identify WHICH run/step is being recorded, rather than carrying
|
|
@@ -139,6 +152,7 @@ const NO_FLAG_ALLOWLIST = {
|
|
|
139
152
|
total_assertions: 'derived by ResultStore.finalize by counting assertion_results; no independent flag by design',
|
|
140
153
|
passed_assertions: 'derived by ResultStore.finalize by counting assertion_results; no independent flag by design',
|
|
141
154
|
failed_assertions: 'derived by ResultStore.finalize by counting assertion_results; no independent flag by design',
|
|
155
|
+
measurements: 'derived by ResultStore.finalize from the run trace (src/observe/trace.js); deliberately has NO flag — an agent-supplied measurement is precisely the self-report this field exists to replace',
|
|
142
156
|
};
|
|
143
157
|
|
|
144
158
|
module.exports = { RESULT_CAPABILITIES, IDENTITY_FLAGS, NO_FLAG_ALLOWLIST };
|
package/src/result/store.js
CHANGED
|
@@ -17,6 +17,11 @@ const { OBSERVATION_TYPES } = require('./flags');
|
|
|
17
17
|
|
|
18
18
|
const SCHEMA_VERSION = '2.0';
|
|
19
19
|
|
|
20
|
+
// A crash report is read into an agent's context and routinely committed to a
|
|
21
|
+
// repo. A native tombstone is tens of kilobytes, so the excerpt is bounded HERE
|
|
22
|
+
// rather than trusted from the caller — the full report's home is report_path.
|
|
23
|
+
const MAX_CRASH_EXCERPT = 2000;
|
|
24
|
+
|
|
20
25
|
function defaultMetadata(overrides = {}) {
|
|
21
26
|
return {
|
|
22
27
|
app_version: overrides.app_version || 'unknown',
|
|
@@ -77,6 +82,7 @@ class ResultStore {
|
|
|
77
82
|
this._assertions = loaded.assertion_results || [];
|
|
78
83
|
this._observations = loaded.observations || [];
|
|
79
84
|
this._capturedVariables = loaded.captured_variables || {};
|
|
85
|
+
this._crashes = loaded.crashes || [];
|
|
80
86
|
if (!this.scenarioId && loaded.scenario_id) this.scenarioId = loaded.scenario_id;
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -151,6 +157,7 @@ class ResultStore {
|
|
|
151
157
|
assertion_results: this._assertions,
|
|
152
158
|
observations: this._observations,
|
|
153
159
|
captured_variables: this._capturedVariables,
|
|
160
|
+
crashes: this._crashes,
|
|
154
161
|
_in_progress: true,
|
|
155
162
|
};
|
|
156
163
|
this._atomicWrite(JSON.stringify(snapshot, null, 2));
|
|
@@ -228,6 +235,29 @@ class ResultStore {
|
|
|
228
235
|
});
|
|
229
236
|
}
|
|
230
237
|
|
|
238
|
+
addCrash({ crash_id, process = null, timestamp = null, step_id = null, excerpt = null, report_path = null } = {}) {
|
|
239
|
+
return withLock(this._lock, () => {
|
|
240
|
+
this._refreshFromDisk();
|
|
241
|
+
if (!crash_id || String(crash_id).trim() === '') {
|
|
242
|
+
// Without an id the full report is unreachable, which makes the record
|
|
243
|
+
// a claim nobody can check.
|
|
244
|
+
throw new Error('addCrash requires a crash_id');
|
|
245
|
+
}
|
|
246
|
+
const entry = {
|
|
247
|
+
crash_id: String(crash_id),
|
|
248
|
+
process: process == null ? null : String(process),
|
|
249
|
+
timestamp: timestamp == null ? null : String(timestamp),
|
|
250
|
+
detected_at: new Date().toISOString(),
|
|
251
|
+
step_id: step_id == null ? null : String(step_id),
|
|
252
|
+
excerpt: excerpt == null ? null : String(excerpt).slice(0, MAX_CRASH_EXCERPT),
|
|
253
|
+
report_path: report_path == null ? null : String(report_path),
|
|
254
|
+
};
|
|
255
|
+
this._crashes.push(entry);
|
|
256
|
+
this._persistInProgress();
|
|
257
|
+
return entry;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
231
261
|
captureVariable(name, value) {
|
|
232
262
|
return withLock(this._lock, () => {
|
|
233
263
|
this._refreshFromDisk();
|
|
@@ -236,9 +266,66 @@ class ResultStore {
|
|
|
236
266
|
});
|
|
237
267
|
}
|
|
238
268
|
|
|
239
|
-
|
|
269
|
+
// Typed observations derived from the measurement.
|
|
270
|
+
//
|
|
271
|
+
// They go in the observations array as well as in `measurements` because
|
|
272
|
+
// src/memory/store.js harvests observations into run-history: a scenario
|
|
273
|
+
// whose reported durations are chronically wrong, or whose steps claim no
|
|
274
|
+
// retries while the device kept failing, is a CROSS-RUN fact, and
|
|
275
|
+
// `measurements` is only ever read one file at a time.
|
|
276
|
+
//
|
|
277
|
+
// `state_context` for the duration case rather than a new observation type:
|
|
278
|
+
// extending the enum would mean changing OBSERVATION_TYPES and the schema
|
|
279
|
+
// together for a fact the existing vocabulary already describes — the agent's
|
|
280
|
+
// model of the run disagreed with the machine's.
|
|
281
|
+
_measurementObservations(measurements, measuredSeconds) {
|
|
282
|
+
const notes = [];
|
|
283
|
+
if (!measurements) return notes;
|
|
284
|
+
|
|
285
|
+
if (measurements.duration_disagreement) {
|
|
286
|
+
notes.push({
|
|
287
|
+
type: 'state_context',
|
|
288
|
+
step_id: null,
|
|
289
|
+
message:
|
|
290
|
+
`Reported duration ${measurements.reported_duration_seconds}s disagrees with ` +
|
|
291
|
+
`${measuredSeconds}s measured from the run trace; the measured value was recorded.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Provable under-report: the device failed, and not one step admits to a
|
|
296
|
+
// retry. `_steps.length > 0` because `every` on an empty array is true, and
|
|
297
|
+
// a run with no steps has nothing to under-report.
|
|
298
|
+
if (
|
|
299
|
+
measurements.device_failures > 0 &&
|
|
300
|
+
this._steps.length > 0 &&
|
|
301
|
+
this._steps.every((s) => (s.retry_count || 0) === 0)
|
|
302
|
+
) {
|
|
303
|
+
notes.push({
|
|
304
|
+
type: 'flakiness',
|
|
305
|
+
step_id: null,
|
|
306
|
+
message:
|
|
307
|
+
`${measurements.device_failures} device call(s) failed during this run, but no step ` +
|
|
308
|
+
`reported a retry; the recorded attempt counts under-report what the device saw.`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return notes;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
finalize({ status, durationSeconds = 0, summary, metadata, measurements } = {}) {
|
|
240
316
|
return withLock(this._lock, () => {
|
|
241
317
|
this._refreshFromDisk();
|
|
318
|
+
|
|
319
|
+
// Appended INSIDE the lock and deduped by (type, message), so re-running
|
|
320
|
+
// finalize — which the execute guide tells an agent to do after a failed
|
|
321
|
+
// one — cannot stack duplicates.
|
|
322
|
+
for (const note of this._measurementObservations(measurements, Number(durationSeconds) || 0)) {
|
|
323
|
+
const already = this._observations.some(
|
|
324
|
+
(o) => o.type === note.type && o.message === note.message
|
|
325
|
+
);
|
|
326
|
+
if (!already) this._observations.push(note);
|
|
327
|
+
}
|
|
328
|
+
|
|
242
329
|
const passed = this._assertions.filter((a) => a.status === 'passed').length;
|
|
243
330
|
const failed = this._assertions.filter((a) => a.status === 'failed').length;
|
|
244
331
|
const total = this._assertions.length;
|
|
@@ -264,6 +351,16 @@ class ResultStore {
|
|
|
264
351
|
`${resolvedStatus}: ${passed}/${total} assertion(s) passed across ${this._steps.length} step(s).`,
|
|
265
352
|
};
|
|
266
353
|
|
|
354
|
+
// Additive and OMITTED when absent: a result file from a run with no
|
|
355
|
+
// trace must stay shaped exactly as 0.24.0 wrote it, so finalize never
|
|
356
|
+
// emits an empty measurements object as a placeholder.
|
|
357
|
+
if (measurements) result.measurements = measurements;
|
|
358
|
+
|
|
359
|
+
// Present only when non-empty. That is what makes `crashes` additive in
|
|
360
|
+
// PRACTICE and not merely in schema: a run with no crashes emits exactly
|
|
361
|
+
// the file it emitted before this field existed.
|
|
362
|
+
if (this._crashes.length > 0) result.crashes = this._crashes;
|
|
363
|
+
|
|
267
364
|
this._atomicWrite(JSON.stringify(result, null, 2));
|
|
268
365
|
return result;
|
|
269
366
|
});
|
|
@@ -37,6 +37,17 @@
|
|
|
37
37
|
"environments": { "$ref": "#/definitions/stringList" },
|
|
38
38
|
"protected_directories": { "$ref": "#/definitions/stringList" },
|
|
39
39
|
"business_critical_paths": { "$ref": "#/definitions/stringList" },
|
|
40
|
+
"telemetry": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": true,
|
|
43
|
+
"description": "Anonymous usage telemetry. Off unless `enabled` is literally true. MAUTO_TELEMETRY=0 and DO_NOT_TRACK=1 override this and always win; MAUTO_TELEMETRY=1 deliberately does not enable. Run `mauto telemetry status` for the exact field list.",
|
|
44
|
+
"properties": {
|
|
45
|
+
"enabled": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"description": "Opt in to anonymous usage telemetry. Default false. Set with `mauto telemetry enable`."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
40
51
|
"app": {
|
|
41
52
|
"type": "object",
|
|
42
53
|
"additionalProperties": true,
|