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,64 @@
1
+ 'use strict';
2
+
3
+ // The mobile-mcp primitives mauto actually calls, as a closed set.
4
+ //
5
+ // It exists so `tool` can be sends:true in the event catalog. That field's
6
+ // justification — an enumerated primitive name — is only true if the recorded
7
+ // value is enforced to be one, exactly as `verb` is enforced against
8
+ // commander's resolved command rather than argv (see src/observe/event.js).
9
+ // The daemon's `tool` arrives inside a socket frame and the daemon's socket is
10
+ // reachable by anything on the machine, so an unchecked value is caller-
11
+ // supplied text.
12
+ //
13
+ // tests/lint/mobile-mcp-tool-coverage.test.js pins this set to the primitives
14
+ // src/device/bridge.js calls, in BOTH directions, so neither a new bridge call
15
+ // nor a stale entry can drift. The guard being bidirectional is why the crash
16
+ // primitives and DeviceBridge#listCrashes/#getCrash landed in one commit: either
17
+ // half alone fails one of its two assertions.
18
+
19
+ // The vocabulary, as a frozen ARRAY, exported only so the lint guard can
20
+ // enumerate it in both directions.
21
+ //
22
+ // Deliberately not a frozen Set. Object.freeze on a Set is decorative: a Set's
23
+ // entries live in its [[SetData]] internal slot, which `.add` / `.delete` /
24
+ // `.clear` mutate without ever consulting the object's extensibility, so
25
+ // freezing reaches only the Set's own properties. The result reports
26
+ // Object.isFrozen === true over a wide-open allowlist, which is worse than an
27
+ // unfrozen set — a reader who checks is actively misled. On an array the
28
+ // entries ARE ordinary indexed properties, so the freeze is real: push throws
29
+ // (it must define a property on a non-extensible object) and no element
30
+ // assignment can land.
31
+ const MOBILE_MCP_TOOL_NAMES = Object.freeze([
32
+ 'mobile_list_available_devices',
33
+ 'mobile_list_elements_on_screen',
34
+ 'mobile_save_screenshot',
35
+ 'mobile_click_on_screen_at_coordinates',
36
+ 'mobile_long_press_on_screen_at_coordinates',
37
+ 'mobile_double_tap_on_screen',
38
+ 'mobile_type_keys',
39
+ 'mobile_swipe_on_screen',
40
+ 'mobile_get_screen_size',
41
+ 'mobile_press_button',
42
+ 'mobile_launch_app',
43
+ 'mobile_install_app',
44
+ 'mobile_uninstall_app',
45
+ 'mobile_open_url',
46
+ 'mobile_set_orientation',
47
+ // Diagnostic, not a scenario action — see DeviceBridge#listCrashes.
48
+ 'mobile_list_crashes',
49
+ 'mobile_get_crash',
50
+ ]);
51
+
52
+ // The structure isKnownTool actually queries is module-PRIVATE, and that — not
53
+ // the freeze above — is what closes the set. Freezing protects the enumeration;
54
+ // withholding the reference protects the decision. No consumer can hold the
55
+ // thing `.has()` is called on, so there is nothing to call `.add()` on.
56
+ // tests/lint/mobile-mcp-tool-coverage.test.js attempts the widening and the
57
+ // narrowing through every export and then asks isKnownTool for the verdict.
58
+ const TOOL_SET = new Set(MOBILE_MCP_TOOL_NAMES);
59
+
60
+ function isKnownTool(name) {
61
+ return typeof name === 'string' && TOOL_SET.has(name);
62
+ }
63
+
64
+ module.exports = { MOBILE_MCP_TOOL_NAMES, isKnownTool };
@@ -21,10 +21,8 @@
21
21
  // Everything device-touching is injected so the whole matrix is unit-testable
22
22
  // with a fake daemon + fake createCall + fake spawn.
23
23
 
24
- const fs = require('fs');
25
-
26
24
  const { DeviceBridge } = require('./bridge');
