agent-device 0.4.0 → 0.4.1
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 +2 -0
- package/dist/src/797.js +1 -0
- package/dist/src/bin.js +39 -28
- package/dist/src/daemon.js +17 -17
- package/package.json +2 -2
- package/skills/agent-device/SKILL.md +4 -1
- package/skills/agent-device/references/session-management.md +2 -0
- package/src/__tests__/cli-help.test.ts +102 -0
- package/src/cli.ts +36 -6
- package/src/daemon/__tests__/session-store.test.ts +23 -0
- package/src/daemon/handlers/session.ts +2 -2
- package/src/daemon/handlers/snapshot.ts +210 -185
- package/src/daemon/session-store.ts +16 -4
- package/src/daemon/types.ts +2 -1
- package/src/daemon-client.ts +28 -7
- package/src/daemon.ts +99 -9
- package/src/platforms/ios/runner-client.ts +99 -53
- package/src/utils/__tests__/args.test.ts +47 -1
- package/src/utils/__tests__/daemon-client.test.ts +78 -0
- package/src/utils/__tests__/keyed-lock.test.ts +55 -0
- package/src/utils/__tests__/process-identity.test.ts +33 -0
- package/src/utils/args.ts +31 -0
- package/src/utils/command-schema.ts +51 -13
- package/src/utils/keyed-lock.ts +14 -0
- package/src/utils/process-identity.ts +100 -0
- package/dist/src/274.js +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
|
|
2
2
|
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
|
|
3
|
-
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
|
|
3
|
+
import { runIosRunnerCommand, stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
|
|
4
4
|
import { snapshotAndroid } from '../../platforms/android/index.ts';
|
|
5
5
|
import {
|
|
6
6
|
attachRefs,
|
|
@@ -78,45 +78,47 @@ export async function handleSnapshotCommands(params: {
|
|
|
78
78
|
}
|
|
79
79
|
snapshotScope = resolved;
|
|
80
80
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
truncated: data?.truncated,
|
|
98
|
-
createdAt: Date.now(),
|
|
99
|
-
backend: data?.backend,
|
|
100
|
-
};
|
|
101
|
-
const nextSession: SessionState = session
|
|
102
|
-
? { ...session, snapshot }
|
|
103
|
-
: { name: sessionName, device, createdAt: Date.now(), appBundleId, snapshot, actions: [] };
|
|
104
|
-
recordIfSession(sessionStore, nextSession, req, {
|
|
105
|
-
nodes: nodes.length,
|
|
106
|
-
truncated: data?.truncated ?? false,
|
|
107
|
-
});
|
|
108
|
-
sessionStore.set(sessionName, nextSession);
|
|
109
|
-
return {
|
|
110
|
-
ok: true,
|
|
111
|
-
data: {
|
|
81
|
+
return await withSessionlessRunnerCleanup(session, device, async () => {
|
|
82
|
+
const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
|
|
83
|
+
...contextFromFlags(
|
|
84
|
+
logPath,
|
|
85
|
+
{ ...req.flags, snapshotScope },
|
|
86
|
+
appBundleId,
|
|
87
|
+
session?.trace?.outPath,
|
|
88
|
+
),
|
|
89
|
+
})) as {
|
|
90
|
+
nodes?: RawSnapshotNode[];
|
|
91
|
+
truncated?: boolean;
|
|
92
|
+
backend?: 'ax' | 'xctest' | 'android';
|
|
93
|
+
};
|
|
94
|
+
const rawNodes = data?.nodes ?? [];
|
|
95
|
+
const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
96
|
+
const snapshot: SnapshotState = {
|
|
112
97
|
nodes,
|
|
98
|
+
truncated: data?.truncated,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
backend: data?.backend,
|
|
101
|
+
};
|
|
102
|
+
const nextSession: SessionState = session
|
|
103
|
+
? { ...session, snapshot }
|
|
104
|
+
: { name: sessionName, device, createdAt: Date.now(), appBundleId, snapshot, actions: [] };
|
|
105
|
+
recordIfSession(sessionStore, nextSession, req, {
|
|
106
|
+
nodes: nodes.length,
|
|
113
107
|
truncated: data?.truncated ?? false,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
108
|
+
});
|
|
109
|
+
sessionStore.set(sessionName, nextSession);
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
data: {
|
|
113
|
+
nodes,
|
|
114
|
+
truncated: data?.truncated ?? false,
|
|
115
|
+
appName: nextSession.appBundleId
|
|
116
|
+
? (nextSession.appName ?? nextSession.appBundleId)
|
|
117
|
+
: undefined,
|
|
118
|
+
appBundleId: nextSession.appBundleId,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
if (command === 'wait') {
|
|
@@ -140,125 +142,127 @@ export async function handleSnapshotCommands(params: {
|
|
|
140
142
|
error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' },
|
|
141
143
|
};
|
|
142
144
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const rawNodes = data?.nodes ?? [];
|
|
166
|
-
const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
167
|
-
if (session) {
|
|
168
|
-
session.snapshot = {
|
|
169
|
-
nodes,
|
|
170
|
-
truncated: data?.truncated,
|
|
171
|
-
createdAt: Date.now(),
|
|
172
|
-
backend: data?.backend,
|
|
145
|
+
return await withSessionlessRunnerCleanup(session, device, async () => {
|
|
146
|
+
let text: string;
|
|
147
|
+
let timeoutMs: number | null;
|
|
148
|
+
if (parsed.kind === 'selector') {
|
|
149
|
+
const timeout = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
while (Date.now() - start < timeout) {
|
|
152
|
+
const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
|
|
153
|
+
...contextFromFlags(
|
|
154
|
+
logPath,
|
|
155
|
+
{
|
|
156
|
+
...req.flags,
|
|
157
|
+
snapshotInteractiveOnly: false,
|
|
158
|
+
snapshotCompact: false,
|
|
159
|
+
},
|
|
160
|
+
session?.appBundleId,
|
|
161
|
+
session?.trace?.outPath,
|
|
162
|
+
),
|
|
163
|
+
})) as {
|
|
164
|
+
nodes?: RawSnapshotNode[];
|
|
165
|
+
truncated?: boolean;
|
|
166
|
+
backend?: 'ax' | 'xctest' | 'android';
|
|
173
167
|
};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
168
|
+
const rawNodes = data?.nodes ?? [];
|
|
169
|
+
const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
|
|
170
|
+
if (session) {
|
|
171
|
+
session.snapshot = {
|
|
172
|
+
nodes,
|
|
173
|
+
truncated: data?.truncated,
|
|
174
|
+
createdAt: Date.now(),
|
|
175
|
+
backend: data?.backend,
|
|
176
|
+
};
|
|
177
|
+
sessionStore.set(sessionName, session);
|
|
178
|
+
}
|
|
179
|
+
const match = findSelectorChainMatch(nodes, parsed.selector, { platform: device.platform });
|
|
180
|
+
if (match) {
|
|
181
|
+
recordIfSession(sessionStore, session, req, {
|
|
185
182
|
selector: match.selector.raw,
|
|
186
183
|
waitedMs: Date.now() - start,
|
|
187
|
-
}
|
|
188
|
-
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
data: {
|
|
188
|
+
selector: match.selector.raw,
|
|
189
|
+
waitedMs: Date.now() - start,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
189
194
|
}
|
|
190
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
191
|
-
}
|
|
192
|
-
return {
|
|
193
|
-
ok: false,
|
|
194
|
-
error: {
|
|
195
|
-
code: 'COMMAND_FAILED',
|
|
196
|
-
message: `wait timed out for selector: ${parsed.selectorExpression}`,
|
|
197
|
-
},
|
|
198
|
-
};
|
|
199
|
-
} else if (parsed.kind === 'ref') {
|
|
200
|
-
if (!session?.snapshot) {
|
|
201
|
-
return {
|
|
202
|
-
ok: false,
|
|
203
|
-
error: {
|
|
204
|
-
code: 'INVALID_ARGS',
|
|
205
|
-
message: 'Ref wait requires an existing snapshot in session.',
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
const ref = normalizeRef(parsed.rawRef);
|
|
210
|
-
if (!ref) {
|
|
211
|
-
return {
|
|
212
|
-
ok: false,
|
|
213
|
-
error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` },
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
217
|
-
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
|
|
218
|
-
if (!resolved) {
|
|
219
195
|
return {
|
|
220
196
|
ok: false,
|
|
221
197
|
error: {
|
|
222
198
|
code: 'COMMAND_FAILED',
|
|
223
|
-
message: `
|
|
199
|
+
message: `wait timed out for selector: ${parsed.selectorExpression}`,
|
|
224
200
|
},
|
|
225
201
|
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
202
|
+
} else if (parsed.kind === 'ref') {
|
|
203
|
+
if (!session?.snapshot) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: {
|
|
207
|
+
code: 'INVALID_ARGS',
|
|
208
|
+
message: 'Ref wait requires an existing snapshot in session.',
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const ref = normalizeRef(parsed.rawRef);
|
|
213
|
+
if (!ref) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: { code: 'INVALID_ARGS', message: `Invalid ref: ${parsed.rawRef}` },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const node = findNodeByRef(session.snapshot.nodes, ref);
|
|
220
|
+
const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
|
|
221
|
+
if (!resolved) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
error: {
|
|
225
|
+
code: 'COMMAND_FAILED',
|
|
226
|
+
message: `Ref ${parsed.rawRef} not found or has no label`,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
248
229
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
230
|
+
text = resolved;
|
|
231
|
+
timeoutMs = parsed.timeoutMs;
|
|
232
|
+
} else {
|
|
233
|
+
text = parsed.text;
|
|
234
|
+
timeoutMs = parsed.timeoutMs;
|
|
235
|
+
}
|
|
236
|
+
if (!text) {
|
|
237
|
+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
|
|
238
|
+
}
|
|
239
|
+
const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
240
|
+
const start = Date.now();
|
|
241
|
+
while (Date.now() - start < timeout) {
|
|
242
|
+
if (device.platform === 'ios') {
|
|
243
|
+
const result = (await runIosRunnerCommand(
|
|
244
|
+
device,
|
|
245
|
+
{ command: 'findText', text, appBundleId: session?.appBundleId },
|
|
246
|
+
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
|
|
247
|
+
)) as { found?: boolean };
|
|
248
|
+
if (result?.found) {
|
|
249
|
+
recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
|
|
250
|
+
return { ok: true, data: { text, waitedMs: Date.now() - start } };
|
|
251
|
+
}
|
|
252
|
+
} else if (device.platform === 'android') {
|
|
253
|
+
const androidResult = await snapshotAndroid(device, { scope: text });
|
|
254
|
+
if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
|
|
255
|
+
recordIfSession(sessionStore, session, req, { text, waitedMs: Date.now() - start });
|
|
256
|
+
return { ok: true, data: { text, waitedMs: Date.now() - start } };
|
|
257
|
+
}
|
|
254
258
|
}
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
255
260
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
};
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` },
|
|
264
|
+
};
|
|
265
|
+
});
|
|
262
266
|
}
|
|
263
267
|
|
|
264
268
|
if (command === 'alert') {
|
|
@@ -273,37 +277,39 @@ export async function handleSnapshotCommands(params: {
|
|
|
273
277
|
},
|
|
274
278
|
};
|
|
275
279
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
280
|
+
return await withSessionlessRunnerCleanup(session, device, async () => {
|
|
281
|
+
if (action === 'wait') {
|
|
282
|
+
const timeout = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS;
|
|
283
|
+
const start = Date.now();
|
|
284
|
+
while (Date.now() - start < timeout) {
|
|
285
|
+
try {
|
|
286
|
+
const data = await runIosRunnerCommand(
|
|
287
|
+
device,
|
|
288
|
+
{ command: 'alert', action: 'get', appBundleId: session?.appBundleId },
|
|
289
|
+
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
|
|
290
|
+
);
|
|
291
|
+
recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
|
|
292
|
+
return { ok: true, data };
|
|
293
|
+
} catch {
|
|
294
|
+
// keep waiting
|
|
295
|
+
}
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
290
297
|
}
|
|
291
|
-
|
|
298
|
+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
|
|
292
299
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return { ok: true, data };
|
|
300
|
+
const data = await runIosRunnerCommand(
|
|
301
|
+
device,
|
|
302
|
+
{
|
|
303
|
+
command: 'alert',
|
|
304
|
+
action:
|
|
305
|
+
action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
|
|
306
|
+
appBundleId: session?.appBundleId,
|
|
307
|
+
},
|
|
308
|
+
{ verbose: req.flags?.verbose, logPath, traceLogPath: session?.trace?.outPath },
|
|
309
|
+
);
|
|
310
|
+
recordIfSession(sessionStore, session, req, data as Record<string, unknown>);
|
|
311
|
+
return { ok: true, data };
|
|
312
|
+
});
|
|
307
313
|
}
|
|
308
314
|
|
|
309
315
|
if (command === 'settings') {
|
|
@@ -328,18 +334,20 @@ export async function handleSnapshotCommands(params: {
|
|
|
328
334
|
},
|
|
329
335
|
};
|
|
330
336
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
337
|
+
return await withSessionlessRunnerCleanup(session, device, async () => {
|
|
338
|
+
const appBundleId = session?.appBundleId;
|
|
339
|
+
const data = await dispatchCommand(
|
|
340
|
+
device,
|
|
341
|
+
'settings',
|
|
342
|
+
[setting, state, appBundleId ?? ''],
|
|
343
|
+
req.flags?.out,
|
|
344
|
+
{
|
|
345
|
+
...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath),
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
recordIfSession(sessionStore, session, req, data ?? { setting, state });
|
|
349
|
+
return { ok: true, data: data ?? { setting, state } };
|
|
350
|
+
});
|
|
343
351
|
}
|
|
344
352
|
|
|
345
353
|
return null;
|
|
@@ -398,6 +406,23 @@ async function resolveSessionDevice(
|
|
|
398
406
|
return { session, device };
|
|
399
407
|
}
|
|
400
408
|
|
|
409
|
+
async function withSessionlessRunnerCleanup<T>(
|
|
410
|
+
session: SessionState | undefined,
|
|
411
|
+
device: SessionState['device'],
|
|
412
|
+
task: () => Promise<T>,
|
|
413
|
+
): Promise<T> {
|
|
414
|
+
const shouldCleanupSessionlessIosRunner = !session && device.platform === 'ios';
|
|
415
|
+
try {
|
|
416
|
+
return await task();
|
|
417
|
+
} finally {
|
|
418
|
+
// Sessionless iOS commands intentionally stop the runner to avoid leaked xcodebuild processes.
|
|
419
|
+
// For multi-command flows, keep an active session via `open` so the runner can be reused.
|
|
420
|
+
if (shouldCleanupSessionlessIosRunner) {
|
|
421
|
+
await stopIosRunnerSession(device.id);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
401
426
|
function recordIfSession(
|
|
402
427
|
sessionStore: SessionStore,
|
|
403
428
|
session: SessionState | undefined,
|
|
@@ -49,6 +49,9 @@ export class SessionStore {
|
|
|
49
49
|
if (entry.flags?.noRecord) return;
|
|
50
50
|
if (entry.flags?.saveScript) {
|
|
51
51
|
session.recordSession = true;
|
|
52
|
+
if (typeof entry.flags.saveScript === 'string') {
|
|
53
|
+
session.saveScriptPath = SessionStore.expandHome(entry.flags.saveScript);
|
|
54
|
+
}
|
|
52
55
|
}
|
|
53
56
|
session.actions.push({
|
|
54
57
|
ts: Date.now(),
|
|
@@ -62,10 +65,9 @@ export class SessionStore {
|
|
|
62
65
|
writeSessionLog(session: SessionState): void {
|
|
63
66
|
try {
|
|
64
67
|
if (!session.recordSession) return;
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
|
|
68
|
+
const scriptPath = this.resolveScriptPath(session);
|
|
69
|
+
const scriptDir = path.dirname(scriptPath);
|
|
70
|
+
if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true });
|
|
69
71
|
const script = formatScript(session, this.buildOptimizedActions(session));
|
|
70
72
|
fs.writeFileSync(scriptPath, script);
|
|
71
73
|
} catch {
|
|
@@ -86,6 +88,16 @@ export class SessionStore {
|
|
|
86
88
|
return path.resolve(filePath);
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
private resolveScriptPath(session: SessionState): string {
|
|
92
|
+
if (session.saveScriptPath) {
|
|
93
|
+
return SessionStore.expandHome(session.saveScriptPath);
|
|
94
|
+
}
|
|
95
|
+
if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
|
|
96
|
+
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
97
|
+
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
|
|
98
|
+
return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
private buildOptimizedActions(session: SessionState): SessionAction[] {
|
|
90
102
|
const optimized: SessionAction[] = [];
|
|
91
103
|
for (const action of session.actions) {
|
package/src/daemon/types.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type SessionState = {
|
|
|
27
27
|
startedAt: number;
|
|
28
28
|
};
|
|
29
29
|
recordSession?: boolean;
|
|
30
|
+
saveScriptPath?: string;
|
|
30
31
|
actions: SessionAction[];
|
|
31
32
|
recording?: {
|
|
32
33
|
platform: 'ios' | 'android';
|
|
@@ -48,7 +49,7 @@ export type SessionAction = {
|
|
|
48
49
|
snapshotScope?: string;
|
|
49
50
|
snapshotRaw?: boolean;
|
|
50
51
|
snapshotBackend?: 'ax' | 'xctest';
|
|
51
|
-
saveScript?: boolean;
|
|
52
|
+
saveScript?: boolean | string;
|
|
52
53
|
noRecord?: boolean;
|
|
53
54
|
};
|
|
54
55
|
result?: Record<string, unknown>;
|
package/src/daemon-client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { AppError } from './utils/errors.ts';
|
|
|
6
6
|
import type { CommandFlags } from './core/dispatch.ts';
|
|
7
7
|
import { runCmdDetached } from './utils/exec.ts';
|
|
8
8
|
import { findProjectRoot, readVersion } from './utils/version.ts';
|
|
9
|
+
import { stopProcessForTakeover } from './utils/process-identity.ts';
|
|
9
10
|
|
|
10
11
|
export type DaemonRequest = {
|
|
11
12
|
token: string;
|
|
@@ -19,12 +20,20 @@ export type DaemonResponse =
|
|
|
19
20
|
| { ok: true; data?: Record<string, unknown> }
|
|
20
21
|
| { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
|
|
21
22
|
|
|
22
|
-
type DaemonInfo = {
|
|
23
|
+
type DaemonInfo = {
|
|
24
|
+
port: number;
|
|
25
|
+
token: string;
|
|
26
|
+
pid: number;
|
|
27
|
+
version?: string;
|
|
28
|
+
processStartTime?: string;
|
|
29
|
+
};
|
|
23
30
|
|
|
24
31
|
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
25
32
|
const infoPath = path.join(baseDir, 'daemon.json');
|
|
26
|
-
const REQUEST_TIMEOUT_MS =
|
|
33
|
+
const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
|
|
27
34
|
const DAEMON_STARTUP_TIMEOUT_MS = 5000;
|
|
35
|
+
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
|
|
36
|
+
const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
|
|
28
37
|
|
|
29
38
|
export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
|
|
30
39
|
const info = await ensureDaemon();
|
|
@@ -38,6 +47,7 @@ async function ensureDaemon(): Promise<DaemonInfo> {
|
|
|
38
47
|
const existingReachable = existing ? await canConnect(existing) : false;
|
|
39
48
|
if (existing && existing.version === localVersion && existingReachable) return existing;
|
|
40
49
|
if (existing && (existing.version !== localVersion || !existingReachable)) {
|
|
50
|
+
await stopDaemonProcessForTakeover(existing);
|
|
41
51
|
removeDaemonInfo();
|
|
42
52
|
}
|
|
43
53
|
|
|
@@ -56,12 +66,23 @@ async function ensureDaemon(): Promise<DaemonInfo> {
|
|
|
56
66
|
});
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
|
|
70
|
+
await stopProcessForTakeover(info.pid, {
|
|
71
|
+
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
|
|
72
|
+
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
|
|
73
|
+
expectedStartTime: info.processStartTime,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
function readDaemonInfo(): DaemonInfo | null {
|
|
60
78
|
if (!fs.existsSync(infoPath)) return null;
|
|
61
79
|
try {
|
|
62
80
|
const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
|
|
63
81
|
if (!data.port || !data.token) return null;
|
|
64
|
-
return
|
|
82
|
+
return {
|
|
83
|
+
...data,
|
|
84
|
+
pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
|
|
85
|
+
};
|
|
65
86
|
} catch {
|
|
66
87
|
return null;
|
|
67
88
|
}
|
|
@@ -142,10 +163,10 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
|
|
|
142
163
|
});
|
|
143
164
|
}
|
|
144
165
|
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
if (!raw) return
|
|
166
|
+
export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
|
|
167
|
+
// iOS physical-device runner startup/build can exceed 60s, so use a safer default for daemon RPCs.
|
|
168
|
+
if (!raw) return 180000;
|
|
148
169
|
const parsed = Number(raw);
|
|
149
|
-
if (!Number.isFinite(parsed)) return
|
|
170
|
+
if (!Number.isFinite(parsed)) return 180000;
|
|
150
171
|
return Math.max(1000, Math.floor(parsed));
|
|
151
172
|
}
|