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,166 @@
1
+ 'use strict';
2
+
3
+ // Photograph a device failure while the bridge is still live.
4
+ //
5
+ // The value is in what it replaces: today a failed run's result file carries
6
+ // the agent's NARRATIVE of what the screen looked like. This makes it carry the
7
+ // screen.
8
+ //
9
+ // It is a module rather than four lines inside connectBridge because every one
10
+ // of its rules is a rule about failure handling, and they are easier to state —
11
+ // and to test — where they are not interleaved with commander:
12
+ //
13
+ // 1. It never throws. A capture is a daemon round-trip and can fail for
14
+ // exactly the reasons the original call failed.
15
+ // 2. It never touches the caller's result. The original error is what the
16
+ // agent needs; the screenshot is a footnote. The return value is
17
+ // deliberately ignored at the call site, and the envelope reaches emit()
18
+ // byte-identical whether this succeeded, failed, or never ran.
19
+ // 3. It captures only what it can correlate. With no run id there is no trace
20
+ // to reference the file from and no finalize to collect it, so the PNG
21
+ // would be litter in a directory the user did not ask us to fill — the
22
+ // same argument the file sink already makes about creating
23
+ // mobile-automator/. It also makes the run id the single switch for this
24
+ // whole slice: export it and you get traces, measured durations and
25
+ // failure evidence; leave it unset and 0.24.0's behaviour is unchanged.
26
+
27
+ const realFs = require('fs');
28
+ const path = require('path');
29
+
30
+ const { record } = require('./recorder');
31
+ const { runTracePath, workspaceDir } = require('./paths');
32
+
33
+ // The failure kinds worth a picture.
34
+ //
35
+ // `device` is the ordinary "the device said no". `timeout` is included
36
+ // deliberately and is arguably the more valuable of the two: a timed-out call
37
+ // may have PARTIALLY executed — session-client's own hint says so — and the
38
+ // screen is the only way to find out what actually happened.
39
+ //
40
+ // `invalid_input` is excluded: nothing reached the device, so the picture would
41
+ // show the state BEFORE the verb and be evidence of nothing. `internal` is
42
+ // excluded for the same reason, and because a crashed CLI has a stack trace,
43
+ // which is the artifact that actually helps.
44
+ const CAPTURED_KINDS = new Set(['device', 'timeout']);
45
+
46
+ function isCapturable(result) {
47
+ return Boolean(
48
+ result && result.envelope && result.envelope.ok === false && CAPTURED_KINDS.has(result.exitKind)
49
+ );
50
+ }
51
+
52
+ // verb becomes a filename, not a correlation key the way runId does (see
53
+ // runTracePath's comment in ./paths.js) — the event this module records
54
+ // carries the raw verb in its own field regardless of what the filename
55
+ // says, so nothing downstream reads the filename to learn which verb failed.
56
+ // That means a REJECT-and-fall-back is enough here; there is no
57
+ // isValidRunId-style argument that sanitizing would corrupt a measurement.
58
+ //
59
+ // It is safe today only because verb is always command.name() from a
60
+ // commander command that already parsed successfully — a guarantee this
61
+ // module does not control and must not depend on. Anything that is not a
62
+ // plain token falls back to the same 'device' literal the module already
63
+ // used for a missing verb, so the ordinary case is unaffected.
64
+ const SAFE_VERB_TOKEN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
65
+
66
+ function safeVerbToken(verb) {
67
+ return typeof verb === 'string' && SAFE_VERB_TOKEN.test(verb) ? verb : 'device';
68
+ }
69
+
70
+ // mobile-automator/screenshots/<runId>/<verb>-<ts>.png.
71
+ //
72
+ // Under screenshots/, NOT results/<run_id>/screenshots/ which the execute guide
73
+ // uses for agent-directed captures, because `mauto setup` writes
74
+ // mobile-automator/.gitignore covering `screenshots/` and not the results tree.
75
+ // A photograph of a failing app is precisely the artifact that must not ride
76
+ // into a user's commit on a `git add -A`.
77
+ function failureShotPath(projectRoot, runId, verb) {
78
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
79
+ return path.join(
80
+ projectRoot,
81
+ 'mobile-automator',
82
+ 'screenshots',
83
+ runId,
84
+ `${safeVerbToken(verb)}-${stamp}.png`
85
+ );
86
+ }
87
+
88
+ async function captureOnFailure({
89
+ bridge,
90
+ result,
91
+ projectRoot,
92
+ runId,
93
+ verb,
94
+ env = process.env,
95
+ fs = realFs,
96
+ } = {}) {
97
+ // Resolved once and reused as BOTH the trace target and the is-this-id-safe
98
+ // predicate — one gate rather than a second check that can disagree with it.
99
+ // Declared here and assigned inside the try so a non-string projectRoot
100
+ // (path.join throws via logsDir for a VALID run id) is caught by this
101
+ // function's own catch instead of escaping to a caller that has none.
102
+ let tracePath = null;
103
+ try {
104
+ tracePath = runTracePath(projectRoot, runId, env);
105
+ if (!isCapturable(result)) return null;
106
+ if (!tracePath) return null;
107
+ // The same permission the file sink requires (see sinks/file.js): an
108
+ // observability artifact may not create mobile-automator/ where no
109
+ // workspace exists. A valid run id is not consent to litter — without this
110
+ // gate a failing verb would mkdir screenshots/ into an undiscoverable
111
+ // tree, the #188 pathology.
112
+ if (!fs.existsSync(workspaceDir(projectRoot))) return null;
113
+ if (!bridge || typeof bridge.screenshot !== 'function') return null;
114
+
115
+ const dest = failureShotPath(projectRoot, runId, verb);
116
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
117
+ // Bounded by the layers below, not here: the daemon fails a call at
118
+ // DAEMON_CALL_TIMEOUT_MS and the client gives up on a reply at its own
119
+ // timeout, so this cannot wait forever. A third bound would be a third
120
+ // policy to keep in step with the other two.
121
+ const saved = (await bridge.screenshot(dest)) || dest;
122
+
123
+ record(
124
+ {
125
+ // info, not warn — the inverse of slice 2's daemon choice, for slice
126
+ // 2's own reason. The daemon's stderr is a log file; the CLI's is a
127
+ // human's terminal, and the envelope has already told them the verb
128
+ // failed. A second line about the screenshot is noise on top of noise.
129
+ level: 'info',
130
+ src: 'cli',
131
+ event: 'screenshot.on_failure',
132
+ verb,
133
+ run_id: runId,
134
+ error_kind: result.envelope.error && result.envelope.error.kind,
135
+ // sends:false in the event catalog — a path leaks usernames and project
136
+ // layout, so it stays local. `result finalize` reads it back from here.
137
+ path: saved,
138
+ },
139
+ { projectRoot, env, tracePath }
140
+ );
141
+ return saved;
142
+ } catch (err) {
143
+ // Recorded and discarded. Replacing the original error with "could not take
144
+ // a screenshot" would answer a question nobody asked and lose the answer to
145
+ // the one they did.
146
+ try {
147
+ record(
148
+ {
149
+ level: 'info',
150
+ src: 'cli',
151
+ event: 'screenshot.capture_failed',
152
+ verb,
153
+ run_id: runId,
154
+ message: (err && (err.message || String(err))) || 'unknown capture failure',
155
+ },
156
+ { projectRoot, env, tracePath }
157
+ );
158
+ } catch (_) {
159
+ // record() is already total; this exists so the outer catch cannot itself
160
+ // be the thing that throws.
161
+ }
162
+ return null;
163
+ }
164
+ }
165
+
166
+ module.exports = { captureOnFailure, isCapturable, CAPTURED_KINDS };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ // Drains the spool. The only caller of src/observe/transport.js.
4
+ //
5
+ // Delivery is AT-LEAST-ONCE, deliberately. A batch can be posted successfully
6
+ // and then fail to unlink (a SIGKILLed daemon, a read-only .logs), or chunk 1
7
+ // can succeed while chunk 2 fails retryably — and in both cases the batch is
8
+ // re-sent. Two things make that the right trade:
9
+ //
10
+ // * every spooled line carries msg_id, sent as the PostHog event's `uuid`,
11
+ // so ingestion deduplicates;
12
+ // * the alternative — rewriting the claimed file with the unsent remainder —
13
+ // is a write performed on the failure path, whose own failure mode is
14
+ // LOSING records rather than duplicating them. Duplicates are recoverable
15
+ // at the sink; a lost record is not.
16
+
17
+ const realFs = require('fs');
18
+
19
+ const spool = require('./spool');
20
+ const realTransport = require('./transport');
21
+ const { decideForProject } = require('./telemetry');
22
+
23
+ // Two chunks covers a full 256 KiB spool at ~250 bytes/line, so the duplicate
24
+ // window on a partial failure is at most one chunk.
25
+ const BATCH_SIZE = 250;
26
+
27
+ const FLUSH_INTERVAL_MS = 60 * 1000;
28
+ const MAX_FLUSH_INTERVAL_MS = 30 * 60 * 1000;
29
+
30
+ // A permanently-offline machine converges to <= 256 KiB of spool plus this many
31
+ // claimed batches, all inside .logs/, which `mauto setup` gitignores.
32
+ const MAX_CLAIMED = 3;
33
+
34
+ function chunk(items, size) {
35
+ const out = [];
36
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
37
+ return out;
38
+ }
39
+
40
+ function makeFlusher({
41
+ projectRoot,
42
+ env = process.env,
43
+ transport = realTransport,
44
+ observe = () => {},
45
+ fs = realFs,
46
+ } = {}) {
47
+ let nextDelayMs = FLUSH_INTERVAL_MS;
48
+
49
+ return async function flush() {
50
+ const decision = decideForProject(projectRoot, env);
51
+ // Layer two of "no upload path is reachable while disabled": the transport
52
+ // is not merely unused here, it is not REACHED. The unit suite injects a
53
+ // transport that throws if called and asserts this line returns first.
54
+ if (!decision.enabled) {
55
+ return { skipped: decision.reason, sent: 0, dropped: 0, kept: 0, kept_events: 0, ok: true, nextDelayMs };
56
+ }
57
+
58
+ const startedAt = Date.now();
59
+ let sent = 0;
60
+ let dropped = 0;
61
+ let kept = 0;
62
+ // Payload count of batches left claimed for retry — distinct from `kept`,
63
+ // which counts BATCHES (files), not events. Needed so a flush that only
64
+ // ever retries (nothing sent or dropped yet) still reports how many
65
+ // events it actually touched.
66
+ let keptEvents = 0;
67
+ let lastStatus = 0;
68
+ let retryable = false;
69
+
70
+ try {
71
+ // Leftovers first, oldest first, then whatever has accumulated since.
72
+ const batches = spool.listClaimed({ projectRoot, env, fs });
73
+ const fresh = spool.claim({ projectRoot, env, fs });
74
+ if (fresh) batches.push(fresh);
75
+
76
+ for (const file of batches) {
77
+ const payloads = spool.readBatch(file, { fs });
78
+ if (payloads.length === 0) {
79
+ safeUnlink(file, fs);
80
+ continue;
81
+ }
82
+
83
+ let batchRetryable = false;
84
+ let batchSent = 0;
85
+ let batchDropped = 0;
86
+
87
+ for (const part of chunk(payloads, BATCH_SIZE)) {
88
+ let r;
89
+ try {
90
+ r = await transport.postBatch(part, { env });
91
+ } catch (_) {
92
+ // postBatch is documented never to reject; this is the guarantee
93
+ // restated locally so an injected transport cannot break the daemon.
94
+ r = { ok: false, retry: true, status: 0 };
95
+ }
96
+ lastStatus = r.status;
97
+ if (r.ok) {
98
+ batchSent += part.length;
99
+ continue;
100
+ }
101
+ if (r.retry) {
102
+ batchRetryable = true;
103
+ break; // stop at the first retryable chunk; the file stays claimed
104
+ }
105
+ // Permanent (4xx other than 429): a malformed body or a revoked token
106
+ // would retry forever and the queue would never drain. Drop it.
107
+ batchDropped += part.length;
108
+ }
109
+
110
+ sent += batchSent;
111
+ dropped += batchDropped;
112
+
113
+ if (batchRetryable) {
114
+ kept += 1;
115
+ keptEvents += payloads.length;
116
+ retryable = true;
117
+ break; // the network is down; do not hammer the remaining batches
118
+ }
119
+ safeUnlink(file, fs);
120
+ }
121
+
122
+ // Converge: beyond the cap, the oldest claimed batches go.
123
+ const remaining = spool.listClaimed({ projectRoot, env, fs });
124
+ for (const stale of remaining.slice(0, Math.max(0, remaining.length - MAX_CLAIMED))) {
125
+ safeUnlink(stale, fs);
126
+ }
127
+ } catch (_) {
128
+ // Observability is never load-bearing. A flush that blew up in an
129
+ // unanticipated way costs a log line, not a daemon.
130
+ retryable = true;
131
+ }
132
+
133
+ // Not merely !retryable: a permanently-rejected batch is dropped rather
134
+ // than retried, so `retryable` stays false for it, but it is still a
135
+ // failed flush — data was lost, not merely delayed. `ok` must reflect
136
+ // both failure shapes, and only the retryable one feeds the backoff
137
+ // below (a permanent drop is definitionally never going to succeed by
138
+ // waiting longer).
139
+ const ok = !retryable && dropped === 0;
140
+
141
+ nextDelayMs = retryable
142
+ ? Math.min(nextDelayMs * 2, MAX_FLUSH_INTERVAL_MS)
143
+ : FLUSH_INTERVAL_MS;
144
+
145
+ // `debug`, not `warn`. Slice 2 made warn/error the daemon's genuine-failure
146
+ // levels, and daemon.log is where a human debugging a dead daemon looks. A
147
+ // user's flaky wifi is not a daemon failure, and putting it at warn would
148
+ // train that reader to ignore the level that matters.
149
+ if (sent + dropped + kept > 0) {
150
+ observe({
151
+ level: 'debug',
152
+ event: 'telemetry.flush',
153
+ ok,
154
+ count: sent + dropped + keptEvents,
155
+ http_status: lastStatus || undefined,
156
+ dur_ms: Date.now() - startedAt,
157
+ });
158
+ }
159
+
160
+ // Units: `kept` counts BATCHES (files) left claimed for retry; `sent`,
161
+ // `dropped` and `kept_events` count events. `kept_events` exists because a
162
+ // flush that only ever retries (nothing sent or dropped yet) still needs
163
+ // to report how many events it actually touched.
164
+ return { sent, dropped, kept, kept_events: keptEvents, ok, nextDelayMs };
165
+ };
166
+ }
167
+
168
+ function safeUnlink(file, fs) {
169
+ try {
170
+ fs.unlinkSync(file);
171
+ } catch (_) {
172
+ // Already gone, or a read-only .logs. Either way the next flush's
173
+ // MAX_CLAIMED prune is the backstop.
174
+ }
175
+ }
176
+
177
+ module.exports = { BATCH_SIZE, FLUSH_INTERVAL_MS, MAX_FLUSH_INTERVAL_MS, MAX_CLAIMED, makeFlusher };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // Pure path helpers for the structured event logs. Side-effect-free so they
4
+ // can be unit-tested without touching the filesystem, matching
5
+ // src/device/session-paths.js.
6
+ //
7
+ // NOTE: this is NOT where the daemon's raw stdio goes. That lives at
8
+ // mobile-automator/.session/daemon.log (session-paths.logFilePath, PR #176).
9
+ // Two artifacts, two homes: raw process output there, structured events here.
10
+
11
+ const path = require('path');
12
+
13
+ const LOGS_DIRNAME = '.logs';
14
+ const MAIN_LOG_NAME = 'mauto.ndjson';
15
+ const DAEMON_LOG_NAME = 'daemon.ndjson';
16
+ const RUN_TRACE_PREFIX = 'run-';
17
+ const SPOOL_NAME = 'telemetry.spool';
18
+
19
+ // The workspace root `mauto setup` creates. The file sink treats its existence
20
+ // as permission to log: mauto runs from whatever directory a user is standing
21
+ // in, so creating this tree as a side effect of logging would litter unrelated
22
+ // repos with a directory that has no .gitignore in it.
23
+ function workspaceDir(projectRoot) {
24
+ return path.join(projectRoot, 'mobile-automator');
25
+ }
26
+
27
+ function logsDir(projectRoot, env = process.env) {
28
+ if (env && env.MAUTO_LOG_DIR) return path.resolve(env.MAUTO_LOG_DIR);
29
+ return path.join(projectRoot, 'mobile-automator', LOGS_DIRNAME);
30
+ }
31
+
32
+ function mainLogPath(projectRoot, env = process.env) {
33
+ return path.join(logsDir(projectRoot, env), MAIN_LOG_NAME);
34
+ }
35
+
36
+ // The DAEMON's structured event stream — deliberately a different file from
37
+ // mauto.ndjson. Two reasons, in the order they actually carry weight:
38
+ //
39
+ // VOLUME. A 40-step scenario writes ~40 CLI verb.end lines against 40+ daemon
40
+ // call.end lines. Sharing one 1 MiB budget would rotate the CLI's history out
41
+ // roughly twice as fast, and the two streams are read for different questions.
42
+ //
43
+ // PRECEDENT. .session/daemon.log is already a daemon-owned file distinct from
44
+ // the CLI's diagnostics. Two writers, two files is the shape this codebase
45
+ // already chose.
46
+ //
47
+ // What this does NOT do is make rotation single-writer, and an earlier version
48
+ // of this comment claimed it did. rotateIfLarge is statSync-then-renameSync, so
49
+ // concurrent rotations can clobber a generation — but mauto.ndjson is ALREADY
50
+ // multi-writer (any two concurrent `mauto` verbs race it), and daemon.ndjson is
51
+ // not strictly single-writer either, because a spawn-race lock loser also runs
52
+ // bin/mauto-session-daemon.js and writes daemon.lock_conflict here. Splitting
53
+ // the files removes the daemon from one instance of that race; it does not
54
+ // remove the race. The exposure is a handful of ~200-byte appends in a window of
55
+ // milliseconds, worst case one lost generation of a bounded log, and
56
+ // rotateIfLarge already swallows the ENOENT a losing rename produces.
57
+ //
58
+ // It is also not .session/daemon.log, which is the same process's RAW stdio
59
+ // (PR #176). Same writer, two artifacts: unstructured text a human reads there,
60
+ // parseable events here.
61
+ function daemonEventLogPath(projectRoot, env = process.env) {
62
+ return path.join(logsDir(projectRoot, env), DAEMON_LOG_NAME);
63
+ }
64
+
65
+ // The telemetry queue. Deliberately NOT a .ndjson name even though its content
66
+ // is NDJSON: TROUBLESHOOTING.md documents `cat mobile-automator/.logs/*.ndjson`
67
+ // as the merged CLI+daemon timeline, and the spool is not part of that timeline
68
+ // — it is a queue that gets renamed out from under a reader mid-flush. The
69
+ // extension is the interface that keeps the two apart.
70
+ function spoolPath(projectRoot, env = process.env) {
71
+ return path.join(logsDir(projectRoot, env), SPOOL_NAME);
72
+ }
73
+
74
+ // A run id becomes a FILENAME, so it is validated rather than sanitized.
75
+ //
76
+ // Sanitizing — replacing the offending characters — is the tempting move and it
77
+ // is wrong here: `login/smoke` and `login-smoke` would collapse onto one trace,
78
+ // and `result finalize` would then derive a duration spanning two unrelated
79
+ // runs. A contaminated measurement presented as measured is worse than no
80
+ // measurement, which is the whole premise of this slice. An id that cannot be a
81
+ // filename gets NO trace and everything degrades to the MAUTO_RUN_ID-unset
82
+ // case, which is 0.24.0's behaviour.
83
+ //
84
+ // The charset is deliberately WIDER than the result schema's
85
+ // ^run_\d{8}_\d{6}$: MAUTO_RUN_ID is agent-chosen and this is not the place to
86
+ // start enforcing a pattern the CLI has never enforced. It is narrow enough
87
+ // that no accepted value can contain a path separator, a NUL, a drive colon or
88
+ // a leading dot, so `..`, `../../etc/x`, `/etc/x` and `C:\x` are all rejected
89
+ // by construction rather than by a list of cases someone has to keep complete.
90
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
91
+
92
+ function isValidRunId(runId) {
93
+ if (typeof runId !== 'string' || !RUN_ID_PATTERN.test(runId)) return false;
94
+ // Belt and braces against a future widening of the charset: an accepted id
95
+ // must survive path.basename unchanged, so it can never be a path.
96
+ return path.basename(runId) === runId;
97
+ }
98
+
99
+ // The per-run trace file, or null when the id cannot safely name one.
100
+ //
101
+ // Returning null rather than throwing is what lets every call site treat "no
102
+ // usable run id" and "no run id at all" as one already-handled case, and makes
103
+ // this the SINGLE gate: a hostile id cannot be turned into a write target
104
+ // anywhere in the codebase, because this is the only function that builds the
105
+ // path. src/observe/failure-capture.js reuses it as its own safety check for
106
+ // exactly that reason.
107
+ function runTracePath(projectRoot, runId, env = process.env) {
108
+ if (!isValidRunId(runId)) return null;
109
+ return path.join(logsDir(projectRoot, env), `${RUN_TRACE_PREFIX}${runId}.ndjson`);
110
+ }
111
+
112
+ module.exports = {
113
+ LOGS_DIRNAME,
114
+ MAIN_LOG_NAME,
115
+ DAEMON_LOG_NAME,
116
+ RUN_TRACE_PREFIX,
117
+ SPOOL_NAME,
118
+ workspaceDir,
119
+ logsDir,
120
+ mainLogPath,
121
+ daemonEventLogPath,
122
+ isValidRunId,
123
+ runTracePath,
124
+ spoolPath,
125
+ };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ // The single seam. Everything that wants to be observable calls record().
4
+ //
5
+ // Two properties are load-bearing and both are tested:
6
+ //
7
+ // 1. It never throws and never propagates. Observability is not allowed to
8
+ // be the reason a verb fails, so each sink is individually guarded and
9
+ // the whole body is wrapped again. A throwing sink must not deprive its
10
+ // neighbours of the event.
11
+ // 2. It writes nothing to stdout. That is a property of the sinks, but the
12
+ // recorder is where the sink list is chosen, so it is the enforcement
13
+ // point for which sinks exist at all.
14
+ //
15
+ // Level filtering happens here rather than in the sinks because the two sinks
16
+ // have DIFFERENT thresholds (stderr warn, file info) and only the recorder
17
+ // knows which sink it is currently feeding.
18
+ //
19
+ // A sink is a { threshold, write } pair and ALWAYS states its own threshold —
20
+ // there is no default filled in on its behalf. A test that wants the real
21
+ // thresholds wraps defaultSinks(root, env) and swaps only `write`, so it
22
+ // exercises the levels production resolves instead of a branch that exists
23
+ // only for tests.
24
+
25
+ const { makeEvent } = require('./event');
26
+ const { resolveLevels, atLeast } = require('./settings');
27
+ const stderrSink = require('./sinks/stderr');
28
+ const fileSink = require('./sinks/file');
29
+ const spoolSink = require('./spool');
30
+ const { decideForProject } = require('./telemetry');
31
+
32
+ // `logPath` is passed straight through to the file sink, which already takes it
33
+ // (sinks/file.js:44) and falls back to mauto.ndjson when it is undefined. So
34
+ // omitting it — as record() does — is exactly today's CLI behaviour, and naming
35
+ // it is how a writer with its own log file (the daemon's daemon.ndjson) gets one
36
+ // without a second copy of this list.
37
+ //
38
+ // `tracePath` adds a THIRD sink — the per-run trace — and two things about it
39
+ // are deliberate:
40
+ //
41
+ // ADDITIONAL, not a redirect. Sending a run's events to the trace instead of
42
+ // mauto.ndjson would punch a hole in the maintainer log across exactly the
43
+ // invocations a bug report is about, and would let the trace's cap silently
44
+ // drop CLI history. The cost of writing both is one extra ~300-byte
45
+ // open/write/close per verb against a 112ms process start.
46
+ //
47
+ // INJECTED, never resolved from `env` here. A daemon reads MAUTO_RUN_ID from
48
+ // whichever verb spawned it and then outlives that run — its environment is
49
+ // pinned at spawn and cannot change. Resolving the trace from `env` would
50
+ // make boundRecorder pick it up, and the daemon would file every later run's
51
+ // device calls under the first run's id. Only cli.js's finish() passes this.
52
+ //
53
+ // The trace's threshold is levels.file (info), the same as the main log, not
54
+ // levels.stderr (warn): a trace exists to reconstruct one run, and a threshold
55
+ // that filtered out most of a passing run's own verb.end events would defeat
56
+ // that purpose. It is deliberately not wider than `info` either — nothing in
57
+ // this slice records below `info`, so there is no debug firehose to admit by
58
+ // pinning the trace looser than the log it is bound to the same constant as.
59
+ function defaultSinks(projectRoot, env, { logPath, tracePath } = {}) {
60
+ const levels = resolveLevels(env);
61
+ const sinks = [
62
+ { threshold: levels.stderr, write: (e) => stderrSink.write(e) },
63
+ { threshold: levels.file, write: (e) => fileSink.write(e, { projectRoot, env, logPath }) },
64
+ ];
65
+ if (tracePath) {
66
+ sinks.push({
67
+ threshold: levels.file,
68
+ // bound:'cap' — a trace must not rotate; see sinks/file.js.
69
+ write: (e) => fileSink.write(e, { projectRoot, env, logPath: tracePath, bound: 'cap' }),
70
+ });
71
+ }
72
+
73
+ // The spool is the ONLY path by which an event can leave this machine, so
74
+ // it is not merely level-gated — it is not CONSTRUCTED at all until a
75
+ // human has opted in. While telemetry is off there is no spool file,
76
+ // which means there is nothing for a flusher to find even if one ran.
77
+ // That is the first of the three layers behind "no upload path is
78
+ // reachable while disabled"; the other two are flush()'s own early
79
+ // return and the one-file transport lint guard.
80
+ //
81
+ // Its threshold is spool.SPOOL_LEVEL, deliberately NOT levels.file:
82
+ // telemetry has its own separate control, so MAUTO_LOG_LEVEL=debug must
83
+ // not multiply what leaves the machine by 40x, and MAUTO_LOG_LEVEL=silent
84
+ // must not be read as consent withdrawal (there is an explicit control for
85
+ // that: MAUTO_TELEMETRY=0 / DO_NOT_TRACK=1).
86
+ //
87
+ // decideForProject memoises per project root — one config.json read per
88
+ // process, not one per event. See src/observe/telemetry.js.
89
+ if (decideForProject(projectRoot, env).enabled) {
90
+ sinks.push({
91
+ threshold: spoolSink.SPOOL_LEVEL,
92
+ write: (e) => spoolSink.write(e, { projectRoot, env }),
93
+ });
94
+ }
95
+
96
+ return sinks;
97
+ }
98
+
99
+ function record(
100
+ fields = {},
101
+ { projectRoot = process.cwd(), env = process.env, sinks, tracePath } = {}
102
+ ) {
103
+ try {
104
+ const level = fields.level || 'info';
105
+ const list = sinks || defaultSinks(projectRoot, env, { tracePath });
106
+ const event = makeEvent({ ...fields, level });
107
+
108
+ for (const sink of list) {
109
+ if (!atLeast(level, sink.threshold)) continue;
110
+ try {
111
+ sink.write(event);
112
+ } catch (_) {
113
+ // One bad sink must not deprive the others.
114
+ }
115
+ }
116
+ } catch (_) {
117
+ // Belt and suspenders: a hostile event (cyclic, exotic getters) must not
118
+ // escape as an exception into the verb's own control flow.
119
+ }
120
+ }
121
+
122
+ // The never-load-bearing guarantee, as a function instead of as a warning.
123
+ //
124
+ // record() is already total; this is for the INJECTED seams, which exist so
125
+ // that something OTHER than record() gets passed. In the daemon `call.start`
126
+ // fires before the device call, so an unguarded sink there decides whether a
127
+ // tap happens at all. Exported from here, with the seam it guards, so every
128
+ // boundary shares one implementation.
129
+ function safeObserve(observe) {
130
+ return (fields) => {
131
+ try {
132
+ observe(fields);
133
+ } catch (_) {
134
+ /* an observability fault is never worth the work it was describing */
135
+ }
136
+ };
137
+ }
138
+
139
+ // record() bound to one writer, for a process that records many times.
140
+ //
141
+ // record()'s own defaults are written for a one-shot verb that records once and
142
+ // exits. Two of them are wrong for anything long-lived:
143
+ //
144
+ // projectRoot defaults to process.cwd(). A detached daemon inherits its cwd
145
+ // from whichever verb spawned it and then OUTLIVES that verb —
146
+ // the user can cd away or delete the directory. Bound once, here.
147
+ // sinks record() resolves levels per call. For the CLI that is once
148
+ // per process; for a daemon it would be once per device call,
149
+ // re-reading an environment that CANNOT change — nothing can
150
+ // export a variable into a running detached process. So the sink
151
+ // list is built ONCE, at construction. The consequence is worth
152
+ // stating: MAUTO_LOG_LEVEL and MAUTO_LOG_DIR are captured at
153
+ // construction and pinned for the caller's lifetime; for the
154
+ // daemon, `mauto session end` is how you change them.
155
+ //
156
+ // `fields` are stamped on every event and applied AFTER the caller's, so no call
157
+ // site can forge `src`, claim another session's id or misreport its pid. That
158
+ // identity is the whole point of binding: it is what makes one process's events
159
+ // a queryable group.
160
+ //
161
+ // CONSTRUCTION is total too, and the try/catch is here rather than around each
162
+ // caller's boundRecorder() call because building sinks happens before any event
163
+ // exists, outside the returned observe's own guarantee. A failure degrades to an
164
+ // inert observe — session-log.js's frozen IGNORED handle, not a null every call
165
+ // site has to branch on.
166
+ function boundRecorder({ projectRoot, env = process.env, logPath, fields = {} } = {}) {
167
+ let sinks;
168
+ try {
169
+ sinks = defaultSinks(projectRoot, env, { logPath });
170
+ } catch (_) {
171
+ return () => {};
172
+ }
173
+ return function observe(extra = {}) {
174
+ record({ ...extra, ...fields }, { projectRoot, env, sinks });
175
+ };
176
+ }
177
+
178
+ module.exports = { record, defaultSinks, boundRecorder, safeObserve };