27
- const paths = require('./session-paths');
25
+ const { readHandle } = require('./session-handle');
28
26
  const sessionClient = require('./session-client');
29
27
  const { daemonLogHint } = require('./session-log');
30
28
  const sessionSpawn = require('./session-spawn');
@@ -38,14 +36,14 @@ const NOOP_CLOSE = () => Promise.resolve();
38
36
  // Read the live daemon's pinned device from its handle, or null. A null pin
39
37
  // means the daemon serves whatever device mobile-mcp selected (matches any
40
38
  // request that doesn't pin a specific device).
39
+ //
40
+ // Reading and parsing the handle belongs to session-handle.js, which owns that
41
+ // file and is already total (absent/unreadable/malformed all mean "no
42
+ // session"). This is the projection onto the one field this module cares about,
43
+ // not a second reader of the same JSON.
41
44
  function readHandleDevice(projectRoot) {
42
- try {
43
- const raw = fs.readFileSync(paths.handlePath(projectRoot), 'utf8');
44
- const handle = JSON.parse(raw);
45
- return handle && handle.device ? handle.device : null;
46
- } catch (_) {
47
- return null;
48
- }
45
+ const handle = readHandle(projectRoot);
46
+ return (handle && handle.device) || null;
49
47
  }
50
48
 
51
49
  // Does a daemon pinned to `handleDevice` satisfy a request for `requested`?
@@ -24,6 +24,10 @@ const net = require('net');
24
24
 
25
25
  const paths = require('./session-paths');
26
26
  const { FrameParser } = require('./session-protocol');
27
+ const { newSessionId } = require('./session-handle');
28
+ const { makeDeviceCall } = require('./device-call');
29
+ const { safeObserve: guardObserve } = require('../observe/recorder');
30
+ const { FLUSH_INTERVAL_MS } = require('../observe/flush');
27
31
 
28
32
  const DEFAULT_IDLE_MS = 5 * 60 * 1000;
29
33
 
