mobile-automator 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/bin/mauto-session-daemon.js +85 -16
- package/package.json +1 -1
- package/src/cli.js +724 -59
- package/src/device/bridge.js +30 -0
- package/src/device/connection.js +23 -4
- package/src/device/crash-model.js +110 -0
- package/src/device/device-call.js +145 -0
- package/src/device/failure-probe.js +276 -0
- package/src/device/mobile-mcp-tools.js +64 -0
- package/src/device/resolve-connection.js +8 -10
- package/src/device/session-daemon.js +265 -51
- package/src/device/session-handle.js +53 -0
- package/src/device/session-log.js +5 -16
- package/src/guide/content/execute.agnostic.md +48 -5
- package/src/guide/content/execute.aware.md +42 -1
- package/src/guide/content/execute.invariants.md +1 -0
- package/src/observe/accepts.js +108 -0
- package/src/observe/event.js +241 -0
- package/src/observe/failure-capture.js +166 -0
- package/src/observe/flush.js +177 -0
- package/src/observe/paths.js +125 -0
- package/src/observe/recorder.js +178 -0
- package/src/observe/settings.js +51 -0
- package/src/observe/sinks/file.js +101 -0
- package/src/observe/sinks/stderr.js +20 -0
- package/src/observe/spool.js +199 -0
- package/src/observe/telemetry.js +143 -0
- package/src/observe/trace.js +174 -0
- package/src/observe/transport.js +169 -0
- package/src/observe/vocab.js +96 -0
- package/src/output/envelope.js +6 -0
- package/src/result/capability-catalog.js +14 -0
- package/src/result/store.js +98 -1
- package/src/schemas/config_schema.json +11 -0
- package/src/schemas/result_schema.json +55 -0
- package/src/setup/scaffold.js +27 -0
- package/src/util/log-rotate.js +34 -0
- package/src/workspace/discover.js +69 -0
|
@@ -0,0 +1,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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|