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.
Files changed (39) hide show
  1. package/README.md +27 -4
  2. package/bin/mauto-session-daemon.js +85 -16
  3. package/package.json +4 -3
  4. package/src/cli.js +724 -59
  5. package/src/device/bridge.js +30 -0
  6. package/src/device/connection.js +23 -4
  7. package/src/device/crash-model.js +110 -0
  8. package/src/device/device-call.js +145 -0
  9. package/src/device/failure-probe.js +276 -0
  10. package/src/device/mobile-mcp-tools.js +64 -0
  11. package/src/device/resolve-connection.js +8 -10
  12. package/src/device/session-daemon.js +265 -51
  13. package/src/device/session-handle.js +53 -0
  14. package/src/device/session-log.js +5 -16
  15. package/src/guide/content/execute.agnostic.md +48 -5
  16. package/src/guide/content/execute.aware.md +42 -1
  17. package/src/guide/content/execute.invariants.md +1 -0
  18. package/src/observe/accepts.js +108 -0
  19. package/src/observe/event.js +241 -0
  20. package/src/observe/failure-capture.js +166 -0
  21. package/src/observe/flush.js +177 -0
  22. package/src/observe/paths.js +125 -0
  23. package/src/observe/recorder.js +178 -0
  24. package/src/observe/settings.js +51 -0
  25. package/src/observe/sinks/file.js +101 -0
  26. package/src/observe/sinks/stderr.js +20 -0
  27. package/src/observe/spool.js +199 -0
  28. package/src/observe/telemetry.js +143 -0
  29. package/src/observe/trace.js +174 -0
  30. package/src/observe/transport.js +169 -0
  31. package/src/observe/vocab.js +96 -0
  32. package/src/output/envelope.js +6 -0
  33. package/src/result/capability-catalog.js +14 -0
  34. package/src/result/store.js +98 -1
  35. package/src/schemas/config_schema.json +11 -0
  36. package/src/schemas/result_schema.json +55 -0
  37. package/src/setup/scaffold.js +27 -0
  38. package/src/util/log-rotate.js +34 -0
  39. 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 };
@@ -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 };
@@ -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
- finalize({ status, durationSeconds = 0, summary, metadata } = {}) {
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,