mobile-automator 0.23.8
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/LICENSE +17 -0
- package/README.md +167 -0
- package/bin/mauto-session-daemon.js +101 -0
- package/bin/mauto.js +13 -0
- package/package.json +49 -0
- package/src/assertion/evaluator.js +285 -0
- package/src/cli.js +1722 -0
- package/src/config/coerce.js +97 -0
- package/src/config/manager.js +75 -0
- package/src/config/schema.js +124 -0
- package/src/device/action-catalog.js +99 -0
- package/src/device/bridge.js +139 -0
- package/src/device/connection.js +65 -0
- package/src/device/device-model.js +59 -0
- package/src/device/device-resolver.js +34 -0
- package/src/device/element-model.js +82 -0
- package/src/device/mobile-mcp-client.js +163 -0
- package/src/device/resolve-connection.js +156 -0
- package/src/device/selection.js +80 -0
- package/src/device/semantic-press/index.js +38 -0
- package/src/device/semantic-press/labels.js +39 -0
- package/src/device/semantic-press/resolver.js +100 -0
- package/src/device/session-client.js +173 -0
- package/src/device/session-daemon.js +526 -0
- package/src/device/session-paths.js +80 -0
- package/src/device/session-protocol.js +60 -0
- package/src/device/session-spawn.js +67 -0
- package/src/device/tool-args.js +14 -0
- package/src/guide/content/execute.agnostic.md +207 -0
- package/src/guide/content/execute.aware.md +281 -0
- package/src/guide/content/execute.invariants.md +4 -0
- package/src/guide/content/generate.agnostic.md +236 -0
- package/src/guide/content/generate.aware.md +327 -0
- package/src/guide/content/generate.invariants.md +5 -0
- package/src/guide/content/setup.agnostic.md +63 -0
- package/src/guide/content/setup.aware.md +91 -0
- package/src/guide/content/setup.invariants.md +3 -0
- package/src/guide/emitter.js +137 -0
- package/src/guide/placeholders.js +132 -0
- package/src/init/adapters.js +162 -0
- package/src/init/skill-meta.js +31 -0
- package/src/init/skill-renderer.js +60 -0
- package/src/mcp/prompts.js +55 -0
- package/src/mcp/server.js +55 -0
- package/src/memory/entries.js +61 -0
- package/src/memory/history.js +86 -0
- package/src/memory/lock.js +5 -0
- package/src/memory/paths.js +47 -0
- package/src/memory/store.js +230 -0
- package/src/output/envelope.js +106 -0
- package/src/result/capability-catalog.js +144 -0
- package/src/result/flags.js +52 -0
- package/src/result/store.js +273 -0
- package/src/scenario/validator.js +30 -0
- package/src/schemas/config_schema.json +68 -0
- package/src/schemas/format-error.js +19 -0
- package/src/schemas/result_schema.json +211 -0
- package/src/schemas/scenario_schema.json +587 -0
- package/src/setup/scaffold.js +53 -0
- package/src/util/atomic.js +41 -0
- package/src/util/lock.js +85 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Long-lived device session daemon.
|
|
4
|
+
//
|
|
5
|
+
// Builds ONE mobile-mcp connection (via the injected `createCall`) and serves
|
|
6
|
+
// every connecting one-shot verb over a per-workspace Unix domain socket. The
|
|
7
|
+
// single connection is the whole point of the feature: a 40-step scenario pays
|
|
8
|
+
// the spawn+handshake tax exactly once.
|
|
9
|
+
//
|
|
10
|
+
// Lifecycle:
|
|
11
|
+
// - clean any stale socket/pidfile before binding,
|
|
12
|
+
// - listen on the socket, write handle + pidfile once listening,
|
|
13
|
+
// - route { type:'call', tool, args } frames to the single `call`,
|
|
14
|
+
// - reset an idle timer on every request; reap the daemon when it expires,
|
|
15
|
+
// - shut down cleanly on a { type:'shutdown' } control frame and on
|
|
16
|
+
// SIGTERM/SIGINT, removing the socket/pidfile/handle.
|
|
17
|
+
//
|
|
18
|
+
// Everything that touches a real device is injected, so the full lifecycle is
|
|
19
|
+
// unit-testable with a fake createCall and a mkdtemp project root.
|
|
20
|
+
|
|
21
|
+
const childProcess = require('child_process');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const net = require('net');
|
|
24
|
+
|
|
25
|
+
const paths = require('./session-paths');
|
|
26
|
+
const { FrameParser } = require('./session-protocol');
|
|
27
|
+
|
|
28
|
+
const DEFAULT_IDLE_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
// Per-call timeout for the shared mobile-mcp connection. Deliberately BELOW the
|
|
31
|
+
// client's DEFAULT_TIMEOUT_MS (30000) so the daemon's timeout error always wins
|
|
32
|
+
// the race and reaches the client as an honest {kind:'timeout'} reply instead of
|
|
33
|
+
// being dropped by the client's own 30s timeout (which would look like a
|
|
34
|
+
// false-failure and prompt a retry → double-execution on the device). The
|
|
35
|
+
// structural invariant CLIENT_TIMEOUT > DAEMON_CALL_TIMEOUT is pinned by
|
|
36
|
+
// tests/unit/device/timeout-invariant.test.js.
|
|
37
|
+
const DAEMON_CALL_TIMEOUT_MS = 25000;
|
|
38
|
+
|
|
39
|
+
// Resolves after `ms` without keeping the process alive. Injectable so tests
|
|
40
|
+
// can drive the timeout path without waiting 25s.
|
|
41
|
+
function defaultScheduleTimeout(ms) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const t = setTimeout(resolve, ms);
|
|
44
|
+
if (typeof t.unref === 'function') t.unref();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultCreateCall(opts) {
|
|
49
|
+
return require('./mobile-mcp-client').createCall(opts);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Best-effort unlink — never throws.
|
|
53
|
+
function safeUnlink(p) {
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(p);
|
|
56
|
+
} catch (_) {
|
|
57
|
+
/* not present */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// True when a pid is a live process we own. ESRCH => dead; EPERM => alive.
|
|
62
|
+
// Kept exported for back-compat; cleanStale routes through pidIdentity, which
|
|
63
|
+
// also checks the process's identity (not just liveness).
|
|
64
|
+
function pidAlive(pid) {
|
|
65
|
+
if (!pid || !Number.isInteger(pid)) return false;
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 0);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return err.code === 'EPERM';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Classify a pid by liveness AND identity. Liveness alone is not enough: a
|
|
75
|
+
// recycled pid (an unrelated process that inherited the dead daemon's pid) is
|
|
76
|
+
// "alive" but is NOT our daemon, so a stale lock/pidfile pointing at it must be
|
|
77
|
+
// cleaned rather than respected. Returns one of:
|
|
78
|
+
// 'dead' - no such process (ESRCH or any non-EPERM kill error)
|
|
79
|
+
// 'ours' - alive AND its command line contains `mauto-session-daemon`
|
|
80
|
+
// 'other' - alive AND its command line does NOT (recycled pid)
|
|
81
|
+
// 'unknown' - alive but `ps` itself failed (e.g. no `ps` on the host) —
|
|
82
|
+
// treated as 'ours' by cleanStale: never clean when unsure.
|
|
83
|
+
// `execFile` is injectable for tests; a failure there must never crash the
|
|
84
|
+
// daemon, hence the 'unknown' fallback.
|
|
85
|
+
function pidIdentity(pid, { execFile = childProcess.execFileSync } = {}) {
|
|
86
|
+
if (!pid || !Number.isInteger(pid)) return 'dead';
|
|
87
|
+
// Our own pid is the in-process daemon (unit tests run startDaemon in the
|
|
88
|
+
// test process, so the lock/pidfile carry the test runner's pid). Never clean
|
|
89
|
+
// files that point at ourselves. In production cleanStale runs before the
|
|
90
|
+
// daemon writes its own pid/lock, so this branch only fires in-process.
|
|
91
|
+
if (pid === process.pid) return 'ours';
|
|
92
|
+
let alive;
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0);
|
|
95
|
+
alive = true;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.code === 'EPERM') alive = true;
|
|
98
|
+
else return 'dead';
|
|
99
|
+
}
|
|
100
|
+
if (!alive) return 'dead';
|
|
101
|
+
try {
|
|
102
|
+
const out = execFile('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
|
|
103
|
+
return /mauto-session-daemon/.test(String(out || '')) ? 'ours' : 'other';
|
|
104
|
+
} catch (_) {
|
|
105
|
+
return 'unknown';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Read a pid out of a one-line pid/lock file, or null when absent/garbage.
|
|
110
|
+
function readPidFile(file) {
|
|
111
|
+
try {
|
|
112
|
+
const pid = parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
|
|
113
|
+
return Number.isInteger(pid) ? pid : null;
|
|
114
|
+
} catch (_) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Remove stale socket/pidfile/handle/lock left by a crashed daemon so a fresh
|
|
120
|
+
// daemon can bind. Identity is decided from the LOCK file first (acquired before
|
|
121
|
+
// the connection is built, so it covers a daemon still mid-startup) then the
|
|
122
|
+
// pidfile (written once listening). If either points at a LIVE process that is
|
|
123
|
+
// OURS (or unclassifiable — 'unknown') we leave everything alone and signal the
|
|
124
|
+
// caller — the lock's O_EXCL or the listen() EADDRINUSE then surfaces the
|
|
125
|
+
// conflict rather than hijacking a healthy daemon. A pid that is alive but NOT
|
|
126
|
+
// ours ('other') is a RECYCLED pid: the daemon that wrote it is long dead and an
|
|
127
|
+
// unrelated process inherited the number, so the stale files are cleaned instead
|
|
128
|
+
// of wedging every spawn for the lifetime of that unrelated process.
|
|
129
|
+
function cleanStale(projectRoot, { execFile } = {}) {
|
|
130
|
+
const pidFile = paths.pidFilePath(projectRoot);
|
|
131
|
+
const lockFile = paths.lockPath(projectRoot);
|
|
132
|
+
let livePid = null;
|
|
133
|
+
for (const f of [lockFile, pidFile]) {
|
|
134
|
+
const pid = readPidFile(f);
|
|
135
|
+
if (!pid) continue;
|
|
136
|
+
const id = pidIdentity(pid, { execFile });
|
|
137
|
+
if (id === 'dead') continue; // keep scanning; ultimately clean
|
|
138
|
+
if (id === 'ours' || id === 'unknown') {
|
|
139
|
+
livePid = pid;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
// id === 'other' → recycled pid → fall through to the safeUnlinks.
|
|
143
|
+
}
|
|
144
|
+
if (livePid) return { cleaned: false, livePid };
|
|
145
|
+
|
|
146
|
+
safeUnlink(paths.socketPath(projectRoot));
|
|
147
|
+
safeUnlink(pidFile);
|
|
148
|
+
safeUnlink(paths.handlePath(projectRoot));
|
|
149
|
+
safeUnlink(lockFile);
|
|
150
|
+
return { cleaned: true, livePid: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function startDaemon({
|
|
154
|
+
projectRoot,
|
|
155
|
+
device = null,
|
|
156
|
+
idleMs = DEFAULT_IDLE_MS,
|
|
157
|
+
createCall = defaultCreateCall,
|
|
158
|
+
onUndeliverable = null,
|
|
159
|
+
scheduleTimeout = defaultScheduleTimeout,
|
|
160
|
+
execFile = childProcess.execFileSync,
|
|
161
|
+
} = {}) {
|
|
162
|
+
if (!projectRoot) throw new TypeError('startDaemon requires projectRoot');
|
|
163
|
+
|
|
164
|
+
const dir = paths.sessionDir(projectRoot);
|
|
165
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
166
|
+
|
|
167
|
+
// Reap a crashed daemon's leftovers before we bind.
|
|
168
|
+
cleanStale(projectRoot, { execFile });
|
|
169
|
+
|
|
170
|
+
// Acquire an EXCLUSIVE per-workspace lock BEFORE building the connection. Two
|
|
171
|
+
// `mauto` invocations that both saw isAlive=false would otherwise both spawn a
|
|
172
|
+
// daemon and both call createCall (spawning mobile-mcp + grabbing the device)
|
|
173
|
+
// before the loser hits EADDRINUSE. With the lock the loser fails here and
|
|
174
|
+
// exits before it ever spawns a child. O_EXCL ('wx') is the atomicity.
|
|
175
|
+
const lockFile = paths.lockPath(projectRoot);
|
|
176
|
+
let lockFd;
|
|
177
|
+
try {
|
|
178
|
+
lockFd = fs.openSync(lockFile, 'wx');
|
|
179
|
+
fs.writeSync(lockFd, String(process.pid) + '\n');
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err && err.code === 'EEXIST') {
|
|
182
|
+
const e = new Error('device session daemon already starting for this workspace (lock held)');
|
|
183
|
+
e.code = 'ELOCKED';
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let lockReleased = false;
|
|
190
|
+
function releaseLock() {
|
|
191
|
+
if (lockReleased) return;
|
|
192
|
+
lockReleased = true;
|
|
193
|
+
try {
|
|
194
|
+
if (lockFd !== undefined) fs.closeSync(lockFd);
|
|
195
|
+
} catch (_) {
|
|
196
|
+
/* ignore */
|
|
197
|
+
}
|
|
198
|
+
safeUnlink(lockFile);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Build the single shared connection up front. If it rejects, never leak the
|
|
202
|
+
// lock (and the child it may have started is the connection's own to clean).
|
|
203
|
+
let call;
|
|
204
|
+
let close;
|
|
205
|
+
try {
|
|
206
|
+
({ call, close } = await createCall({ device }));
|
|
207
|
+
} catch (err) {
|
|
208
|
+
releaseLock();
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let idleTimer = null;
|
|
213
|
+
let stopping = false;
|
|
214
|
+
let inFlight = 0; // device calls whose reply has not yet been sent
|
|
215
|
+
const sockets = new Set();
|
|
216
|
+
const undeliverable = []; // replies we could not deliver (peer gone / unflushable)
|
|
217
|
+
let onStop = null; // resolves stop()'s promise
|
|
218
|
+
|
|
219
|
+
const notifyUndeliverable =
|
|
220
|
+
typeof onUndeliverable === 'function'
|
|
221
|
+
? 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
|
+
/* stderr gone */
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function recordUndeliverable(info) {
|
|
233
|
+
undeliverable.push(info);
|
|
234
|
+
notifyUndeliverable(info);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const server = net.createServer();
|
|
238
|
+
|
|
239
|
+
function clearIdle() {
|
|
240
|
+
if (idleTimer) {
|
|
241
|
+
clearTimeout(idleTimer);
|
|
242
|
+
idleTimer = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function armIdle() {
|
|
247
|
+
clearIdle();
|
|
248
|
+
if (stopping) return; // never re-arm once we have begun tearing down
|
|
249
|
+
if (idleMs > 0 && Number.isFinite(idleMs)) {
|
|
250
|
+
idleTimer = setTimeout(() => {
|
|
251
|
+
stop().catch(() => {});
|
|
252
|
+
}, idleMs);
|
|
253
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function stop() {
|
|
258
|
+
if (stopping) return;
|
|
259
|
+
stopping = true;
|
|
260
|
+
clearIdle();
|
|
261
|
+
|
|
262
|
+
// Drain-before-teardown: never destroy a socket or tear down the shared
|
|
263
|
+
// connection while a device call is still in flight. The action already hit
|
|
264
|
+
// the device — its reply must be allowed to go out first.
|
|
265
|
+
while (inFlight > 0) {
|
|
266
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const s of sockets) {
|
|
270
|
+
try {
|
|
271
|
+
s.destroy();
|
|
272
|
+
} catch (_) {
|
|
273
|
+
/* ignore */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
sockets.clear();
|
|
277
|
+
|
|
278
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await close();
|
|
282
|
+
} catch (_) {
|
|
283
|
+
/* best-effort */
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
safeUnlink(paths.socketPath(projectRoot));
|
|
287
|
+
safeUnlink(paths.pidFilePath(projectRoot));
|
|
288
|
+
safeUnlink(paths.handlePath(projectRoot));
|
|
289
|
+
releaseLock();
|
|
290
|
+
|
|
291
|
+
process.removeListener('SIGTERM', onSignal);
|
|
292
|
+
process.removeListener('SIGINT', onSignal);
|
|
293
|
+
|
|
294
|
+
if (onStop) onStop();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function onSignal() {
|
|
298
|
+
stop().catch(() => {});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
server.on('connection', (socket) => {
|
|
302
|
+
sockets.add(socket);
|
|
303
|
+
const parser = new FrameParser();
|
|
304
|
+
|
|
305
|
+
socket.setEncoding('utf8');
|
|
306
|
+
|
|
307
|
+
// Honest reply: by the time this runs the device action has ALREADY
|
|
308
|
+
// happened, so a write that fails must never be silently swallowed.
|
|
309
|
+
// - a non-serializable result is converted to an explicit ok:false frame
|
|
310
|
+
// rather than dropped (a dropped reply hangs the client to a 30s
|
|
311
|
+
// false-failure → retry → double tap / re-granted permission);
|
|
312
|
+
// - socket backpressure (write() === false) is awaited via 'drain' so
|
|
313
|
+
// large payloads (screenshots, element trees) actually reach the client;
|
|
314
|
+
// - a destroyed peer / write error is RECORDED as undeliverable, not
|
|
315
|
+
// swallowed.
|
|
316
|
+
// Returns true when the frame was flushed, false when undeliverable.
|
|
317
|
+
const reply = async (obj) => {
|
|
318
|
+
const id = obj && obj.id != null ? obj.id : null;
|
|
319
|
+
let frame;
|
|
320
|
+
try {
|
|
321
|
+
frame = FrameParser.encode(obj);
|
|
322
|
+
} catch (encErr) {
|
|
323
|
+
// Result could not be serialized — surface it as an explicit failure
|
|
324
|
+
// frame instead of dropping the reply entirely.
|
|
325
|
+
frame = FrameParser.encode({
|
|
326
|
+
id,
|
|
327
|
+
ok: false,
|
|
328
|
+
error: { message: `result not serializable: ${encErr.message || encErr}` },
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (socket.destroyed || !socket.writable) {
|
|
333
|
+
recordUndeliverable({ id, reason: 'socket destroyed before reply' });
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let flushed;
|
|
338
|
+
try {
|
|
339
|
+
flushed = socket.write(frame);
|
|
340
|
+
} catch (writeErr) {
|
|
341
|
+
recordUndeliverable({ id, reason: writeErr.message || String(writeErr) });
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (flushed === false) {
|
|
346
|
+
// Backpressure: wait for the kernel buffer to drain before treating the
|
|
347
|
+
// reply as delivered. Bail honestly if the peer dies first.
|
|
348
|
+
try {
|
|
349
|
+
await new Promise((resolve, reject) => {
|
|
350
|
+
const cleanup = () => {
|
|
351
|
+
socket.removeListener('drain', onDrain);
|
|
352
|
+
socket.removeListener('error', onErr);
|
|
353
|
+
socket.removeListener('close', onClose);
|
|
354
|
+
};
|
|
355
|
+
const onDrain = () => {
|
|
356
|
+
cleanup();
|
|
357
|
+
resolve();
|
|
358
|
+
};
|
|
359
|
+
const onErr = (e) => {
|
|
360
|
+
cleanup();
|
|
361
|
+
reject(e);
|
|
362
|
+
};
|
|
363
|
+
const onClose = () => {
|
|
364
|
+
cleanup();
|
|
365
|
+
reject(new Error('socket closed before drain'));
|
|
366
|
+
};
|
|
367
|
+
socket.once('drain', onDrain);
|
|
368
|
+
socket.once('error', onErr);
|
|
369
|
+
socket.once('close', onClose);
|
|
370
|
+
});
|
|
371
|
+
} catch (drainErr) {
|
|
372
|
+
recordUndeliverable({ id, reason: drainErr.message || String(drainErr) });
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
socket.on('data', async (chunk) => {
|
|
380
|
+
const frames = parser.push(chunk);
|
|
381
|
+
for (const f of frames) {
|
|
382
|
+
// Pause the idle clock while we handle a frame so it can never fire
|
|
383
|
+
// mid-call; it is re-armed only AFTER the reply has gone out.
|
|
384
|
+
clearIdle();
|
|
385
|
+
|
|
386
|
+
if (f.error) {
|
|
387
|
+
await reply({ id: null, ok: false, error: { message: `malformed frame: ${f.error.message}` } });
|
|
388
|
+
armIdle();
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const req = f.value;
|
|
392
|
+
|
|
393
|
+
if (req.type === 'shutdown') {
|
|
394
|
+
await reply({ id: req.id, ok: true, result: { stopping: true } });
|
|
395
|
+
// Reply already flushed (awaited) — now tear down.
|
|
396
|
+
setImmediate(() => stop().catch(() => {}));
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (req.type === 'ping') {
|
|
400
|
+
await reply({ id: req.id, ok: true, result: { pong: true, device, in_flight: inFlight } });
|
|
401
|
+
armIdle();
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (req.type === 'call') {
|
|
405
|
+
// A call that arrives after stop() began must be rejected up front:
|
|
406
|
+
// it never started, so it must not block stop()'s drain (inFlight is
|
|
407
|
+
// only decremented for calls that actually incremented it).
|
|
408
|
+
if (stopping) {
|
|
409
|
+
await reply({
|
|
410
|
+
id: req.id,
|
|
411
|
+
ok: false,
|
|
412
|
+
error: { message: 'session daemon is shutting down', kind: 'device' },
|
|
413
|
+
});
|
|
414
|
+
armIdle();
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
inFlight += 1;
|
|
418
|
+
try {
|
|
419
|
+
// Bound the shared mobile-mcp call. The daemon's timeout (25s) is
|
|
420
|
+
// BELOW the client's (30s) so this error always reaches the client
|
|
421
|
+
// as an honest {kind:'timeout'} reply instead of being dropped by
|
|
422
|
+
// the client's own timeout — a dropped reply reads as a
|
|
423
|
+
// false-failure and prompts a retry → double-execution. The
|
|
424
|
+
// underlying call is NOT cancellable; it keeps running in the
|
|
425
|
+
// background, which is fine — the point is the agent gets a bounded,
|
|
426
|
+
// honest error. Swallow the loser's eventual settlement so it can't
|
|
427
|
+
// surface as an unhandled rejection after the race already settled.
|
|
428
|
+
const callPromise = call(req.tool, req.args || {});
|
|
429
|
+
callPromise.catch(() => {});
|
|
430
|
+
const result = await Promise.race([
|
|
431
|
+
callPromise,
|
|
432
|
+
scheduleTimeout(DAEMON_CALL_TIMEOUT_MS).then(() => {
|
|
433
|
+
const e = new Error(
|
|
434
|
+
`${req.tool}: the mobile-mcp call did not respond within ${DAEMON_CALL_TIMEOUT_MS}ms`
|
|
435
|
+
);
|
|
436
|
+
e.kind = 'timeout';
|
|
437
|
+
e.hint = 'the action may have partially executed on the device — verify state before retrying';
|
|
438
|
+
throw e;
|
|
439
|
+
}),
|
|
440
|
+
]);
|
|
441
|
+
await reply({ id: req.id, ok: true, result });
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (err && err.kind === 'timeout') {
|
|
444
|
+
await reply({
|
|
445
|
+
id: req.id,
|
|
446
|
+
ok: false,
|
|
447
|
+
error: { message: err.message, kind: 'timeout', hint: err.hint },
|
|
448
|
+
});
|
|
449
|
+
} else {
|
|
450
|
+
// Non-timeout failure: no kind → client/deviceFail default to
|
|
451
|
+
// 'device'. Only the timeout path sets kind:'timeout'.
|
|
452
|
+
await reply({ id: req.id, ok: false, error: { message: err.message || String(err) } });
|
|
453
|
+
}
|
|
454
|
+
} finally {
|
|
455
|
+
inFlight -= 1;
|
|
456
|
+
armIdle(); // arm idle only after the reply has completed
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
await reply({ id: req.id, ok: false, error: { message: `unknown request type: ${req.type}` } });
|
|
461
|
+
armIdle();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
socket.on('error', () => {
|
|
466
|
+
sockets.delete(socket);
|
|
467
|
+
});
|
|
468
|
+
socket.on('close', () => {
|
|
469
|
+
sockets.delete(socket);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const socketPath = paths.socketPath(projectRoot);
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
await new Promise((resolve, reject) => {
|
|
477
|
+
server.once('error', reject);
|
|
478
|
+
server.listen(socketPath, () => {
|
|
479
|
+
server.removeListener('error', reject);
|
|
480
|
+
resolve();
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
} catch (err) {
|
|
484
|
+
// listen failed (e.g. EADDRINUSE from a double-spawn that beat us to the
|
|
485
|
+
// bind, or a recycled-pid wedge). The connection was already built — close
|
|
486
|
+
// it so we never leak the mobile-mcp child, and release the lock.
|
|
487
|
+
try {
|
|
488
|
+
await close();
|
|
489
|
+
} catch (_) {
|
|
490
|
+
/* best-effort */
|
|
491
|
+
}
|
|
492
|
+
releaseLock();
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Persist handle + pidfile now that we are actually listening.
|
|
497
|
+
const handle = {
|
|
498
|
+
pid: process.pid,
|
|
499
|
+
device: device || null,
|
|
500
|
+
socket: socketPath,
|
|
501
|
+
started_at: new Date().toISOString(),
|
|
502
|
+
idle_ms: idleMs,
|
|
503
|
+
};
|
|
504
|
+
fs.writeFileSync(paths.handlePath(projectRoot), JSON.stringify(handle, null, 2) + '\n');
|
|
505
|
+
fs.writeFileSync(paths.pidFilePath(projectRoot), String(process.pid) + '\n');
|
|
506
|
+
|
|
507
|
+
process.on('SIGTERM', onSignal);
|
|
508
|
+
process.on('SIGINT', onSignal);
|
|
509
|
+
|
|
510
|
+
armIdle();
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
socketPath,
|
|
514
|
+
device: device || null,
|
|
515
|
+
stop,
|
|
516
|
+
// Replies that could not be delivered (peer gone / unflushable). Surfaced so
|
|
517
|
+
// callers/tests can observe transport failures instead of them being eaten.
|
|
518
|
+
undeliverable,
|
|
519
|
+
// Resolves when the daemon has fully stopped (idle reap / signal / shutdown).
|
|
520
|
+
whenStopped: new Promise((resolve) => {
|
|
521
|
+
onStop = resolve;
|
|
522
|
+
}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
module.exports = { startDaemon, cleanStale, pidAlive, pidIdentity, DAEMON_CALL_TIMEOUT_MS, DEFAULT_IDLE_MS };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure path helpers for the per-workspace device session daemon.
|
|
4
|
+
//
|
|
5
|
+
// Everything lives under <projectRoot>/mobile-automator/.session/:
|
|
6
|
+
// mauto.sock — Unix domain socket the daemon listens on
|
|
7
|
+
// daemon.pid — pidfile written once the daemon is listening
|
|
8
|
+
// session.json — handle describing the live session (device pin, socket, pid)
|
|
9
|
+
//
|
|
10
|
+
// These are intentionally side-effect-free so they can be unit-tested without
|
|
11
|
+
// touching the filesystem.
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const SESSION_DIRNAME = '.session';
|
|
18
|
+
const SOCKET_NAME = 'mauto.sock';
|
|
19
|
+
const PID_NAME = 'daemon.pid';
|
|
20
|
+
const HANDLE_NAME = 'session.json';
|
|
21
|
+
const LOCK_NAME = 'daemon.lock';
|
|
22
|
+
|
|
23
|
+
// Unix domain socket paths have a hard length limit (~104 bytes on macOS,
|
|
24
|
+
// ~108 on Linux). Deep project roots blow past it, so when the in-workspace
|
|
25
|
+
// path would be too long we fall back to a short, deterministic socket path in
|
|
26
|
+
// the OS temp dir keyed by a hash of the project root. pidfile + handle always
|
|
27
|
+
// stay in the workspace (per-workspace ownership), and the handle records the
|
|
28
|
+
// actual socket path it bound.
|
|
29
|
+
const SOCKET_PATH_LIMIT = 100;
|
|
30
|
+
|
|
31
|
+
function sessionDir(projectRoot) {
|
|
32
|
+
return path.join(projectRoot, 'mobile-automator', SESSION_DIRNAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The canonical in-workspace socket path. Used when short enough.
|
|
36
|
+
function workspaceSocketPath(projectRoot) {
|
|
37
|
+
return path.join(sessionDir(projectRoot), SOCKET_NAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The path the daemon binds and the client connects to. Deterministic from the
|
|
41
|
+
// project root so both sides agree without reading the handle.
|
|
42
|
+
function socketPath(projectRoot) {
|
|
43
|
+
const inWorkspace = workspaceSocketPath(projectRoot);
|
|
44
|
+
if (Buffer.byteLength(inWorkspace) <= SOCKET_PATH_LIMIT) return inWorkspace;
|
|
45
|
+
const hash = crypto
|
|
46
|
+
.createHash('sha1')
|
|
47
|
+
.update(path.resolve(projectRoot))
|
|
48
|
+
.digest('hex')
|
|
49
|
+
.slice(0, 16);
|
|
50
|
+
return path.join(os.tmpdir(), `mauto-${hash}.sock`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pidFilePath(projectRoot) {
|
|
54
|
+
return path.join(sessionDir(projectRoot), PID_NAME);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handlePath(projectRoot) {
|
|
58
|
+
return path.join(sessionDir(projectRoot), HANDLE_NAME);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Exclusive workspace lock acquired BEFORE building the mobile-mcp connection so
|
|
62
|
+
// a double-spawn loser fails fast and exits before it ever spawns a child.
|
|
63
|
+
function lockPath(projectRoot) {
|
|
64
|
+
return path.join(sessionDir(projectRoot), LOCK_NAME);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
SESSION_DIRNAME,
|
|
69
|
+
SOCKET_NAME,
|
|
70
|
+
PID_NAME,
|
|
71
|
+
HANDLE_NAME,
|
|
72
|
+
LOCK_NAME,
|
|
73
|
+
SOCKET_PATH_LIMIT,
|
|
74
|
+
sessionDir,
|
|
75
|
+
workspaceSocketPath,
|
|
76
|
+
socketPath,
|
|
77
|
+
pidFilePath,
|
|
78
|
+
handlePath,
|
|
79
|
+
lockPath,
|
|
80
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Newline-delimited JSON framing for the device session daemon protocol.
|
|
4
|
+
//
|
|
5
|
+
// The client sends request frames; the daemon replies with response frames.
|
|
6
|
+
// Each frame is a single JSON object terminated by exactly one '\n'. Requests
|
|
7
|
+
// carry an `id` so a client can correlate responses on a multiplexed socket.
|
|
8
|
+
//
|
|
9
|
+
// Request frames:
|
|
10
|
+
// { id, type: 'call', tool, args } — route a mobile-mcp tool call
|
|
11
|
+
// { id, type: 'shutdown' } — control: ask the daemon to exit
|
|
12
|
+
// { id, type: 'ping' } — liveness probe
|
|
13
|
+
//
|
|
14
|
+
// Response frames:
|
|
15
|
+
// { id, ok: true, result }
|
|
16
|
+
// { id, ok: false, error: { message } }
|
|
17
|
+
//
|
|
18
|
+
// FrameParser owns the framing on both sides: encode() writes a frame, push()
|
|
19
|
+
// buffers partial chunks and yields whole frames. A malformed line is REPORTED
|
|
20
|
+
// (yielded as an error frame), never thrown — a single bad line must not kill
|
|
21
|
+
// the connection. (The encode/decode of a single frame is a JSON.stringify /
|
|
22
|
+
// JSON.parse one-liner with no independent reason to live as separate exports,
|
|
23
|
+
// so they fold into the one module that earns its keep.)
|
|
24
|
+
|
|
25
|
+
const NL = '\n';
|
|
26
|
+
|
|
27
|
+
// Buffers byte chunks and yields one parsed object per complete '\n'-terminated
|
|
28
|
+
// line. push() returns an array of results; each result is either
|
|
29
|
+
// { value } for a parsed object or { error, line } for a malformed line.
|
|
30
|
+
class FrameParser {
|
|
31
|
+
// Serialize a request/response object to one newline-terminated wire frame.
|
|
32
|
+
static encode(obj) {
|
|
33
|
+
return JSON.stringify(obj) + NL;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
this._buf = '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
push(chunk) {
|
|
41
|
+
this._buf += chunk == null ? '' : String(chunk);
|
|
42
|
+
const out = [];
|
|
43
|
+
let idx;
|
|
44
|
+
while ((idx = this._buf.indexOf(NL)) !== -1) {
|
|
45
|
+
const line = this._buf.slice(0, idx);
|
|
46
|
+
this._buf = this._buf.slice(idx + 1);
|
|
47
|
+
if (line.length === 0) continue; // tolerate blank lines
|
|
48
|
+
try {
|
|
49
|
+
out.push({ value: JSON.parse(line) });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
out.push({ error: err, line });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
FrameParser,
|
|
60
|
+
};
|