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,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 };
|