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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolves the observability control surface from the environment.
|
|
4
|
+
//
|
|
5
|
+
// Defaults are asymmetric on purpose: stderr is a HUMAN's channel during an
|
|
6
|
+
// interactive run, so it stays quiet at `warn`; the file is a forensic record
|
|
7
|
+
// nobody reads unless something broke, so it keeps `info`. One MAUTO_LOG_LEVEL
|
|
8
|
+
// overrides both, because a user debugging wants the same detail in both
|
|
9
|
+
// places and a second env var to remember is a worse interface.
|
|
10
|
+
|
|
11
|
+
const { LEVELS } = require('./event');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_STDERR_LEVEL = 'warn';
|
|
14
|
+
const DEFAULT_FILE_LEVEL = 'info';
|
|
15
|
+
|
|
16
|
+
function resolveLevels(env = process.env) {
|
|
17
|
+
const raw = String((env && env.MAUTO_LOG_LEVEL) || '').toLowerCase();
|
|
18
|
+
if (raw === 'silent') return { stderr: null, file: null };
|
|
19
|
+
if (LEVELS.includes(raw)) return { stderr: raw, file: raw };
|
|
20
|
+
// Unrecognised values fall back rather than throw: a typo in an env var must
|
|
21
|
+
// never break `mauto tap`.
|
|
22
|
+
return { stderr: DEFAULT_STDERR_LEVEL, file: DEFAULT_FILE_LEVEL };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// True when `level` is at or above `threshold`. A null threshold means the
|
|
26
|
+
// sink is off, so nothing passes.
|
|
27
|
+
function atLeast(level, threshold) {
|
|
28
|
+
if (!threshold) return false;
|
|
29
|
+
const a = LEVELS.indexOf(level);
|
|
30
|
+
const b = LEVELS.indexOf(threshold);
|
|
31
|
+
if (a === -1 || b === -1) return false;
|
|
32
|
+
return a >= b;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// MAUTO_RUN_ID is the ONLY correlation input for device verbs — deliberately no
|
|
36
|
+
// --run-id flag on the twelve of them. The agent exports it once per run; a
|
|
37
|
+
// value that cannot change inside a run does not want twelve flags, each of
|
|
38
|
+
// which is another place to forget it. The three `result` verbs correlate
|
|
39
|
+
// through the --run-id they already require, which cli.js prefers over this.
|
|
40
|
+
//
|
|
41
|
+
// This answers "is there a run id", not "is it usable as a filename" — that is
|
|
42
|
+
// runTracePath's job, and keeping it there means one gate rather than two that
|
|
43
|
+
// can drift apart.
|
|
44
|
+
function resolveRunId(env = process.env) {
|
|
45
|
+
const raw = env && env.MAUTO_RUN_ID;
|
|
46
|
+
if (typeof raw !== 'string') return null;
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
return trimmed === '' ? null : trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { DEFAULT_STDERR_LEVEL, DEFAULT_FILE_LEVEL, resolveLevels, atLeast, resolveRunId };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// NDJSON sink: one JSON object per line, append-only, so a log is greppable,
|
|
4
|
+
// streamable, and parseable a line at a time even if the process died mid-file.
|
|
5
|
+
//
|
|
6
|
+
// Bounded by src/util/log-rotate.js — the codebase's one rotation policy, which
|
|
7
|
+
// the daemon's raw stdio log binds to as well. This file used to say that while
|
|
8
|
+
// forking the implementation; it now calls it.
|
|
9
|
+
//
|
|
10
|
+
// Every filesystem operation is guarded: a read-only workspace or a full disk
|
|
11
|
+
// must degrade to "no logging", never to a failed `mauto tap`.
|
|
12
|
+
|
|
13
|
+
const realFs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { mainLogPath, workspaceDir } = require('../paths');
|
|
17
|
+
const { MAX_LOG_BYTES, rotateIfLarge } = require('../../util/log-rotate');
|
|
18
|
+
|
|
19
|
+
function format(event) {
|
|
20
|
+
return JSON.stringify(event) + '\n';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// No workspace, no file log.
|
|
24
|
+
//
|
|
25
|
+
// `mauto` is a CLI run from wherever the user is standing, and unconditional
|
|
26
|
+
// mkdir meant every invocation in any directory silently created
|
|
27
|
+
// mobile-automator/. That is litter, and worse than litter: `mauto setup`
|
|
28
|
+
// writes mobile-automator/.gitignore, so a tree created by the SINK instead of
|
|
29
|
+
// by setup is an un-ignored directory accumulating device serials in someone's
|
|
30
|
+
// repo, ready for `git add -A`.
|
|
31
|
+
//
|
|
32
|
+
// The check is on the base dir, not on .logs/ — .logs/ legitimately does not
|
|
33
|
+
// exist before the first event, so testing it would mean never logging at all.
|
|
34
|
+
// MAUTO_LOG_DIR is exempt: pointing it somewhere is an explicit instruction to
|
|
35
|
+
// log there, and it is how a user logs from an un-set-up directory on purpose.
|
|
36
|
+
function allowed(projectRoot, env, fs) {
|
|
37
|
+
if (env && env.MAUTO_LOG_DIR) return true;
|
|
38
|
+
return fs.existsSync(workspaceDir(projectRoot));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// True when the file is at or past the cap, i.e. the next event would be
|
|
42
|
+
// dropped. Exported because `result finalize` has to ask the same question
|
|
43
|
+
// about a trace it is about to read, and a second copy of the comparison would
|
|
44
|
+
// be a second policy that can drift from this one.
|
|
45
|
+
function atCap(target, { fs = realFs, maxBytes = MAX_LOG_BYTES } = {}) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.statSync(target).size >= maxBytes;
|
|
48
|
+
} catch (_) {
|
|
49
|
+
// No file yet, or unreadable: nothing has been dropped.
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// How a log file is kept bounded. TWO modes, ONE constant — this is not a
|
|
55
|
+
// second rotation policy, it is the same MAX_LOG_BYTES under a different
|
|
56
|
+
// action.
|
|
57
|
+
//
|
|
58
|
+
// 'rotate' is the shared policy: at the cap, rename to `<log>.1` and start
|
|
59
|
+
// fresh. Right for mauto.ndjson and daemon.ndjson, rolling diagnostics where
|
|
60
|
+
// the recent past is the part worth keeping.
|
|
61
|
+
//
|
|
62
|
+
// 'cap' stops appending at the cap, and the difference is not a preference. A
|
|
63
|
+
// per-run trace is not a rolling log: its whole CONTENT is a measurement, and
|
|
64
|
+
// finalize derives the run duration from its FIRST and last events. Rotating it
|
|
65
|
+
// renames the run's beginning out of the live file, so the next finalize would
|
|
66
|
+
// compute the span of an arbitrary suffix and write a three-minute run down as
|
|
67
|
+
// forty seconds — a number that looks measured, is reported as measured, and is
|
|
68
|
+
// wrong, which is strictly worse than the self-report this slice replaces.
|
|
69
|
+
// Dropping the NEWEST events instead keeps a contiguous prefix from the true
|
|
70
|
+
// start, so a truncated trace still yields a duration with a stated meaning: a
|
|
71
|
+
// lower bound, flagged as one via `trace_truncated`.
|
|
72
|
+
function shouldAppend(target, bound, fs) {
|
|
73
|
+
if (bound !== 'cap') {
|
|
74
|
+
rotateIfLarge(target, { fs });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return !atCap(target, { fs });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `logPath` lets a caller name the target file explicitly; without it the CLI's
|
|
81
|
+
// mauto.ndjson is used. It does NOT bypass allowed(): which file to write is a
|
|
82
|
+
// different question from whether this directory has opted into logging at all.
|
|
83
|
+
//
|
|
84
|
+
// `bound` picks the disk-hygiene policy: 'rotate' (default, unchanged) for
|
|
85
|
+
// rolling diagnostics, 'cap' for a run trace. See shouldAppend for why the two
|
|
86
|
+
// cannot share behaviour even though they share the one constant that bounds
|
|
87
|
+
// them.
|
|
88
|
+
function write(event, { projectRoot, env = process.env, fs = realFs, logPath, bound = 'rotate' } = {}) {
|
|
89
|
+
try {
|
|
90
|
+
if (!allowed(projectRoot, env, fs)) return;
|
|
91
|
+
const target = logPath || mainLogPath(projectRoot, env);
|
|
92
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
93
|
+
if (!shouldAppend(target, bound, fs)) return;
|
|
94
|
+
fs.appendFileSync(target, format(event));
|
|
95
|
+
} catch (_) {
|
|
96
|
+
// Observability must never be load-bearing. Losing a log line is always
|
|
97
|
+
// preferable to failing the verb the user actually asked for.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { format, write, atCap };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Human-facing sink for interactive debugging. STDERR ONLY — stdout is owned
|
|
4
|
+
// exclusively by the verb's envelope (or its raw guide/schema output), and a
|
|
5
|
+
// single stray stdout write silently breaks the contract every calling agent
|
|
6
|
+
// depends on. The stream is injected so a test can prove that.
|
|
7
|
+
|
|
8
|
+
const SKIP = new Set(['ts', 'v', 'mauto_version', 'node', 'os', 'level', 'event']);
|
|
9
|
+
|
|
10
|
+
function write(event = {}, { stream = process.stderr } = {}) {
|
|
11
|
+
const parts = [`[${event.level || 'info'}]`, event.event || 'event'];
|
|
12
|
+
for (const [k, v] of Object.entries(event)) {
|
|
13
|
+
if (SKIP.has(k)) continue;
|
|
14
|
+
if (v === undefined || v === null) continue;
|
|
15
|
+
parts.push(`${k}=${v}`);
|
|
16
|
+
}
|
|
17
|
+
stream.write(parts.join(' ') + '\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { write };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The telemetry queue: the file every verb writes and no verb reads.
|
|
4
|
+
//
|
|
5
|
+
// A one-shot process cannot do network I/O. finish() calls process.exit() on
|
|
6
|
+
// the line after record(), which tears down a pending socket mid-handshake —
|
|
7
|
+
// so a fire-and-forget POST is not merely lossy, it is BIASED toward fast
|
|
8
|
+
// machines and warm connections. Awaiting one instead adds a full RTT to every
|
|
9
|
+
// `mauto tap`. So a verb appends to this file (tens of microseconds, completes
|
|
10
|
+
// before the next statement) and something with an event loop — the daemon, or
|
|
11
|
+
// `mauto telemetry flush` — does the network. An undelivered spool is not an
|
|
12
|
+
// error state; it is a file the next daemon picks up.
|
|
13
|
+
//
|
|
14
|
+
// The line written here IS the network payload. Writing the full event and
|
|
15
|
+
// redacting at flush time would put the redaction decision on the network path,
|
|
16
|
+
// in a process that may be a different version of mauto than the one that wrote
|
|
17
|
+
// the line. Redacting at SPOOL time means a device serial is never in the file
|
|
18
|
+
// that gets uploaded, and makes the queue auditable: `cat` it and you have seen
|
|
19
|
+
// everything that would be sent.
|
|
20
|
+
|
|
21
|
+
const realFs = require('fs');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const { spoolPath, workspaceDir } = require('./paths');
|
|
26
|
+
const { telemetryPayload } = require('./event');
|
|
27
|
+
|
|
28
|
+
// ~1000 lines at ~250 bytes. Not rotateIfLarge: that policy is for LOGS, and
|
|
29
|
+
// renaming a queue to `.1` silently discards records that were pending
|
|
30
|
+
// delivery, then clobbers that generation on the next rotation. A queue gets a
|
|
31
|
+
// hard cap instead.
|
|
32
|
+
const SPOOL_MAX_BYTES = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
// Fixed at `info`, independent of resolveLevels(), because telemetry has its
|
|
35
|
+
// own separate control. Two halves, both deliberate:
|
|
36
|
+
// * `debug` never spools — 40x the volume, and debug-only fields are exactly
|
|
37
|
+
// the ones most likely to be newly added and thinly classified.
|
|
38
|
+
// * MAUTO_LOG_LEVEL=silent does not disable telemetry — silencing local logs
|
|
39
|
+
// is not withdrawal of consent, and consent has an explicit control of its
|
|
40
|
+
// own (`mauto telemetry disable`, MAUTO_TELEMETRY=0, DO_NOT_TRACK=1).
|
|
41
|
+
const SPOOL_LEVEL = 'info';
|
|
42
|
+
|
|
43
|
+
const CLAIM_SUFFIX = '.sending';
|
|
44
|
+
|
|
45
|
+
// Zero-arity, CSPRNG, derived from NOTHING — the same property that makes
|
|
46
|
+
// session_id's sends:true classification true rather than asserted. Generated
|
|
47
|
+
// per EVENT and never reused, so it cannot correlate two events, let alone two
|
|
48
|
+
// machines. It exists so that a re-sent batch (delivery is at-least-once)
|
|
49
|
+
// deduplicates at ingestion.
|
|
50
|
+
function newMessageId() {
|
|
51
|
+
return crypto.randomBytes(16).toString('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Events about telemetry itself never enter the queue. Without this rule an
|
|
55
|
+
// offline machine appends one flush-failure event per flush attempt, forever,
|
|
56
|
+
// and the queue that exists to be drained fills itself.
|
|
57
|
+
function isSelfReferential(event) {
|
|
58
|
+
return typeof event.event === 'string' && event.event.startsWith('telemetry.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Same gate as the file sink: no mobile-automator/ workspace, no artifacts.
|
|
62
|
+
// mauto runs from wherever a user is standing, so creating this tree as a
|
|
63
|
+
// side effect of logging would litter unrelated repos with a directory that
|
|
64
|
+
// has no .gitignore in it.
|
|
65
|
+
function allowed(projectRoot, env, fs) {
|
|
66
|
+
if (env && env.MAUTO_LOG_DIR) return true;
|
|
67
|
+
return fs.existsSync(workspaceDir(projectRoot));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function write(event, { projectRoot, env = process.env, fs = realFs } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
if (isSelfReferential(event)) return;
|
|
73
|
+
if (!allowed(projectRoot, env, fs)) return;
|
|
74
|
+
|
|
75
|
+
const target = spoolPath(projectRoot, env);
|
|
76
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
77
|
+
|
|
78
|
+
// One stat before the append. At the cap we drop the NEW line rather than
|
|
79
|
+
// evicting an old one: eviction means read-filter-rewrite on the hot path
|
|
80
|
+
// of every verb, and the older records are the ones already queued for
|
|
81
|
+
// delivery — evicting them loses more history than refusing one new line.
|
|
82
|
+
try {
|
|
83
|
+
if (fs.statSync(target).size >= SPOOL_MAX_BYTES) return;
|
|
84
|
+
} catch (_) {
|
|
85
|
+
/* no spool yet — proceed */
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const payload = telemetryPayload({ ...event, msg_id: newMessageId() });
|
|
89
|
+
fs.appendFileSync(target, JSON.stringify(payload) + '\n');
|
|
90
|
+
} catch (_) {
|
|
91
|
+
// Observability is never load-bearing. Losing a telemetry line is always
|
|
92
|
+
// preferable to failing the verb the user actually asked for.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Monotonic per-process claim stamp, seeded from wall-clock so listClaimed's
|
|
97
|
+
// oldest-first sort (which parses this suffix as a number) still reflects
|
|
98
|
+
// real ordering across process restarts. Date.now() ALONE is not unique
|
|
99
|
+
// enough: a flush that finds a leftover batch and then claims a fresh one
|
|
100
|
+
// back-to-back can complete both within the same millisecond, and a rename
|
|
101
|
+
// onto an already-existing target SILENTLY REPLACES it on POSIX — destroying
|
|
102
|
+
// whichever claim got there first rather than erroring. Falling back to
|
|
103
|
+
// `lastClaimStamp + 1` when the clock hasn't advanced guarantees the target
|
|
104
|
+
// is always new within this process; a different process can never collide
|
|
105
|
+
// regardless, because its pid is already part of the filename.
|
|
106
|
+
let lastClaimStamp = 0;
|
|
107
|
+
function nextClaimStamp() {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
lastClaimStamp = now > lastClaimStamp ? now : lastClaimStamp + 1;
|
|
110
|
+
return lastClaimStamp;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Atomic hand-off from "being appended to" to "being sent".
|
|
114
|
+
//
|
|
115
|
+
// renameSync within a directory is atomic, and it loses nothing: appendFileSync
|
|
116
|
+
// opens by path and closes per call, so a verb that opened its fd BEFORE the
|
|
117
|
+
// rename writes into the claimed inode (that line ships with this batch) and a
|
|
118
|
+
// verb that opens AFTER creates a fresh spool. There is no window in which a
|
|
119
|
+
// line lands nowhere, and no truncate that could clobber a concurrent writer.
|
|
120
|
+
function claim({ projectRoot, env = process.env, fs = realFs } = {}) {
|
|
121
|
+
const source = spoolPath(projectRoot, env);
|
|
122
|
+
const target = `${source}${CLAIM_SUFFIX}.${process.pid}.${nextClaimStamp()}`;
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync(source, target);
|
|
125
|
+
return target;
|
|
126
|
+
} catch (_) {
|
|
127
|
+
return null; // nothing spooled, or another flusher beat us to it
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Oldest first: the suffix carries pid then epoch-ms, so a lexical sort on the
|
|
132
|
+
// trailing number would misorder across digit widths. Sort on the parsed stamp.
|
|
133
|
+
function listClaimed({ projectRoot, env = process.env, fs = realFs } = {}) {
|
|
134
|
+
const dir = path.dirname(spoolPath(projectRoot, env));
|
|
135
|
+
const prefix = `${path.basename(spoolPath(projectRoot, env))}${CLAIM_SUFFIX}.`;
|
|
136
|
+
let names;
|
|
137
|
+
try {
|
|
138
|
+
names = fs.readdirSync(dir);
|
|
139
|
+
} catch (_) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
return names
|
|
143
|
+
.filter((n) => n.startsWith(prefix))
|
|
144
|
+
.map((n) => ({ name: n, stamp: Number(n.slice(n.lastIndexOf('.') + 1)) || 0 }))
|
|
145
|
+
.sort((a, b) => a.stamp - b.stamp)
|
|
146
|
+
.map((e) => path.join(dir, e.name));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// One bad line must not discard the batch around it. A torn final write (the
|
|
150
|
+
// daemon SIGKILLed mid-append) is exactly one unparseable line.
|
|
151
|
+
function readBatch(file, { fs = realFs } = {}) {
|
|
152
|
+
let text;
|
|
153
|
+
try {
|
|
154
|
+
text = fs.readFileSync(file, 'utf8');
|
|
155
|
+
} catch (_) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const line of text.split('\n')) {
|
|
160
|
+
if (!line) continue;
|
|
161
|
+
try {
|
|
162
|
+
out.push(JSON.parse(line));
|
|
163
|
+
} catch (_) {
|
|
164
|
+
/* skip */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// What `mauto telemetry status` shows a user deciding whether to opt in.
|
|
171
|
+
function stats({ projectRoot, env = process.env, fs = realFs } = {}) {
|
|
172
|
+
const target = spoolPath(projectRoot, env);
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
let events = 0;
|
|
175
|
+
try {
|
|
176
|
+
bytes = fs.statSync(target).size;
|
|
177
|
+
events = readBatch(target, { fs }).length;
|
|
178
|
+
} catch (_) {
|
|
179
|
+
/* nothing spooled */
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
path: target,
|
|
183
|
+
bytes,
|
|
184
|
+
events,
|
|
185
|
+
pending_batches: listClaimed({ projectRoot, env, fs }).length,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
SPOOL_MAX_BYTES,
|
|
191
|
+
SPOOL_LEVEL,
|
|
192
|
+
CLAIM_SUFFIX,
|
|
193
|
+
newMessageId,
|
|
194
|
+
write,
|
|
195
|
+
claim,
|
|
196
|
+
listClaimed,
|
|
197
|
+
readBatch,
|
|
198
|
+
stats,
|
|
199
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolves whether telemetry is on, and owns the consent copy.
|
|
4
|
+
//
|
|
5
|
+
// Precedence, in this order and for these reasons:
|
|
6
|
+
//
|
|
7
|
+
// 1. MAUTO_TELEMETRY=0 a kill switch must win over everything, and it is
|
|
8
|
+
// checked FIRST so it costs zero filesystem calls.
|
|
9
|
+
// 2. DO_NOT_TRACK=1 the cross-tool convention, honoured.
|
|
10
|
+
// 3. telemetry.enabled the user's durable, explicit opt-in.
|
|
11
|
+
// 4. a real token a placeholder build cannot post even if enabled.
|
|
12
|
+
//
|
|
13
|
+
// MAUTO_TELEMETRY=1 deliberately does NOT enable. An off-switch is safe to
|
|
14
|
+
// honour from the environment; an on-switch is a way to turn collection on for
|
|
15
|
+
// a machine — a CI image, a shared shell profile, an inherited Dockerfile —
|
|
16
|
+
// whose owner never consented. On-switches live in a file the project owner
|
|
17
|
+
// edits.
|
|
18
|
+
|
|
19
|
+
const configManager = require('../config/manager');
|
|
20
|
+
const { EVENT_FIELDS, NEVER_SENDS } = require('./event');
|
|
21
|
+
const { hasToken } = require('./transport');
|
|
22
|
+
|
|
23
|
+
const REASONS = Object.freeze([
|
|
24
|
+
'kill_switch',
|
|
25
|
+
'do_not_track',
|
|
26
|
+
'not_configured',
|
|
27
|
+
'no_token',
|
|
28
|
+
'enabled',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// The single home for the consent copy. `mauto telemetry status`, the setup
|
|
32
|
+
// envelope and docs/reference/telemetry.md all render THIS string; three
|
|
33
|
+
// hand-written copies would drift, and the one that drifts is the one a user
|
|
34
|
+
// reads before deciding.
|
|
35
|
+
//
|
|
36
|
+
// It is a NOTICE, never a prompt. mauto verbs are invoked by an agent, not
|
|
37
|
+
// typed by a human, so there is nobody at the keyboard to answer a question —
|
|
38
|
+
// and an agent answering a consent question on a human's behalf is worse than
|
|
39
|
+
// not asking.
|
|
40
|
+
const CONSENT_NOTICE = [
|
|
41
|
+
'Telemetry is off by default and nothing is sent until you turn it on.',
|
|
42
|
+
'',
|
|
43
|
+
'When enabled, mauto records anonymous usage counts — which verb ran, whether',
|
|
44
|
+
'it succeeded, how long it took, and the mobile-mcp primitive behind it. It',
|
|
45
|
+
'sends no free text of any kind: no scenario ids, app package names, device',
|
|
46
|
+
'serials, element labels, typed input or filesystem paths. There is no',
|
|
47
|
+
'per-machine identifier; every event is anonymous and uncorrelated.',
|
|
48
|
+
'',
|
|
49
|
+
'Run `mauto telemetry status` to see the exact field list, read from the same',
|
|
50
|
+
'catalog the uploader uses. Run `mauto telemetry enable` to turn it on, or set',
|
|
51
|
+
'MAUTO_TELEMETRY=0 (or DO_NOT_TRACK=1) to force it off everywhere.',
|
|
52
|
+
].join('\n');
|
|
53
|
+
|
|
54
|
+
function envKillSwitch(env = process.env) {
|
|
55
|
+
if (String((env && env.MAUTO_TELEMETRY) || '') === '0') return 'kill_switch';
|
|
56
|
+
const dnt = String((env && env.DO_NOT_TRACK) || '').toLowerCase();
|
|
57
|
+
if (dnt === '1' || dnt === 'true') return 'do_not_track';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveTelemetry({ env = process.env, config = null } = {}) {
|
|
62
|
+
const killed = envKillSwitch(env);
|
|
63
|
+
if (killed) return { enabled: false, reason: killed };
|
|
64
|
+
// A LITERAL true. A truthy string ("true", "1") means someone hand-edited
|
|
65
|
+
// the file into a shape `mauto config set` would never produce, and guessing
|
|
66
|
+
// at intent is not something a consent flag gets to do.
|
|
67
|
+
const configured = Boolean(config && config.telemetry && config.telemetry.enabled === true);
|
|
68
|
+
if (!configured) return { enabled: false, reason: 'not_configured' };
|
|
69
|
+
if (!hasToken(env)) return { enabled: false, reason: 'no_token' };
|
|
70
|
+
return { enabled: true, reason: 'enabled' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Memoised per project root. resolveLevels() is re-derived per record() call
|
|
74
|
+
// because it reads only env; this one reads config.json, and a file read per
|
|
75
|
+
// event is not acceptable on a path that runs on every verb.
|
|
76
|
+
//
|
|
77
|
+
// The pinning consequence is the same one slice 2 documented for the daemon's
|
|
78
|
+
// MAUTO_LOG_LEVEL: for a one-shot verb this is once per process (correct by
|
|
79
|
+
// construction), and for the daemon it is once per daemon lifetime. Editing
|
|
80
|
+
// config.json while a daemon is running does not change that daemon's
|
|
81
|
+
// behaviour; `mauto session end` then re-run is the remedy.
|
|
82
|
+
const memo = new Map();
|
|
83
|
+
|
|
84
|
+
function decideForProject(projectRoot, env = process.env) {
|
|
85
|
+
const key = String(projectRoot);
|
|
86
|
+
if (memo.has(key)) return memo.get(key);
|
|
87
|
+
let decision;
|
|
88
|
+
try {
|
|
89
|
+
// Checked before the read, not after: a kill switch must cost nothing.
|
|
90
|
+
decision = envKillSwitch(env)
|
|
91
|
+
? resolveTelemetry({ env, config: null })
|
|
92
|
+
: resolveTelemetry({ env, config: configManager.load(projectRoot) });
|
|
93
|
+
} catch (_) {
|
|
94
|
+
// A corrupt or unreadable config resolves OFF. Failing closed is the only
|
|
95
|
+
// acceptable direction for a consent flag.
|
|
96
|
+
decision = { enabled: false, reason: 'not_configured' };
|
|
97
|
+
}
|
|
98
|
+
memo.set(key, decision);
|
|
99
|
+
return decision;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A write through the module that owns the decision also invalidates it, so no
|
|
103
|
+
// caller can forget a pairing it never sees. The delete is surgical — the
|
|
104
|
+
// affected root's key only; other roots' memoised decisions survive.
|
|
105
|
+
//
|
|
106
|
+
// `config set` cannot route through this: its raw CLI strings need coerceValue
|
|
107
|
+
// first, so that write stays in cli.js. That path does not invalidate, and
|
|
108
|
+
// stays harmless for as long as nothing seeds the memo before a handler's own
|
|
109
|
+
// report in a one-shot verb process.
|
|
110
|
+
function setEnabled(projectRoot, value) {
|
|
111
|
+
configManager.set(projectRoot, 'telemetry.enabled', value);
|
|
112
|
+
memo.delete(String(projectRoot));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Test isolation only: production writes go through setEnabled above. Kept
|
|
116
|
+
// because a test process is long-lived where a verb process is not, and one
|
|
117
|
+
// memo spans every workspace the suite builds.
|
|
118
|
+
function _resetMemo() {
|
|
119
|
+
memo.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The disclosure is COMPUTED from the catalog, never restated. That is what
|
|
123
|
+
// makes `mauto telemetry status` and docs/reference/telemetry.md incapable of
|
|
124
|
+
// lying: they render the same source of truth telemetryPayload() iterates.
|
|
125
|
+
function sentFields() {
|
|
126
|
+
return Object.keys(EVENT_FIELDS).filter((k) => EVENT_FIELDS[k].sends);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function neverSentFields() {
|
|
130
|
+
return [...NEVER_SENDS];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
CONSENT_NOTICE,
|
|
135
|
+
REASONS,
|
|
136
|
+
envKillSwitch,
|
|
137
|
+
resolveTelemetry,
|
|
138
|
+
decideForProject,
|
|
139
|
+
setEnabled,
|
|
140
|
+
_resetMemo,
|
|
141
|
+
sentFields,
|
|
142
|
+
neverSentFields,
|
|
143
|
+
};
|