@@ -158,11 +162,93 @@ async function startDaemon({
158
162
  onUndeliverable = null,
159
163
  scheduleTimeout = defaultScheduleTimeout,
160
164
  execFile = childProcess.execFileSync,
165
+ // Structured observability, injected as a FACTORY rather than as a ready-made
166
+ // recorder. See the binding below for why it has to be a factory.
167
+ //
168
+ // Defaulted to an inert one for the same reason the old `observe` parameter
169
+ // was: startDaemon also runs IN-PROCESS across ~40 unit tests, where a live
170
+ // stderr sink would write into jest's reporter output. The only process that
171
+ // is actually a daemon — bin/mauto-session-daemon.js — injects boundRecorder,
172
+ // and tests/unit/bin/mauto-session-daemon-observe.test.js proves it does. The
173
+ // factory must be construction-total (boundRecorder is: recorder.js degrades
174
+ // to an inert observe rather than throwing), because a recorder that cannot
175
+ // be built must not be the reason a device session fails to start.
176
+ recorderFor = () => () => {},
177
+ // The spool flusher, as a FACTORY — exactly recorderFor's shape, and for
178
+ // the same reason: it needs THIS daemon's own safeObserve (bound to the
179
+ // sessionId minted below), which doesn't exist until this function is
180
+ // already running. Defaulted to null: startDaemon runs IN-PROCESS across
181
+ // ~40 unit tests, and a default that built a real flusher would put
182
+ // network I/O in the unit suite. bin/mauto-session-daemon.js — the only
183
+ // process that is actually a daemon — injects makeFlusher.
184
+ flushFor = null,
185
+ flushIntervalMs = FLUSH_INTERVAL_MS,
161
186
  } = {}) {
162
187
  if (!projectRoot) throw new TypeError('startDaemon requires projectRoot');
163
188
 
189
+ // This daemon's identity, minted HERE and nowhere else.
190
+ //
191
+ // startDaemon is the SOLE owner of session_id: it mints it, binds it onto
192
+ // every event it records, and writes it into the handle below — so the handle
193
+ // and the event stream cannot name different sessions, because there is no
194
+ // second parameter for them to disagree through. Callers read it back off the
195
+ // return value.
196
+ //
197
+ // It used to arrive as `sessionId = newSessionId()`, an optional parameter
198
+ // sitting alongside an already-bound `observe`, with nothing forcing the two
199
+ // to agree: a caller that wired observability but not an id got a handle
200
+ // advertising one session and a stream stamped with none, silently. That is
201
+ // exactly the class of failure device-call.js refuses to allow for its own
202
+ // four inputs ("every one of these has a failure mode that is silent if it is
203
+ // wrong"), and it is why the id cannot be a parameter at all — requiring one
204
+ // would still leave two ways in. Pinned by the last test in
205
+ // tests/unit/device/daemon-observability.test.js, which reads the handle and
206
+ // the log file and demands one value.
207
+ const sessionId = newSessionId();
208
+
209
+ // Which is why the seam is a factory: the identity a recorder must be bound
210
+ // to does not exist until the line above. The caller owns WHERE events go;
211
+ // the daemon owns WHOSE they are. `pid` is bound here too, being the same
212
+ // kind of fact, so no daemon call site can hand-stamp either one.
213
+ const observe = recorderFor({ src: 'daemon', session_id: sessionId, pid: process.pid });
214
+
215
+ // The injected seam, guarded once at the boundary with the canonical wrapper
216
+ // (src/observe/recorder.js) rather than a fourth hand-rolled try/catch. Two
217
+ // failure modes this closes, both proved by
218
+ // tests/unit/device/daemon-observability.test.js: the daemon dying at startup
219
+ // killed by the code whose only job is to explain why daemons die, and — on
220
+ // the failure paths, which observe BEFORE releaseLock() — a telemetry fault
221
+ // masking the real error and leaking the lock, wedging every later spawn.
222
+ const safeObserve = guardObserve(observe);
223
+
224
+ // Built here, unwrapped, on the same trust as recorderFor above: the
225
+ // factory must be construction-total (makeFlusher just destructures
226
+ // options and returns a closure — it cannot throw), so this is not a
227
+ // second hand-rolled guard, it is the same contract applied twice.
228
+ const flush = typeof flushFor === 'function' ? flushFor({ observe: safeObserve }) : null;
229
+
230
+ // Zero point for every dur_ms this daemon reports: startup duration on
231
+ // daemon.start, total session lifetime on daemon.stop.
232
+ const startedAtMs = Date.now();
233
+
234
+ // Guarded like the lock failure below, and for the same reason: this is the
235
+ // FIRST filesystem operation the daemon performs, and it fails on exactly the
236
+ // conditions daemon.start_failure exists to report (EACCES, EROFS, ENOSPC on
237
+ // the workspace). An unguarded throw here is the earliest invisible death
238
+ // there is — nothing else in the system records it, and the spawning verb
239
+ // sees only a readiness timeout.
164
240
  const dir = paths.sessionDir(projectRoot);
165
- fs.mkdirSync(dir, { recursive: true });
241
+ try {
242
+ fs.mkdirSync(dir, { recursive: true });
243
+ } catch (err) {
244
+ safeObserve({
245
+ level: 'error',
246
+ event: 'daemon.start_failure',
247
+ error_code: err && err.code,
248
+ message: err && (err.message || String(err)),
249
+ });
250
+ throw err;
251
+ }
166
252
 
167
253
  // Reap a crashed daemon's leftovers before we bind.
168
254
  cleanStale(projectRoot, { execFile });
@@ -179,10 +265,22 @@ async function startDaemon({
179
265
  fs.writeSync(lockFd, String(process.pid) + '\n');
180
266
  } catch (err) {
181
267
  if (err && err.code === 'EEXIST') {
268
+ // warn, not error: a spawn-race loser failing here is the lock DOING ITS
269
+ // JOB. It is worth seeing (a workspace where it repeats means two agents
270
+ // are fighting over one device) but it is not a malfunction.
271
+ safeObserve({ level: 'warn', event: 'daemon.lock_conflict', error_code: 'ELOCKED' });
182
272
  const e = new Error('device session daemon already starting for this workspace (lock held)');
183
273
  e.code = 'ELOCKED';
184
274
  throw e;
185
275
  }
276
+ // Not a race — EACCES, EROFS, ENOSPC on .session/. An invisible death that
277
+ // nothing else in the system reports.
278
+ safeObserve({
279
+ level: 'error',
280
+ event: 'daemon.start_failure',
281
+ error_code: err && err.code,
282
+ message: err && (err.message || String(err)),
283
+ });
186
284
  throw err;
187
285
  }
188
286
 
@@ -205,10 +303,49 @@ async function startDaemon({
205
303
  try {
206
304
  ({ call, close } = await createCall({ device }));
207
305
  } catch (err) {
306
+ // #156's core symptom: mobile-mcp never came up, and until now the only
307
+ // trace was a 15s readiness timeout in the spawning verb.
308
+ safeObserve({
309
+ level: 'error',
310
+ event: 'daemon.connect_failure',
311
+ error_code: err && err.code,
312
+ message: err && (err.message || String(err)),
313
+ dur_ms: Date.now() - startedAtMs,
314
+ });
208
315
  releaseLock();
209
316
  throw err;
210
317
  }
211
318
 
319
+ // One bounded, measured call, built ONCE and shared by every connection: the
320
+ // timeout, the sink and the connection are all per-daemon, so nothing about
321
+ // it is per-socket. Built HERE, before server.listen() below, or the first
322
+ // frame to arrive would hit a temporal dead zone.
323
+ //
324
+ // safeObserve, never the raw `observe` — device-call.js is deliberately not
325
+ // guarded a second time (pinned in tests/unit/device/device-call.test.js).
326
+ //
327
+ // The per-call correlation id is minted HERE, from a counter scoped to this
328
+ // daemon lifetime — the same scope as session_id, one level down. It is what
329
+ // makes call.start and call.end pairable: session_id cannot do it (every call
330
+ // in a lifetime shares one) and neither can position or timestamp, because
331
+ // the daemon multiplexes and two sockets' calls interleave and finish out of
332
+ // order. The counter lives at THIS scope rather than inside makeDeviceCall
333
+ // because that module's factory scope must stay free of mutable state (see
334
+ // its WARNING), and because "per daemon lifetime" is a fact this function
335
+ // owns.
336
+ //
337
+ // Deliberately NOT the frame's `req.id`, which the router discards: that
338
+ // value is client-chosen and the socket is reachable by any process on the
339
+ // machine, so it could not be sends:true in the event catalog. A
340
+ // daemon-minted integer can, on the same grounds as dur_ms.
341
+ let callSeq = 0;
342
+ const invoke = makeDeviceCall(call, {
343
+ scheduleTimeout,
344
+ observe: safeObserve,
345
+ timeoutMs: DAEMON_CALL_TIMEOUT_MS,
346
+ nextCallId: () => (callSeq += 1),
347
+ });
348
+
212
349
  let idleTimer = null;
213
350
  let stopping = false;
214
351
  let inFlight = 0; // device calls whose reply has not yet been sent
@@ -219,19 +356,21 @@ async function startDaemon({
219
356
  const notifyUndeliverable =
220
357
  typeof onUndeliverable === 'function'
221
358
  ? onUndeliverable
222
- : (info) => {
223
- try {
224
- process.stderr.write(
225
- `mauto-session-daemon: undeliverable reply id=${info.id}: ${info.reason}\n`
226
- );
227
- } catch (_) {
228
- // fd 2 is unwritable (full disk, or the workspace log could not be
229
- // opened and this is /dev/null's successor). Since #163 it normally
230
- // reaches mobile-automator/.session/daemon.log; either way an
231
- // undeliverable reply is already recorded in `undeliverable`, so
232
- // losing the warning must not take the daemon down.
233
- }
234
- };
359
+ : (info) =>
360
+ // Was a bespoke process.stderr.write with its own try/catch. It is
361
+ // the same destination — the stderr sink writes to fd 2, which since
362
+ // #176 IS mobile-automator/.session/daemon.log — but now structured,
363
+ // and safeObserve (plus record()'s own never-throw guarantee)
364
+ // subsumes the hand-rolled guard. That guard mattered: this runs
365
+ // inside reply(), on the socket's async data handler, so a throw here
366
+ // would surface as an unhandled rejection. The frame id and the
367
+ // transport reason go in `message`, which is sends:false: a socket
368
+ // errno is free text and free text never reaches the telemetry path.
369
+ safeObserve({
370
+ level: 'warn',
371
+ event: 'daemon.undeliverable',
372
+ message: `id=${info.id}: ${info.reason}`,
373
+ });
235
374
 
236
375
  function recordUndeliverable(info) {
237
376
  undeliverable.push(info);
@@ -252,16 +391,75 @@ async function startDaemon({
252
391
  if (stopping) return; // never re-arm once we have begun tearing down
253
392
  if (idleMs > 0 && Number.isFinite(idleMs)) {
254
393
  idleTimer = setTimeout(() => {
255
- stop().catch(() => {});
394
+ stop('idle').catch(() => {});
256
395
  }, idleMs);
257
396
  if (typeof idleTimer.unref === 'function') idleTimer.unref();
258
397
  }
259
398
  }
260
399
 
261
- async function stop() {
400
+ let flushTimer = null;
401
+
402
+ function clearFlush() {
403
+ if (flushTimer) {
404
+ clearTimeout(flushTimer);
405
+ flushTimer = null;
406
+ }
407
+ }
408
+
409
+ // Why the daemon does this at all: a one-shot verb cannot. process.exit()
410
+ // tears down a pending socket, so a verb's POST is dropped a large fraction
411
+ // of the time. The daemon is the only process here with an event loop, and
412
+ // between device calls it is doing nothing at all.
413
+ //
414
+ // A self-rescheduling setTimeout rather than setInterval: the flusher returns
415
+ // the delay it wants (exponential backoff on a retryable failure, reset on
416
+ // success), and setInterval cannot express that. unref'd, so a pending flush
417
+ // can never hold the process open past its idle reap or delay a stop().
418
+ //
419
+ // In-flight device calls are skipped rather than competed with — the flush is
420
+ // strictly lower priority than the device work the user is waiting on, and
421
+ // the very next tick picks it up.
422
+ function armFlush(delayMs) {
423
+ clearFlush();
424
+ if (!flush || stopping) return;
425
+ if (!(delayMs > 0) || !Number.isFinite(delayMs)) return;
426
+ flushTimer = setTimeout(async () => {
427
+ if (stopping) return;
428
+ if (inFlight > 0) {
429
+ armFlush(delayMs);
430
+ return;
431
+ }
432
+ let next = delayMs;
433
+ try {
434
+ const r = await flush();
435
+ if (r && Number.isFinite(r.nextDelayMs) && r.nextDelayMs > 0) next = r.nextDelayMs;
436
+ } catch (_) {
437
+ // flush() is already total (src/observe/flush.js); this is that
438
+ // guarantee restated as a property of the daemon rather than of its
439
+ // current caller.
440
+ }
441
+ armFlush(next);
442
+ }, delayMs);
443
+ if (typeof flushTimer.unref === 'function') flushTimer.unref();
444
+ }
445
+
446
+ async function stop(reason = 'explicit') {
262
447
  if (stopping) return;
263
448
  stopping = true;
264
449
  clearIdle();
450
+ clearFlush();
451
+
452
+ // Recorded HERE, before the drain, not after teardown. stop() can block on
453
+ // an in-flight device call and on close()ing a mobile-mcp child that will
454
+ // not die — the two hangs most worth seeing — and an event written after
455
+ // them would never be written at all. The `if (stopping) return` above is
456
+ // what keeps this to one event per daemon.
457
+ safeObserve({
458
+ level: 'info',
459
+ event: 'daemon.stop',
460
+ stop_reason: reason,
461
+ dur_ms: Date.now() - startedAtMs,
462
+ });
265
463
 
266
464
  // Drain-before-teardown: never destroy a socket or tear down the shared
267
465
  // connection while a device call is still in flight. The action already hit
@@ -299,7 +497,7 @@ async function startDaemon({
299
497
  }
300
498
 
301
499
  function onSignal() {
302
- stop().catch(() => {});
500
+ stop('signal').catch(() => {});
303
501
  }
304
502
 
305
503
  server.on('connection', (socket) => {
@@ -397,7 +595,7 @@ async function startDaemon({
397
595
  if (req.type === 'shutdown') {
398
596
  await reply({ id: req.id, ok: true, result: { stopping: true } });
399
597
  // Reply already flushed (awaited) — now tear down.
400
- setImmediate(() => stop().catch(() => {}));
598
+ setImmediate(() => stop('shutdown').catch(() => {}));
401
599
  continue;
402
600
  }
403
601
  if (req.type === 'ping') {
@@ -418,43 +616,30 @@ async function startDaemon({
418
616
  armIdle();
419
617
  continue;
420
618
  }
619
+ // inFlight/armIdle stay HERE, not in the decorator: they gate stop()'s
620
+ // drain and are reported by ping/getSessionStatus, which are facts
621
+ // about the daemon's sockets rather than about one device call.
421
622
  inFlight += 1;
422
623
  try {
423
- // Bound the shared mobile-mcp call. The daemon's timeout (25s) is
424
- // BELOW the client's (30s) so this error always reaches the client
425
- // as an honest {kind:'timeout'} reply instead of being dropped by
426
- // the client's own timeout — a dropped reply reads as a
427
- // false-failure and prompts a retry → double-execution. The
428
- // underlying call is NOT cancellable; it keeps running in the
429
- // background, which is fine — the point is the agent gets a bounded,
430
- // honest error. Swallow the loser's eventual settlement so it can't
431
- // surface as an unhandled rejection after the race already settled.
432
- const callPromise = call(req.tool, req.args || {});
433
- callPromise.catch(() => {});
434
- const result = await Promise.race([
435
- callPromise,
436
- scheduleTimeout(DAEMON_CALL_TIMEOUT_MS).then(() => {
437
- const e = new Error(
438
- `${req.tool}: the mobile-mcp call did not respond within ${DAEMON_CALL_TIMEOUT_MS}ms`
439
- );
440
- e.kind = 'timeout';
441
- e.hint = 'the action may have partially executed on the device — verify state before retrying';
442
- throw e;
443
- }),
444
- ]);
624
+ const result = await invoke(req.tool, req.args || {});
445
625
  await reply({ id: req.id, ok: true, result });
446
626
  } catch (err) {
447
- if (err && err.kind === 'timeout') {
448
- await reply({
449
- id: req.id,
450
- ok: false,
451
- error: { message: err.message, kind: 'timeout', hint: err.hint },
452
- });
453
- } else {
454
- // Non-timeout failure: no kind → client/deviceFail default to
455
- // 'device'. Only the timeout path sets kind:'timeout'.
456
- await reply({ id: req.id, ok: false, error: { message: err.message || String(err) } });
457
- }
627
+ // Only a timeout carries kind/hint outward; a non-timeout failure
628
+ // drops both so client/deviceFail defaults to 'device'. Do NOT
629
+ // "simplify" this to `kind: err && err.kind`: that would forward an
630
+ // engine-invented kind that exitCodeFor does not enumerate (turning
631
+ // a device failure's exit 2 into exit 1) and would emit "kind":null
632
+ // for a falsy rejection. daemon-timeout.test.js pins both.
633
+ const timedOut = Boolean(err && err.kind === 'timeout');
634
+ await reply({
635
+ id: req.id,
636
+ ok: false,
637
+ error: {
638
+ message: err && (err.message || String(err)),
639
+ kind: timedOut ? 'timeout' : undefined,
640
+ hint: timedOut ? err.hint : undefined,
641
+ },
642
+ });
458
643
  } finally {
459
644
  inFlight -= 1;
460
645
  armIdle(); // arm idle only after the reply has completed
@@ -488,6 +673,13 @@ async function startDaemon({
488
673
  // listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
489
674
  // bind, or a recycled-pid wedge). The connection was already built — close
490
675
  // it so we never leak the mobile-mcp child, and release the lock.
676
+ safeObserve({
677
+ level: 'error',
678
+ event: 'daemon.listen_failure',
679
+ error_code: err && err.code,
680
+ message: err && (err.message || String(err)),
681
+ dur_ms: Date.now() - startedAtMs,
682
+ });
491
683
  try {
492
684
  await close();
493
685
  } catch (_) {
@@ -500,6 +692,10 @@ async function startDaemon({
500
692
  // Persist handle + pidfile now that we are actually listening.
501
693
  const handle = {
502
694
  pid: process.pid,
695
+ // Correlates every event from this daemon lifetime, which run_id cannot:
696
+ // it lives in the handle so a verb reads it from a file that is already
697
+ // there instead of paying a socket round trip (src/device/session-handle.js).
698
+ session_id: sessionId,
503
699
  device: device || null,
504
700
  socket: socketPath,
505
701
  started_at: new Date().toISOString(),
@@ -512,10 +708,28 @@ async function startDaemon({
512
708
  process.on('SIGINT', onSignal);
513
709
 
514
710
  armIdle();
711
+ armFlush(flushIntervalMs);
712
+
713
+ // Recorded after the handle exists, so anything reading the log can also read
714
+ // the handle it names. device_id is sends:false — a device serial never
715
+ // leaves the machine.
716
+ safeObserve({
717
+ level: 'info',
718
+ event: 'daemon.start',
719
+ device_id: device || undefined,
720
+ dur_ms: Date.now() - startedAtMs,
721
+ });
515
722
 
516
723
  return {
517
724
  socketPath,
518
725
  device: device || null,
726
+ sessionId,
727
+ // This daemon's recorder, already bound to the identity above and already
728
+ // guarded. Handed back so a caller whose own instrumentation outlives
729
+ // startDaemon — the bin's crash guards — can adopt it instead of building a
730
+ // second one and binding the id by hand, which is how the two owners this
731
+ // replaced came about.
732
+ observe: safeObserve,
519
733
  stop,
520
734
  // Replies that could not be delivered (peer gone / unflushable). Surfaced so
521
735
  // callers/tests can observe transport failures instead of them being eaten.
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // Read side of mobile-automator/.session/session.json — the ONLY one — plus the
4
+ // generator for the session id it carries. resolve-connection.js's
5
+ // readHandleDevice() is a projection onto the `device` field and delegates here;
6
+ // it used to parse the file itself, which meant two readers of one artifact.
7
+ //
8
+ // The handle is where session_id lives because it is a file that is ALREADY
9
+ // there, already written at exactly the right moment (once the daemon is
10
+ // listening) and already removed at exactly the right moment (stop()). A verb
11
+ // that wants to stamp its events with the live session's id must not have to
12
+ // pay a socket round trip for it, and a second copy in the ping reply would be
13
+ // a second source of truth that can disagree with this one.
14
+ //
15
+ // Nothing here throws: an absent, unreadable or malformed handle means "no
16
+ // session", which is a normal state, not an error.
17
+
18
+ const realFs = require('fs');
19
+ const crypto = require('crypto');
20
+
21
+ const { handlePath } = require('./session-paths');
22
+ const { SESSION_ID_PATTERN } = require('../observe/event');
23
+
24
+ // 16 hex chars from the CSPRNG, derived from NOTHING — not the project root,
25
+ // not the device, not the pid. That is what makes the event catalog's
26
+ // `sends: true` on session_id true rather than merely asserted, and
27
+ // tests/unit/device/session-handle.test.js is where the claim is enforced.
28
+ // readSessionId() holds the read side to the same shape (SESSION_ID_PATTERN).
29
+ //
30
+ // Deliberately NOT persisted across daemon restarts. A stable id would be a
31
+ // machine fingerprint; this one changes exactly when the daemon does, which is
32
+ // the fact it exists to report — run_id cannot express "the daemon died and
33
+ // respawned mid-run".
34
+ function newSessionId() {
35
+ return crypto.randomBytes(8).toString('hex');
36
+ }
37
+
38
+ function readHandle(projectRoot, { fs = realFs } = {}) {
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(handlePath(projectRoot), 'utf8'));
41
+ return parsed && typeof parsed === 'object' ? parsed : null;
42
+ } catch (_) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function readSessionId(projectRoot, { fs = realFs } = {}) {
48
+ const handle = readHandle(projectRoot, { fs });
49
+ const id = handle && handle.session_id;
50
+ return typeof id === 'string' && SESSION_ID_PATTERN.test(id) ? id : null;
51
+ }
52
+
53
+ module.exports = { newSessionId, readHandle, readSessionId };
@@ -12,8 +12,10 @@
12
12
  const realFs = require('fs');
13
13
 
14
14
  const { sessionDir, logFilePath } = require('./session-paths');
15
-
16
- const MAX_LOG_BYTES = 1024 * 1024;
15
+ // Rotation lives in src/util/log-rotate.js because the structured event log
16
+ // wants the identical policy. Re-exported below so this module's public surface
17
+ // is unchanged for its existing callers.
18
+ const { MAX_LOG_BYTES, rotateIfLarge } = require('../util/log-rotate');
17
19
 
18
20
  // The degraded handle, expressed as a VALUE so callers never branch on null.
19
21
  // `stdio: 'ignore'` is the behaviour that predates #163.
@@ -27,19 +29,6 @@ function daemonLogHint(projectRoot) {
27
29
  return `The daemon's output (including stack traces) was captured to ${logFilePath(projectRoot)}.`;
28
30
  }
29
31
 
30
- // Best-effort rotation to a single previous generation; clobbering an existing
31
- // .1 is the intended bound. Every failure here is survivable — no log yet
32
- // (ENOENT on stat), a concurrent spawn that already rotated (ENOENT on rename),
33
- // an unreadable directory (EACCES) — so we swallow and carry on to the open.
34
- // An oversized log beats no log.
35
- function rotateIfLarge(logPath, maxBytes, fs) {
36
- try {
37
- if (fs.statSync(logPath).size >= maxBytes) fs.renameSync(logPath, `${logPath}.1`);
38
- } catch (_) {
39
- /* nothing to rotate, or someone beat us to it */
40
- }
41
- }
42
-
43
32
  // Returns { stdio, write, close } — NEVER null.
44
33
  //
45
34
  // stdio the triple for child_process.spawn. stdout AND stderr point at the
@@ -60,7 +49,7 @@ function openDaemonLog(projectRoot, { maxBytes = MAX_LOG_BYTES, fs = realFs } =
60
49
  fs.mkdirSync(sessionDir(projectRoot), { recursive: true });
61
50
 
62
51
  const logPath = logFilePath(projectRoot);
63
- rotateIfLarge(logPath, maxBytes, fs);
52
+ rotateIfLarge(logPath, { maxBytes, fs });
64
53
 
65
54
  // Append, never 'w'. Lock-race losers exit ELOCKED through
66
55
  // bin/mauto-session-daemon.js and write to this same file; truncating on