mobile-debug-mcp 0.32.4 → 0.33.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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/AGENTS.md +21 -10
- package/README.md +62 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +5 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +28 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +54 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +273 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +115 -0
- package/dist/observe/readiness.js +81 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/journey-schema.js +3 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/tool-definitions.js +46 -0
- package/dist/server/tool-handlers.js +99 -17
- package/dist/server-core.js +1 -1
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/operation-budget.js +24 -2
- package/dist/utils/resolve-device.js +7 -0
- package/docs/CHANGELOG.md +11 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +3 -0
- package/docs/tools/evidence.md +79 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +63 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +42 -14
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +255 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +89 -0
- package/src/observe/readiness.ts +78 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/journey-schema.ts +3 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/tool-definitions.ts +44 -0
- package/src/server/tool-handlers.ts +93 -17
- package/src/server-core.ts +1 -1
- package/src/types.ts +30 -1
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/operation-budget.ts +22 -2
- package/src/utils/resolve-device.ts +8 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -0
- package/test/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +39 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/server/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
package/dist/observe/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { omitEvidence } from './evidence.js';
|
|
2
|
+
import { withResolvedDevice } from '../utils/resolve-device.js';
|
|
1
3
|
import { measure } from '../utils/timing.js';
|
|
4
|
+
import { ReadinessTracker } from './readiness.js';
|
|
2
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
3
6
|
import { AndroidObserve } from './android.js';
|
|
4
7
|
import { iOSObserve } from './ios.js';
|
|
@@ -136,16 +139,22 @@ export class ToolsObserve {
|
|
|
136
139
|
}
|
|
137
140
|
static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }) {
|
|
138
141
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
|
|
142
|
+
const acquisitionStarted = Date.now();
|
|
139
143
|
const tree = await observe.getUITree(resolved.id);
|
|
144
|
+
tree.readiness = new ReadinessTracker(resolved.platform, resolved.id).observe(tree, acquisitionStarted);
|
|
140
145
|
if (responseMode !== 'compact')
|
|
141
146
|
return tree;
|
|
142
147
|
const revision = tree?.snapshot_revision ?? null;
|
|
148
|
+
const session = { snapshot_session_id: tree?.snapshot_session_id, readiness: tree.readiness };
|
|
149
|
+
if (tree?.error)
|
|
150
|
+
return { ...session, delta_available: false, snapshot_revision: revision, error: tree.error };
|
|
143
151
|
const baseRevision = sinceSnapshotRevision;
|
|
144
152
|
if (!Number.isInteger(baseRevision) || revision === null) {
|
|
145
|
-
return { delta_available: false, snapshot_revision: revision };
|
|
153
|
+
return { ...session, delta_available: false, snapshot_revision: revision };
|
|
146
154
|
}
|
|
147
155
|
const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision, revision);
|
|
148
|
-
return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
156
|
+
return delta ? { ...session, delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
157
|
+
...session,
|
|
149
158
|
delta_available: false,
|
|
150
159
|
snapshot_revision: revision,
|
|
151
160
|
reason: 'BASE_REVISION_UNAVAILABLE'
|
|
@@ -192,14 +201,23 @@ export class ToolsObserve {
|
|
|
192
201
|
return await observe.startLogStream(packageName, resolved.id, sid);
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
|
-
static async readLogStreamHandler({ platform, sessionId, limit, since }) {
|
|
204
|
+
static async readLogStreamHandler({ platform, sessionId, limit, since, deviceId }) {
|
|
196
205
|
const sid = sessionId || 'default';
|
|
197
|
-
const
|
|
206
|
+
const android = new AndroidObserve();
|
|
207
|
+
const ios = new iOSObserve();
|
|
208
|
+
const observe = platform === 'android' ? android : platform === 'ios' ? ios :
|
|
209
|
+
ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android;
|
|
210
|
+
const streamDevice = observe.getLogStreamDevice(sid);
|
|
211
|
+
if (deviceId && streamDevice && streamDevice.id !== deviceId)
|
|
212
|
+
throw new Error('LOG_STREAM_DEVICE_MISMATCH');
|
|
198
213
|
return await observe.readLogStream(sid, limit ?? 100, since);
|
|
199
214
|
}
|
|
200
215
|
static async stopLogStreamHandler({ platform, sessionId }) {
|
|
201
216
|
const sid = sessionId || 'default';
|
|
202
|
-
const
|
|
217
|
+
const android = new AndroidObserve();
|
|
218
|
+
const ios = new iOSObserve();
|
|
219
|
+
const observe = platform === 'android' ? android : platform === 'ios' ? ios :
|
|
220
|
+
ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android;
|
|
203
221
|
return await observe.stopLogStream(sid);
|
|
204
222
|
}
|
|
205
223
|
static async captureScreenshotHandler({ platform, deviceId }) {
|
|
@@ -217,6 +235,15 @@ export class ToolsObserve {
|
|
|
217
235
|
return await observe.getScreenFingerprint(resolved.id);
|
|
218
236
|
}
|
|
219
237
|
static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
238
|
+
const { resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
|
|
239
|
+
return withResolvedDevice(resolved, () => ToolsObserve.collectDebugSnapshot({ reason, includeLogs, includeScreenshot, logLines,
|
|
240
|
+
platform: resolved.platform, appId, deviceId: resolved.id, sessionId }));
|
|
241
|
+
}
|
|
242
|
+
static async collectDebugSnapshot({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
243
|
+
if (!includeScreenshot)
|
|
244
|
+
omitEvidence('screenshot');
|
|
245
|
+
if (!includeLogs)
|
|
246
|
+
omitEvidence('logs');
|
|
220
247
|
const timestamp = Date.now();
|
|
221
248
|
const raw = {
|
|
222
249
|
timestamp,
|
|
@@ -236,7 +263,7 @@ export class ToolsObserve {
|
|
|
236
263
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
237
264
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
238
265
|
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
|
239
|
-
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
266
|
+
readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, deviceId, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
|
|
240
267
|
};
|
|
241
268
|
const results = await Promise.allSettled(Object.values(tasks));
|
|
242
269
|
const keys = Object.keys(tasks);
|
package/dist/observe/ios.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { captureEvidence } from './evidence.js';
|
|
1
3
|
import { spawn } from "child_process";
|
|
2
4
|
import { remainingBudget } from '../utils/operation-budget.js';
|
|
3
5
|
import { execCmd } from '../utils/exec.js';
|
|
@@ -256,17 +258,21 @@ export function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
|
|
|
256
258
|
return currentIndex;
|
|
257
259
|
}
|
|
258
260
|
const iosActiveLogStreams = new Map();
|
|
259
|
-
export function _setIOSActiveLogStream(sessionId, file) {
|
|
260
|
-
iosActiveLogStreams.set(sessionId, { proc: {}, file });
|
|
261
|
+
export function _setIOSActiveLogStream(sessionId, file, device) {
|
|
262
|
+
iosActiveLogStreams.set(sessionId, { proc: {}, file, device });
|
|
261
263
|
}
|
|
262
264
|
export function _clearIOSActiveLogStream(sessionId) {
|
|
263
265
|
iosActiveLogStreams.delete(sessionId);
|
|
264
266
|
}
|
|
265
267
|
export class iOSObserve {
|
|
268
|
+
getLogStreamDevice(sessionId = 'default') { return iosActiveLogStreams.get(sessionId)?.device ?? null; }
|
|
266
269
|
async getDeviceMetadata(deviceId = "booted") {
|
|
267
270
|
return getIOSDeviceMetadata(deviceId);
|
|
268
271
|
}
|
|
269
272
|
async getLogs(filters = {}) {
|
|
273
|
+
return captureEvidence('logs', { platform: 'ios', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
|
|
274
|
+
}
|
|
275
|
+
async collectGetLogs(filters = {}) {
|
|
270
276
|
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
|
|
271
277
|
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
|
|
272
278
|
// Default to last N seconds if no since_seconds provided; limit lines handled after parsing
|
|
@@ -418,8 +424,11 @@ export class iOSObserve {
|
|
|
418
424
|
}
|
|
419
425
|
}
|
|
420
426
|
async captureScreenshot(deviceId = "booted") {
|
|
427
|
+
return captureEvidence('screenshot', { platform: 'ios', id: deviceId ?? '' }, () => this.collectCaptureScreenshot(deviceId));
|
|
428
|
+
}
|
|
429
|
+
async collectCaptureScreenshot(deviceId = "booted") {
|
|
421
430
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
422
|
-
const tmpFile = `/tmp/mcp-ios-screenshot-${
|
|
431
|
+
const tmpFile = `/tmp/mcp-ios-screenshot-${randomUUID()}.png`;
|
|
423
432
|
try {
|
|
424
433
|
await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
|
|
425
434
|
const buffer = await fs.readFile(tmpFile);
|
|
@@ -485,6 +494,9 @@ export class iOSObserve {
|
|
|
485
494
|
}
|
|
486
495
|
}
|
|
487
496
|
async getUITree(deviceId = "booted") {
|
|
497
|
+
return captureEvidence('ui_tree', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
|
|
498
|
+
}
|
|
499
|
+
async collectGetUITree(deviceId = "booted") {
|
|
488
500
|
const measurement = createObservationMeasurement('ios');
|
|
489
501
|
let device;
|
|
490
502
|
try {
|
|
@@ -608,11 +620,14 @@ export class iOSObserve {
|
|
|
608
620
|
}
|
|
609
621
|
}
|
|
610
622
|
async getScreenFingerprint(deviceId = 'booted') {
|
|
623
|
+
return captureEvidence('fingerprint', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
|
|
624
|
+
}
|
|
625
|
+
async collectGetScreenFingerprint(deviceId = 'booted') {
|
|
611
626
|
try {
|
|
612
627
|
const tree = await this.getUITree(deviceId);
|
|
613
628
|
if (!tree || tree.error)
|
|
614
629
|
return { fingerprint: null, error: tree && tree.error };
|
|
615
|
-
return computeScreenFingerprint(tree, null, 'ios', 50);
|
|
630
|
+
return { ...computeScreenFingerprint(tree, null, 'ios', 50), captured_at_ms: tree.captured_at_ms };
|
|
616
631
|
}
|
|
617
632
|
catch (e) {
|
|
618
633
|
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -652,9 +667,10 @@ export class iOSObserve {
|
|
|
652
667
|
});
|
|
653
668
|
proc.on('close', () => {
|
|
654
669
|
stream.end();
|
|
655
|
-
iosActiveLogStreams.
|
|
670
|
+
if (iosActiveLogStreams.get(sessionId)?.proc === proc)
|
|
671
|
+
iosActiveLogStreams.delete(sessionId);
|
|
656
672
|
});
|
|
657
|
-
iosActiveLogStreams.set(sessionId, { proc, file });
|
|
673
|
+
iosActiveLogStreams.set(sessionId, { proc, file, device: { platform: 'ios', id: deviceId ?? '' } });
|
|
658
674
|
return { success: true, stream_started: true };
|
|
659
675
|
}
|
|
660
676
|
catch {
|
|
@@ -673,6 +689,9 @@ export class iOSObserve {
|
|
|
673
689
|
return { success: true };
|
|
674
690
|
}
|
|
675
691
|
async readLogStream(sessionId = 'default', limit = 100, since) {
|
|
692
|
+
return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
|
|
693
|
+
}
|
|
694
|
+
async collectReadLogStream(sessionId = 'default', limit = 100, since) {
|
|
676
695
|
const entry = iosActiveLogStreams.get(sessionId);
|
|
677
696
|
if (!entry)
|
|
678
697
|
return { entries: [] };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js';
|
|
2
|
+
import { remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
3
|
+
import { ToolsObserve } from './index.js';
|
|
4
|
+
import { isDeviceUnavailable, ReadinessTracker, withReadinessTracker } from './readiness.js';
|
|
5
|
+
function budgetExpired(error) {
|
|
6
|
+
if (/ACTION_TIMEOUT/.test(String(error)))
|
|
7
|
+
return true;
|
|
8
|
+
try {
|
|
9
|
+
remainingBudget();
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function resolve(target) {
|
|
17
|
+
if (target.platform)
|
|
18
|
+
return resolveTargetDevice({ ...target, platform: target.platform });
|
|
19
|
+
try {
|
|
20
|
+
return await resolveTargetDevice({ ...target, platform: 'android' });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return resolveTargetDevice({ ...target, platform: 'ios' });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function readinessDelay(ms) {
|
|
27
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))));
|
|
28
|
+
remainingBudget();
|
|
29
|
+
}
|
|
30
|
+
export async function getAppReadiness(args, wait = false) {
|
|
31
|
+
const started = performance.now();
|
|
32
|
+
let device = null;
|
|
33
|
+
let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '');
|
|
34
|
+
let failureCode;
|
|
35
|
+
await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
|
|
36
|
+
try {
|
|
37
|
+
device = await resolve(args);
|
|
38
|
+
remainingBudget();
|
|
39
|
+
tracker = new ReadinessTracker(device.platform, device.id, args.stabilityMs ?? 500);
|
|
40
|
+
await withResolvedDevice(device, async () => {
|
|
41
|
+
let count = 0;
|
|
42
|
+
while (wait || count < 2) {
|
|
43
|
+
remainingBudget();
|
|
44
|
+
const captureStarted = Date.now();
|
|
45
|
+
try {
|
|
46
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform: device.platform, deviceId: device.id });
|
|
47
|
+
remainingBudget();
|
|
48
|
+
tracker.observe(tree, captureStarted);
|
|
49
|
+
if (isDeviceUnavailable(tree?.error)) {
|
|
50
|
+
failureCode = 'DEVICE_UNAVAILABLE';
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (/ACTION_TIMEOUT/.test(String(error)))
|
|
56
|
+
throw error;
|
|
57
|
+
tracker.unavailable();
|
|
58
|
+
if (isDeviceUnavailable(error)) {
|
|
59
|
+
failureCode = 'DEVICE_UNAVAILABLE';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
count++;
|
|
64
|
+
if (tracker.summary.state === 'settled' || (!wait && tracker.summary.state === 'loading'))
|
|
65
|
+
return;
|
|
66
|
+
if (!wait && count === 2)
|
|
67
|
+
return;
|
|
68
|
+
await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const expired = budgetExpired(error);
|
|
74
|
+
if (expired)
|
|
75
|
+
tracker.deadline();
|
|
76
|
+
else
|
|
77
|
+
tracker.unavailable();
|
|
78
|
+
failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE';
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const state = tracker.summary.state;
|
|
82
|
+
const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown');
|
|
83
|
+
return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
|
|
84
|
+
...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {}) };
|
|
85
|
+
}
|
|
86
|
+
export async function withReadyWait(target, timeoutMs, operation, failure) {
|
|
87
|
+
const started = performance.now();
|
|
88
|
+
let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '');
|
|
89
|
+
let resolved = false;
|
|
90
|
+
return runWithBudget(timeoutMs, async () => {
|
|
91
|
+
try {
|
|
92
|
+
const device = await resolve(target);
|
|
93
|
+
resolved = true;
|
|
94
|
+
remainingBudget();
|
|
95
|
+
tracker = new ReadinessTracker(device.platform, device.id);
|
|
96
|
+
const result = await withResolvedDevice(device, () => withReadinessTracker(tracker, () => operation(device)));
|
|
97
|
+
try {
|
|
98
|
+
remainingBudget();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false));
|
|
102
|
+
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() };
|
|
103
|
+
}
|
|
104
|
+
return { ...result, readiness: tracker.summary };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const expired = budgetExpired(error);
|
|
108
|
+
if (expired)
|
|
109
|
+
tracker.deadline();
|
|
110
|
+
else
|
|
111
|
+
tracker.unavailable();
|
|
112
|
+
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary };
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
3
|
+
import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js';
|
|
4
|
+
const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED', 'DEBUGGER_SIGNAL_UNSUPPORTED'];
|
|
5
|
+
const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false };
|
|
6
|
+
export function isDeviceUnavailable(error) {
|
|
7
|
+
return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error));
|
|
8
|
+
}
|
|
9
|
+
export class ReadinessTracker {
|
|
10
|
+
platform;
|
|
11
|
+
deviceId;
|
|
12
|
+
stabilityMs;
|
|
13
|
+
signature = null;
|
|
14
|
+
stableSince = 0;
|
|
15
|
+
summary = {
|
|
16
|
+
state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
|
|
17
|
+
sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
18
|
+
evidence: [], limitations: [...baselineLimitations, 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities }
|
|
19
|
+
};
|
|
20
|
+
constructor(platform, deviceId, stabilityMs = 500) {
|
|
21
|
+
this.platform = platform;
|
|
22
|
+
this.deviceId = deviceId;
|
|
23
|
+
this.stabilityMs = stabilityMs;
|
|
24
|
+
}
|
|
25
|
+
unavailable(code = 'OBSERVATION_FAILED') {
|
|
26
|
+
this.signature = null;
|
|
27
|
+
this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
28
|
+
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, code],
|
|
29
|
+
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) };
|
|
30
|
+
return this.summary;
|
|
31
|
+
}
|
|
32
|
+
deadline() {
|
|
33
|
+
this.summary = { ...this.summary, limitations: [...new Set([...this.summary.limitations, 'DEADLINE_EXCEEDED'])] };
|
|
34
|
+
return this.summary;
|
|
35
|
+
}
|
|
36
|
+
observe(tree, acquisitionStartedAtMs, now = performance.now()) {
|
|
37
|
+
if (tree?.error)
|
|
38
|
+
return this.unavailable('OBSERVATION_FAILED');
|
|
39
|
+
if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
|
|
40
|
+
tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
|
|
41
|
+
!Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms < acquisitionStartedAtMs || tree.captured_at_ms <= 0 ||
|
|
42
|
+
!Array.isArray(tree.elements) || !tree.elements.length || tree.elements.some((element) => !element || typeof element !== 'object') ||
|
|
43
|
+
!Number.isFinite(tree.resolution?.width) || !Number.isFinite(tree.resolution?.height) || tree.resolution.width <= 0 || tree.resolution.height <= 0)
|
|
44
|
+
return this.unavailable('OBSERVATION_INVALID');
|
|
45
|
+
const signature = computeSnapshotSignature(tree);
|
|
46
|
+
if (!signature)
|
|
47
|
+
return this.unavailable('OBSERVATION_INVALID');
|
|
48
|
+
const nativeLoading = tree.elements.some((element) => element.visible === true && ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator'].includes(element.type));
|
|
49
|
+
const textLoading = tree.elements.some((element) => element.visible === true && [element.text, element.contentDescription].some(label => typeof label === 'string' && /^(loading|please\s+wait)[.\s…]*$/i.test(label.trim())));
|
|
50
|
+
const previous = this.signature;
|
|
51
|
+
const unchanged = previous === signature;
|
|
52
|
+
if (!unchanged || nativeLoading || textLoading)
|
|
53
|
+
this.stableSince = now;
|
|
54
|
+
this.signature = signature;
|
|
55
|
+
const stableFor = unchanged && !nativeLoading && !textLoading ? Math.max(0, now - this.stableSince) : 0;
|
|
56
|
+
const state = nativeLoading || textLoading ? 'loading' : !previous ? 'unknown' : !unchanged ? 'transitioning' : stableFor >= this.stabilityMs ? 'settled' : 'unknown';
|
|
57
|
+
const code = nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES';
|
|
58
|
+
this.summary = {
|
|
59
|
+
state, scope: 'device_ui', basis: textLoading && !nativeLoading ? 'heuristic' : state === 'unknown' ? 'insufficient' : 'observed',
|
|
60
|
+
captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
|
|
61
|
+
sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
|
|
62
|
+
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
|
|
63
|
+
limitations: [...baselineLimitations, ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities }
|
|
64
|
+
};
|
|
65
|
+
return this.summary;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const waits = new AsyncLocalStorage();
|
|
69
|
+
export function currentReadinessWait() { return waits.getStore(); }
|
|
70
|
+
export function withReadinessTracker(tracker, operation) { return waits.run(tracker, operation); }
|
|
71
|
+
export function observeReadinessWait(tree, startedAtMs) {
|
|
72
|
+
const tracker = waits.getStore();
|
|
73
|
+
if (!tracker)
|
|
74
|
+
return;
|
|
75
|
+
remainingBudget();
|
|
76
|
+
const summary = tracker.observe(tree, startedAtMs);
|
|
77
|
+
if (isDeviceUnavailable(tree?.error))
|
|
78
|
+
throw new Error('READINESS_DEVICE_UNAVAILABLE');
|
|
79
|
+
if (summary.limitations.some(code => ['OBSERVATION_INVALID', 'OBSERVATION_FAILED'].includes(code)))
|
|
80
|
+
throw new Error('READINESS_SAMPLE_INVALID');
|
|
81
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execCmd } from '../utils/exec.js';
|
|
8
|
+
import { getAdbCmd } from '../utils/android/utils.js';
|
|
9
|
+
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
10
|
+
const recordings = new Map();
|
|
11
|
+
const activeDevices = new Map();
|
|
12
|
+
const adb = (deviceId, args, timeout = 15000) => execCmd(getAdbCmd(), ['-s', deviceId, ...args], { timeout });
|
|
13
|
+
const tool = (name, args, timeout = 30000) => execCmd(name, args, { timeout });
|
|
14
|
+
const swiftScript = fileURLToPath(new URL('../../scripts/recording-frames.swift', import.meta.url));
|
|
15
|
+
async function swiftDecode(video, directory, startMs, endMs) {
|
|
16
|
+
const result = await tool('xcrun', ['swift', swiftScript, video, directory, String(startMs), String(endMs)], 120000);
|
|
17
|
+
if (result.exitCode !== 0)
|
|
18
|
+
throw new Error(`Swift frame decoder failed: ${result.stderr}`);
|
|
19
|
+
return JSON.parse(result.stdout);
|
|
20
|
+
}
|
|
21
|
+
function positiveInteger(value, name, max) {
|
|
22
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max)
|
|
23
|
+
throw new Error(`${name} must be an integer from 1 to ${max}`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
async function probe(video) {
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await tool('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,nb_frames:format=duration', '-of', 'json', video]);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (process.platform !== 'darwin')
|
|
33
|
+
throw error;
|
|
34
|
+
const metadata = await swiftDecode(video, path.join(path.dirname(video), 'metadata'), 1, 0);
|
|
35
|
+
const { frames: _frames, ...rest } = metadata;
|
|
36
|
+
return rest;
|
|
37
|
+
}
|
|
38
|
+
if (result.exitCode !== 0)
|
|
39
|
+
throw new Error(`ffprobe failed: ${result.stderr || 'ffprobe is required for recording metadata and frame extraction'}`);
|
|
40
|
+
const data = JSON.parse(result.stdout);
|
|
41
|
+
const stream = data.streams?.[0];
|
|
42
|
+
if (!stream)
|
|
43
|
+
throw new Error('Recording contains no video stream');
|
|
44
|
+
const frames = await frameTimes(video);
|
|
45
|
+
return { durationMs: Math.round(Number(data.format.duration) * 1000), resolution: { width: stream.width, height: stream.height }, frameCount: frames.length, frameTimestampsMs: frames };
|
|
46
|
+
}
|
|
47
|
+
async function frameTimes(video) {
|
|
48
|
+
const result = await tool('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'frame=best_effort_timestamp_time', '-of', 'json', video], 60000);
|
|
49
|
+
if (result.exitCode !== 0)
|
|
50
|
+
throw new Error(`ffprobe failed: ${result.stderr || 'ffprobe is required'}`);
|
|
51
|
+
return (JSON.parse(result.stdout).frames ?? []).map((frame) => Math.round(Number(frame.best_effort_timestamp_time) * 1000)).filter((value) => Number.isFinite(value));
|
|
52
|
+
}
|
|
53
|
+
export async function startScreenRecording(deviceId, maxDurationSeconds = 15) {
|
|
54
|
+
if (deviceId !== undefined && (typeof deviceId !== 'string' || !deviceId))
|
|
55
|
+
throw new Error('deviceId must be a nonempty string');
|
|
56
|
+
const durationLimit = positiveInteger(maxDurationSeconds, 'maxDurationSeconds', 180);
|
|
57
|
+
const device = await resolveTargetDevice({ platform: 'android', deviceId: deviceId });
|
|
58
|
+
if (activeDevices.has(device.id))
|
|
59
|
+
throw new Error(`A recording is already active for device ${device.id}`);
|
|
60
|
+
const id = randomUUID();
|
|
61
|
+
const directory = path.join(tmpdir(), 'mobile-debug-mcp-recordings', id);
|
|
62
|
+
const remote = `/sdcard/mobile-debug-mcp-${id}.mp4`;
|
|
63
|
+
await mkdir(directory, { recursive: true });
|
|
64
|
+
let finish;
|
|
65
|
+
const finished = new Promise(resolve => { finish = resolve; });
|
|
66
|
+
const child = spawn(getAdbCmd(), ['-s', device.id, 'shell', 'screenrecord', '--time-limit', String(durationLimit), remote], { stdio: 'ignore' });
|
|
67
|
+
const timer = setTimeout(() => { void stopScreenRecording(id).catch(() => undefined); }, (durationLimit + 2) * 1000);
|
|
68
|
+
timer.unref();
|
|
69
|
+
const recording = { id, deviceId: device.id, remote, directory, video: path.join(directory, 'recording.mp4'), startedAt: new Date().toISOString(), durationLimit, process: child, timer, state: 'recording', finished, finish };
|
|
70
|
+
recordings.set(id, recording);
|
|
71
|
+
activeDevices.set(device.id, id);
|
|
72
|
+
child.once('close', () => { finish(); if (recording.state === 'recording')
|
|
73
|
+
void stopScreenRecording(id).catch(() => undefined); });
|
|
74
|
+
child.once('error', () => { finish(); if (recording.state === 'recording')
|
|
75
|
+
void stopScreenRecording(id).catch(() => undefined); });
|
|
76
|
+
return { recordingId: id, deviceId: device.id, startedAt: recording.startedAt, maxDurationSeconds: durationLimit };
|
|
77
|
+
}
|
|
78
|
+
export async function stopScreenRecording(recordingId) {
|
|
79
|
+
const recording = typeof recordingId === 'string' ? recordings.get(recordingId) : undefined;
|
|
80
|
+
if (!recording)
|
|
81
|
+
throw new Error('Unknown recordingId');
|
|
82
|
+
if (recording.stopPromise)
|
|
83
|
+
return recording.stopPromise;
|
|
84
|
+
recording.state = 'stopping';
|
|
85
|
+
clearTimeout(recording.timer);
|
|
86
|
+
recording.stopPromise = (async () => {
|
|
87
|
+
try {
|
|
88
|
+
if (recording.process.exitCode === null) {
|
|
89
|
+
const processes = await adb(recording.deviceId, ['shell', 'ps', '-A', '-o', 'PID,ARGS']).catch(() => null);
|
|
90
|
+
const pid = processes?.stdout.split('\n').find(line => line.includes('screenrecord') && line.includes(recording.remote))?.trim().match(/^\d+/)?.[0];
|
|
91
|
+
if (pid)
|
|
92
|
+
await adb(recording.deviceId, ['shell', 'kill', '-2', pid]).catch(() => undefined);
|
|
93
|
+
}
|
|
94
|
+
await Promise.race([recording.finished, new Promise(resolve => setTimeout(resolve, 3000))]);
|
|
95
|
+
if (recording.process.exitCode === null)
|
|
96
|
+
recording.process.kill('SIGKILL');
|
|
97
|
+
const pulled = await adb(recording.deviceId, ['pull', recording.remote, recording.video], 30000);
|
|
98
|
+
if (pulled.exitCode !== 0)
|
|
99
|
+
throw new Error(`Could not pull recording: ${pulled.stderr}`);
|
|
100
|
+
if ((await stat(recording.video)).size === 0)
|
|
101
|
+
throw new Error('Recording is empty');
|
|
102
|
+
let metadata;
|
|
103
|
+
try {
|
|
104
|
+
metadata = await probe(recording.video);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
metadata = { metadataError: String(error), durationMs: null, resolution: null, frameCount: null, frameTimestampsMs: [] };
|
|
108
|
+
}
|
|
109
|
+
recording.result = { recordingId: recording.id, deviceId: recording.deviceId, videoPath: recording.video, startedAt: recording.startedAt, stoppedAt: new Date().toISOString(), ...metadata };
|
|
110
|
+
return recording.result;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await adb(recording.deviceId, ['shell', 'rm', '-f', recording.remote]).catch(() => undefined);
|
|
114
|
+
activeDevices.delete(recording.deviceId);
|
|
115
|
+
recording.state = 'stopped';
|
|
116
|
+
if (!recording.result) {
|
|
117
|
+
recordings.delete(recording.id);
|
|
118
|
+
await rm(recording.directory, { recursive: true, force: true }).catch(() => undefined);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
return recording.stopPromise;
|
|
123
|
+
}
|
|
124
|
+
export async function extractRecordingFrames(recordingId, startMs, endMs) {
|
|
125
|
+
const recording = typeof recordingId === 'string' ? recordings.get(recordingId) : undefined;
|
|
126
|
+
if (!recording || recording.state !== 'stopped' || !recording.result)
|
|
127
|
+
throw new Error('Unknown or unfinished recordingId');
|
|
128
|
+
if (typeof startMs !== 'number' || !Number.isInteger(startMs) || startMs < 0 || typeof endMs !== 'number' || !Number.isInteger(endMs) || endMs <= startMs || endMs - startMs > 10000)
|
|
129
|
+
throw new Error('Window must be 1 to 10000 ms with nonnegative integer timestamps');
|
|
130
|
+
const directory = path.join(recording.directory, `frames-${startMs}-${endMs}`);
|
|
131
|
+
await rm(directory, { recursive: true, force: true });
|
|
132
|
+
await mkdir(directory, { recursive: true });
|
|
133
|
+
let allTimes;
|
|
134
|
+
let frames;
|
|
135
|
+
try {
|
|
136
|
+
allTimes = await frameTimes(recording.video);
|
|
137
|
+
const selected = allTimes.filter(time => time >= startMs && time <= endMs);
|
|
138
|
+
const result = await tool('ffmpeg', ['-v', 'error', '-i', recording.video, '-vf', `select=between(t\,${startMs / 1000}\,${endMs / 1000})`, '-vsync', '0', path.join(directory, '%06d.png')], 60000);
|
|
139
|
+
if (result.exitCode !== 0)
|
|
140
|
+
throw new Error(`ffmpeg failed: ${result.stderr || 'ffmpeg is required for frame extraction'}`);
|
|
141
|
+
const files = (await readdir(directory)).filter(file => file.endsWith('.png')).sort();
|
|
142
|
+
if (files.length !== selected.length)
|
|
143
|
+
throw new Error(`Frame count mismatch: ffprobe found ${selected.length}, ffmpeg extracted ${files.length}`);
|
|
144
|
+
frames = files.map((file, index) => ({ timestampMs: selected[index], path: path.join(directory, file) }));
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (process.platform !== 'darwin')
|
|
148
|
+
throw error;
|
|
149
|
+
const decoded = await swiftDecode(recording.video, directory, startMs, endMs);
|
|
150
|
+
allTimes = decoded.frameTimestampsMs;
|
|
151
|
+
frames = decoded.frames;
|
|
152
|
+
}
|
|
153
|
+
const selected = frames.map(frame => frame.timestampMs);
|
|
154
|
+
const deltas = allTimes.slice(1).map((time, index) => time - allTimes[index]).filter(delta => delta > 0).sort((a, b) => a - b);
|
|
155
|
+
const nominalMs = deltas.length ? deltas[Math.floor((deltas.length - 1) / 2)] : null;
|
|
156
|
+
const gaps = allTimes.slice(1).flatMap((time, index) => nominalMs && time - allTimes[index] > nominalMs * 1.5 && time >= startMs && allTimes[index] <= endMs ? [{ startMs: allTimes[index], endMs: time, durationMs: time - allTimes[index] }] : []);
|
|
157
|
+
if (selected.length === 0 || selected[0] > startMs + (nominalMs ?? 0) * 1.5)
|
|
158
|
+
gaps.unshift({ startMs, endMs: selected[0] ?? endMs, durationMs: (selected[0] ?? endMs) - startMs });
|
|
159
|
+
if (selected.length && selected[selected.length - 1] < endMs - (nominalMs ?? 0) * 1.5)
|
|
160
|
+
gaps.push({ startMs: selected[selected.length - 1], endMs, durationMs: endMs - selected[selected.length - 1] });
|
|
161
|
+
return { recordingId, startMs, endMs, frames, frameCount: frames.length, nominalFrameIntervalMs: nominalMs, captureGaps: gaps, continuousCoverage: gaps.length === 0, note: 'Frames are encoded samples. A gap may hide a brief flash; absence cannot be established across gaps.' };
|
|
162
|
+
}
|
|
163
|
+
export async function shutdownRecordings() {
|
|
164
|
+
await Promise.allSettled([...activeDevices.values()].map(id => stopScreenRecording(id)));
|
|
165
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
let nextRevision = 1;
|
|
3
|
+
const snapshotSessionId = crypto.randomUUID();
|
|
3
4
|
const snapshotStateByDevice = new Map();
|
|
5
|
+
export function getSnapshotSessionId() { return snapshotSessionId; }
|
|
6
|
+
export function hasSnapshotBaseline(deviceKey, revision) {
|
|
7
|
+
const state = snapshotStateByDevice.get(deviceKey);
|
|
8
|
+
const capturedAt = state?.historyTimes.get(revision);
|
|
9
|
+
return !!state && capturedAt !== undefined && state.history.has(revision) &&
|
|
10
|
+
Date.now() - capturedAt <= 10 * 60 * 1000 && Date.now() - state.updatedAt <= 10 * 60 * 1000;
|
|
11
|
+
}
|
|
4
12
|
function normalize(value) {
|
|
5
13
|
if (value === null || value === undefined)
|
|
6
14
|
return '';
|
|
@@ -135,8 +143,7 @@ export function deriveSnapshotMetadata(deviceKey, tree, source, signatureOverrid
|
|
|
135
143
|
const oversized = previous?.oversized ?? new Map();
|
|
136
144
|
if (hasValidTree) {
|
|
137
145
|
history.set(revision, currentElements);
|
|
138
|
-
|
|
139
|
-
historyTimes.set(revision, Date.now());
|
|
146
|
+
historyTimes.set(revision, Date.now());
|
|
140
147
|
if ((tree.elements?.length ?? 0) > 500)
|
|
141
148
|
oversized.set(revision, (tree.elements?.length ?? 0) - 500);
|
|
142
149
|
}
|
|
@@ -164,6 +171,7 @@ export function deriveSnapshotMetadata(deviceKey, tree, source, signatureOverrid
|
|
|
164
171
|
updatedAt: Date.now()
|
|
165
172
|
});
|
|
166
173
|
return {
|
|
174
|
+
snapshot_session_id: snapshotSessionId,
|
|
167
175
|
snapshot_revision: revision,
|
|
168
176
|
captured_at_ms: Date.now(),
|
|
169
177
|
snapshot_delta: hasValidTree ? summarizeSnapshotDelta(previous, currentElementSignatures) : null,
|
package/dist/server/common.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { currentEvidenceActionId } from '../observe/evidence.js';
|
|
1
2
|
import { recordTiming } from '../utils/timing.js';
|
|
2
3
|
import { ToolsObserve } from '../observe/index.js';
|
|
4
|
+
import { isConditionBlocked } from '../interact/conditional-action.js';
|
|
3
5
|
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3;
|
|
4
6
|
export const DEFAULT_MAX_RETRY_DEPTH = 3;
|
|
5
7
|
export function wrapResponse(data, includeSerializationTiming = true) {
|
|
@@ -62,10 +64,15 @@ export function getArrayArg(args, key) {
|
|
|
62
64
|
}
|
|
63
65
|
let actionSequence = 0;
|
|
64
66
|
export function nextActionId(actionType, timestamp) {
|
|
67
|
+
const current = currentEvidenceActionId();
|
|
68
|
+
if (current)
|
|
69
|
+
return current;
|
|
65
70
|
actionSequence += 1;
|
|
66
71
|
return `${actionType}_${timestamp}_${actionSequence}`;
|
|
67
72
|
}
|
|
68
73
|
export async function captureActionFingerprint(platform, deviceId) {
|
|
74
|
+
if (isConditionBlocked())
|
|
75
|
+
return null;
|
|
69
76
|
if (!platform)
|
|
70
77
|
return null;
|
|
71
78
|
try {
|
|
@@ -235,6 +242,8 @@ function mapFailureCodeToFailureClass(code) {
|
|
|
235
242
|
case 'STALE_REFERENCE':
|
|
236
243
|
return 'TargetResolutionFailure';
|
|
237
244
|
case 'ELEMENT_NOT_INTERACTABLE':
|
|
245
|
+
case 'PRECONDITION_FAILED':
|
|
246
|
+
case 'PRECONDITION_UNAVAILABLE':
|
|
238
247
|
case 'ACTION_TIMEOUT':
|
|
239
248
|
return 'ExecutionFailure';
|
|
240
249
|
case 'TIMEOUT':
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const conditionalTools = new Set(['tap', 'tap_element', 'swipe', 'type_text', 'press_back']);
|
|
3
|
+
export const expectedStateSchema = z.object({
|
|
4
|
+
snapshotSessionId: z.string().min(1),
|
|
5
|
+
snapshotRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
6
|
+
timeoutMs: z.number().int().min(100).max(60000).optional()
|
|
7
|
+
}).strict();
|
|
8
|
+
export const expectedStateJson = {
|
|
9
|
+
type: 'object', additionalProperties: false,
|
|
10
|
+
properties: {
|
|
11
|
+
snapshotSessionId: { type: 'string', minLength: 1 },
|
|
12
|
+
snapshotRevision: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
|
|
13
|
+
timeoutMs: { type: 'integer', minimum: 100, maximum: 60000, default: 15000 }
|
|
14
|
+
},
|
|
15
|
+
required: ['snapshotSessionId', 'snapshotRevision']
|
|
16
|
+
};
|
|
17
|
+
export function validateExpectedState(name, args) {
|
|
18
|
+
if (args.expectedState === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!conditionalTools.has(name))
|
|
21
|
+
throw new Error('INVALID_ARGUMENT: expectedState is not supported on this tool');
|
|
22
|
+
const parsed = expectedStateSchema.safeParse(args.expectedState);
|
|
23
|
+
if (!parsed.success)
|
|
24
|
+
throw new Error('INVALID_ARGUMENT: invalid expectedState');
|
|
25
|
+
if ((args.platform !== 'android' && args.platform !== 'ios') ||
|
|
26
|
+
typeof args.deviceId !== 'string' || !args.deviceId.trim() ||
|
|
27
|
+
['booted', 'default'].includes(args.deviceId))
|
|
28
|
+
throw new Error('INVALID_ARGUMENT: expectedState requires an explicit platform and concrete deviceId');
|
|
29
|
+
if (['type_text', 'press_back'].includes(name) && args.platform !== 'android')
|
|
30
|
+
throw new Error('INVALID_ARGUMENT: this action supports only Android');
|
|
31
|
+
return parsed.data;
|
|
32
|
+
}
|