mobile-debug-mcp 0.32.4 → 0.34.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/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -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 +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -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 +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -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/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- 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 +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -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/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -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/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -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 +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -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/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -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
|
@@ -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,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execCmd } from '../utils/exec.js';
|
|
2
|
+
import { getAdbCmd } from '../utils/android/utils.js';
|
|
3
|
+
export const TRACE_MAX_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
export const TRACE_CATEGORIES = ['sched', 'freq', 'idle', 'am', 'wm', 'gfx', 'view', 'input'];
|
|
5
|
+
// Encode only this fixed preset using the public Perfetto proto field numbers:
|
|
6
|
+
// https://github.com/google/perfetto/tree/main/protos/perfetto/config
|
|
7
|
+
// Avoid --txt (explicitly unstable), arbitrary configuration and extra tooling.
|
|
8
|
+
function varint(n) {
|
|
9
|
+
const bytes = [];
|
|
10
|
+
do {
|
|
11
|
+
const byte = n % 128;
|
|
12
|
+
n = Math.floor(n / 128);
|
|
13
|
+
bytes.push(byte | (n ? 128 : 0));
|
|
14
|
+
} while (n);
|
|
15
|
+
return Buffer.from(bytes);
|
|
16
|
+
}
|
|
17
|
+
const uint = (field, n) => Buffer.concat([varint(field * 8), varint(n)]);
|
|
18
|
+
const message = (field, value) => {
|
|
19
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
20
|
+
return Buffer.concat([varint(field * 8 + 2), varint(bytes.length), bytes]);
|
|
21
|
+
};
|
|
22
|
+
export function traceConfig(duration, categories) {
|
|
23
|
+
const ftrace = Buffer.concat(categories.map(cat => message(2, cat)));
|
|
24
|
+
const source = Buffer.concat([message(1, 'linux.ftrace'), message(100, ftrace)]);
|
|
25
|
+
return Buffer.concat([
|
|
26
|
+
message(1, Buffer.concat([uint(1, 16384), uint(4, 1)])),
|
|
27
|
+
message(2, message(1, source)), uint(3, duration), uint(8, 1), uint(10, TRACE_MAX_BYTES)
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
31
|
+
function ownedPath(remote) {
|
|
32
|
+
if (!/^\/data\/misc\/perfetto-traces\/mcp-[0-9a-f-]{36}\.perfetto-trace$/.test(remote))
|
|
33
|
+
throw new Error('Invalid owned trace path');
|
|
34
|
+
}
|
|
35
|
+
export class AndroidTraceNative {
|
|
36
|
+
execute;
|
|
37
|
+
constructor(execute = execCmd) {
|
|
38
|
+
this.execute = execute;
|
|
39
|
+
}
|
|
40
|
+
async command(device, args, timeout = 10000) {
|
|
41
|
+
const result = await this.execute(getAdbCmd(), ['-s', device, ...args], { timeout });
|
|
42
|
+
if (result.exitCode !== 0)
|
|
43
|
+
throw new Error(result.stderr === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : 'ADB_TRACE_COMMAND_FAILED');
|
|
44
|
+
return result.stdout;
|
|
45
|
+
}
|
|
46
|
+
shell(device, script, timeout) {
|
|
47
|
+
return this.command(device, ['shell', 'sh', '-c', quote(script)], timeout);
|
|
48
|
+
}
|
|
49
|
+
async capabilities(deviceId) {
|
|
50
|
+
const checks = {};
|
|
51
|
+
const probe = async (name, fn) => { try {
|
|
52
|
+
checks[name] = await fn();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
checks[name] = false;
|
|
56
|
+
} };
|
|
57
|
+
let help = '', categories = '';
|
|
58
|
+
await probe('android_api_29', async () => Number(await this.command(deviceId, ['shell', 'getprop', 'ro.build.version.sdk'])) >= 29);
|
|
59
|
+
await probe('perfetto', async () => { const result = await this.execute(getAdbCmd(), ['-s', deviceId, 'shell', 'perfetto', '--help'], { timeout: 10000 }); help = result.stdout + result.stderr; return [0, 1].includes(result.exitCode) && help.includes('Usage: perfetto') && help.includes('--background-wait') && help.includes('--config'); });
|
|
60
|
+
await probe('tracing_service', async () => { await this.command(deviceId, ['shell', 'perfetto', '--query']); return true; });
|
|
61
|
+
await probe('writable_storage', async () => { await this.shell(deviceId, 'test -w /data/misc/perfetto-traces'); return true; });
|
|
62
|
+
await probe('process_identity', async () => { await this.shell(deviceId, 'command -v pidof >/dev/null && test -r /proc/self/stat && test -r /proc/self/cmdline'); return true; });
|
|
63
|
+
await probe('base64', async () => (await this.shell(deviceId, "printf YQ== | base64 -d")) === 'a');
|
|
64
|
+
await probe('required_categories', async () => {
|
|
65
|
+
categories = await this.command(deviceId, ['shell', 'atrace', '--list_categories']);
|
|
66
|
+
return ['sched', 'input'].every(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories));
|
|
67
|
+
});
|
|
68
|
+
const enabled = TRACE_CATEGORIES.filter(c => new RegExp(`^\\s*${c}\\s+-`, 'm').test(categories));
|
|
69
|
+
return { supported: Object.values(checks).every(Boolean), backend: 'perfetto', device: { platform: 'android', id: deviceId },
|
|
70
|
+
prerequisites: checks, reasons: Object.keys(checks).filter(k => !checks[k]), enabled_sources: enabled,
|
|
71
|
+
unavailable_categories: TRACE_CATEGORIES.filter(c => !enabled.includes(c)), limitations: [
|
|
72
|
+
'Device-wide scheduling and system annotations may contain sensitive information.',
|
|
73
|
+
'Host times are not aligned with device trace clocks; source availability varies.',
|
|
74
|
+
'A server crash can leave a bounded trace file on the device for manual removal.'
|
|
75
|
+
] };
|
|
76
|
+
}
|
|
77
|
+
async start(device, remote, duration, categories) {
|
|
78
|
+
ownedPath(remote);
|
|
79
|
+
const encoded = traceConfig(duration, categories).toString('base64');
|
|
80
|
+
await this.shell(device, `umask 077; printf %s ${quote(encoded)} | base64 -d | perfetto --background-wait -c - -o ${quote(remote)}`, 35000);
|
|
81
|
+
}
|
|
82
|
+
// Scan only shell-owned readable processes. Exact output argv + executable name
|
|
83
|
+
// binds ownership even if start acknowledgement/PID output was lost.
|
|
84
|
+
async find(device, remote) {
|
|
85
|
+
ownedPath(remote);
|
|
86
|
+
const output = await this.shell(device, `pids=$(pidof perfetto); rc=$?
|
|
87
|
+
[ "$rc" -le 1 ] || exit 1
|
|
88
|
+
for pid in $pids; do
|
|
89
|
+
p=/proc/$pid
|
|
90
|
+
[ -d "$p" ] || continue
|
|
91
|
+
[ -r "$p/cmdline" ] || { [ ! -d "$p" ] && continue; exit 1; }
|
|
92
|
+
cmd=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
|
|
93
|
+
printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || continue
|
|
94
|
+
first=$(printf '%s\\n' "$cmd" | head -n 1)
|
|
95
|
+
case "$first" in perfetto|/system/bin/perfetto) ;; *) continue ;; esac
|
|
96
|
+
stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }
|
|
97
|
+
rest=\${stat##*) }
|
|
98
|
+
state=$(printf '%s\\n' "$rest" | awk '{print $1}')
|
|
99
|
+
[ "$state" = Z ] && continue
|
|
100
|
+
start=$(printf '%s\\n' "$rest" | awk '{print $20}')
|
|
101
|
+
printf '%s %s\\n' "\${p##*/}" "$start"
|
|
102
|
+
done`);
|
|
103
|
+
if (!output)
|
|
104
|
+
return null;
|
|
105
|
+
const match = /^(\d+) (\d+)$/.exec(output);
|
|
106
|
+
if (!match)
|
|
107
|
+
throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE');
|
|
108
|
+
return { pid: Number(match[1]), startTime: match[2] };
|
|
109
|
+
}
|
|
110
|
+
async stop(device, remote, owner) {
|
|
111
|
+
ownedPath(remote);
|
|
112
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1 || !/^\d+$/.test(owner.startTime))
|
|
113
|
+
throw new Error('TRACE_PROCESS_IDENTITY_UNAVAILABLE');
|
|
114
|
+
// Recheck start time and argv in the same shell immediately before signal.
|
|
115
|
+
await this.shell(device, `p=/proc/${owner.pid}
|
|
116
|
+
[ -d "$p" ] || exit 0
|
|
117
|
+
stat=$(cat "$p/stat") || exit 1
|
|
118
|
+
rest=\${stat##*) }
|
|
119
|
+
[ "$(printf '%s\\n' "$rest" | awk '{print $20}')" = ${quote(owner.startTime)} ] || exit 1
|
|
120
|
+
cmd=$(tr '\\000' '\\n' < "$p/cmdline") || exit 1
|
|
121
|
+
first=$(printf '%s\\n' "$cmd" | head -n 1)
|
|
122
|
+
case "$first" in perfetto|/system/bin/perfetto) ;; *) exit 1 ;; esac
|
|
123
|
+
printf '%s\\n' "$cmd" | grep -Fx ${quote(remote)} >/dev/null || exit 1
|
|
124
|
+
kill -TERM ${owner.pid}`);
|
|
125
|
+
}
|
|
126
|
+
async pull(device, remote, local) {
|
|
127
|
+
ownedPath(remote);
|
|
128
|
+
// Reject oversized output before initiating transfer as well as afterwards.
|
|
129
|
+
const size = Number(await this.command(device, ['shell', 'stat', '-c', '%s', remote]));
|
|
130
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > TRACE_MAX_BYTES)
|
|
131
|
+
throw new Error('TRACE_ARTIFACT_SIZE_INVALID');
|
|
132
|
+
await this.command(device, ['pull', remote, local], 30000);
|
|
133
|
+
}
|
|
134
|
+
async remove(device, remote) { ownedPath(remote); await this.command(device, ['shell', 'rm', '-f', remote]); }
|
|
135
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AndroidTraceNative, TRACE_MAX_BYTES } from './system-trace-android.js';
|
|
6
|
+
import { evidenceSessionId } from './evidence.js';
|
|
7
|
+
import { runWithBudget, remainingBudget } from '../utils/operation-budget.js';
|
|
8
|
+
export class TraceError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code) {
|
|
11
|
+
super(code);
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const clock = {
|
|
16
|
+
wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
17
|
+
schedule: (fn, ms) => { const t = setTimeout(fn, ms); t.unref(); return t; }, clear: clearTimeout
|
|
18
|
+
};
|
|
19
|
+
export class SystemTraceSessions {
|
|
20
|
+
native;
|
|
21
|
+
time;
|
|
22
|
+
sessions = new Map();
|
|
23
|
+
closing = false;
|
|
24
|
+
constructor(native = new AndroidTraceNative(), time = clock) {
|
|
25
|
+
this.native = native;
|
|
26
|
+
this.time = time;
|
|
27
|
+
}
|
|
28
|
+
prune() {
|
|
29
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.result.cleanup.status === 'complete');
|
|
30
|
+
for (const s of terminal)
|
|
31
|
+
if (this.time.mono() - s.terminalAt >= 600000)
|
|
32
|
+
this.sessions.delete(s.result.trace_id);
|
|
33
|
+
const remaining = terminal.filter(s => this.sessions.has(s.result.trace_id));
|
|
34
|
+
for (const s of remaining.slice(0, Math.max(0, remaining.length - 100)))
|
|
35
|
+
this.sessions.delete(s.result.trace_id);
|
|
36
|
+
}
|
|
37
|
+
get(id) { this.prune(); const s = this.sessions.get(id); if (!s)
|
|
38
|
+
throw new TraceError('TRACE_NOT_FOUND'); return s; }
|
|
39
|
+
snapshot(s) { return structuredClone(s.result); }
|
|
40
|
+
serial(s, fn) {
|
|
41
|
+
const result = s.queue.then(fn);
|
|
42
|
+
s.queue = result.catch(() => { });
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
async capabilities(device) { return this.native.capabilities(device); }
|
|
46
|
+
async start(device, duration = 30000) {
|
|
47
|
+
if (!Number.isInteger(duration) || duration < 1000 || duration > 120000)
|
|
48
|
+
throw new TraceError('INVALID_ARGUMENT');
|
|
49
|
+
this.prune();
|
|
50
|
+
const reserved = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
|
|
51
|
+
if (this.closing || reserved.length >= 4 || reserved.some(s => s.result.device.id === device))
|
|
52
|
+
throw new TraceError('TRACE_BUSY');
|
|
53
|
+
const id = randomUUID();
|
|
54
|
+
const result = { trace_id: id, session_id: evidenceSessionId(), device: { platform: 'android', id: device }, backend: 'perfetto',
|
|
55
|
+
state: 'starting', reason: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
|
|
56
|
+
artifact: null, cleanup: { status: 'pending', remaining_resources: [] }, correlation_ids: [], enabled_sources: [], unavailable_categories: [] };
|
|
57
|
+
const s = { result, remote: `/data/misc/perfetto-traces/mcp-${id}.perfetto-trace`, owner: null, stopped: true,
|
|
58
|
+
discard: false, deadline: Infinity, queue: Promise.resolve(), linked: new Set(), partial: new Set() };
|
|
59
|
+
this.sessions.set(id, s);
|
|
60
|
+
return this.serial(s, async () => {
|
|
61
|
+
let cap;
|
|
62
|
+
try {
|
|
63
|
+
cap = await this.native.capabilities(device);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
this.fail(s, 'TRACE_UNSUPPORTED');
|
|
67
|
+
this.clean(s);
|
|
68
|
+
return this.snapshot(s);
|
|
69
|
+
}
|
|
70
|
+
s.result.enabled_sources = cap.enabled_sources;
|
|
71
|
+
s.result.unavailable_categories = cap.unavailable_categories;
|
|
72
|
+
if (!cap.supported || this.closing) {
|
|
73
|
+
this.fail(s, this.closing ? 'TRACE_BUSY' : 'TRACE_UNSUPPORTED');
|
|
74
|
+
this.clean(s);
|
|
75
|
+
return this.snapshot(s);
|
|
76
|
+
}
|
|
77
|
+
s.stopped = false;
|
|
78
|
+
s.result.cleanup.remaining_resources = [s.remote, 'device_capture'];
|
|
79
|
+
// Conservative host bounds include acknowledgement latency, not exact device start.
|
|
80
|
+
s.result.capture_started_at_ms = this.time.wall();
|
|
81
|
+
s.deadline = this.time.mono() + duration;
|
|
82
|
+
try {
|
|
83
|
+
await this.native.start(device, s.remote, duration, cap.enabled_sources);
|
|
84
|
+
s.owner = await this.native.find(device, s.remote);
|
|
85
|
+
s.result.state = 'recording';
|
|
86
|
+
s.timer = this.time.schedule(() => { void this.control(id, 'duration').catch(() => { }); }, Math.max(0, s.deadline - this.time.mono()));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
s.discard = true;
|
|
90
|
+
this.fail(s, this.code(error, 'TRACE_START_FAILED'));
|
|
91
|
+
try {
|
|
92
|
+
await this.halt(s);
|
|
93
|
+
await this.remove(s);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
s.result.cleanup.status = 'failed';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return this.snapshot(s);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
code(error, fallback) { return error instanceof Error && error.message === 'ACTION_TIMEOUT' ? 'ACTION_TIMEOUT' : fallback; }
|
|
103
|
+
fail(s, reason) {
|
|
104
|
+
s.result.state = 'failed';
|
|
105
|
+
s.result.reason = reason;
|
|
106
|
+
s.result.completion_reason = 'error';
|
|
107
|
+
s.terminalAt = this.time.mono();
|
|
108
|
+
}
|
|
109
|
+
clean(s) {
|
|
110
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] };
|
|
111
|
+
s.terminalAt = this.time.mono();
|
|
112
|
+
this.prune();
|
|
113
|
+
}
|
|
114
|
+
async halt(s) {
|
|
115
|
+
if (s.stopped)
|
|
116
|
+
return;
|
|
117
|
+
const deadline = this.time.mono() + 10000;
|
|
118
|
+
await runWithBudget(10000, async () => {
|
|
119
|
+
const found = await this.native.find(s.result.device.id, s.remote);
|
|
120
|
+
if (found) {
|
|
121
|
+
if (s.owner && (s.owner.pid !== found.pid || s.owner.startTime !== found.startTime))
|
|
122
|
+
throw new TraceError('TRACE_STOP_FAILED');
|
|
123
|
+
s.owner ??= found;
|
|
124
|
+
try {
|
|
125
|
+
await this.native.stop(s.result.device.id, s.remote, s.owner);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// Natural expiry can win between identity lookup and SIGTERM. Only
|
|
129
|
+
// confirmed absence permits finalisation; unreadable/reused identity fails.
|
|
130
|
+
if (await this.native.find(s.result.device.id, s.remote))
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
while (await this.native.find(s.result.device.id, s.remote)) {
|
|
134
|
+
if (this.time.mono() >= deadline)
|
|
135
|
+
throw new TraceError('ACTION_TIMEOUT');
|
|
136
|
+
remainingBudget();
|
|
137
|
+
await this.time.sleep(100);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
s.stopped = true;
|
|
142
|
+
s.result.capture_completed_at_ms = this.time.wall();
|
|
143
|
+
s.result.cleanup.remaining_resources = [s.remote];
|
|
144
|
+
for (const linked of s.linked)
|
|
145
|
+
s.partial.add(linked);
|
|
146
|
+
}
|
|
147
|
+
async remove(s) {
|
|
148
|
+
if (!s.stopped)
|
|
149
|
+
throw new TraceError('TRACE_STOP_FAILED');
|
|
150
|
+
await this.native.remove(s.result.device.id, s.remote);
|
|
151
|
+
if (s.local && !s.result.artifact) {
|
|
152
|
+
await fs.rm(s.local, { recursive: true, force: true });
|
|
153
|
+
s.local = undefined;
|
|
154
|
+
}
|
|
155
|
+
this.clean(s);
|
|
156
|
+
}
|
|
157
|
+
async artifact(s) {
|
|
158
|
+
if (s.result.artifact)
|
|
159
|
+
return;
|
|
160
|
+
s.local ??= await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-trace-'));
|
|
161
|
+
const local = s.local;
|
|
162
|
+
const partial = path.join(local, 'capture.partial');
|
|
163
|
+
try {
|
|
164
|
+
await fs.chmod(s.local, 0o700);
|
|
165
|
+
await this.native.pull(s.result.device.id, s.remote, partial);
|
|
166
|
+
const stat = await fs.stat(partial);
|
|
167
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > TRACE_MAX_BYTES)
|
|
168
|
+
throw new TraceError('TRACE_ARTIFACT_FAILED');
|
|
169
|
+
await fs.chmod(partial, 0o600);
|
|
170
|
+
const target = path.join(s.local, `${s.result.trace_id}.perfetto-trace`);
|
|
171
|
+
await fs.rename(partial, target);
|
|
172
|
+
s.result.artifact = { path: target, format: 'perfetto', size_bytes: stat.size };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
try {
|
|
176
|
+
await fs.rm(local, { recursive: true, force: true });
|
|
177
|
+
s.local = undefined;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
s.result.cleanup.remaining_resources = [s.remote, local];
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async control(id, operation) {
|
|
186
|
+
const s = this.get(id);
|
|
187
|
+
return this.serial(s, async () => {
|
|
188
|
+
if (s.result.cleanup.status === 'complete')
|
|
189
|
+
return this.snapshot(s);
|
|
190
|
+
if (operation === 'status' && s.result.state === 'recording') {
|
|
191
|
+
try {
|
|
192
|
+
if (await this.native.find(s.result.device.id, s.remote) && this.time.mono() < s.deadline)
|
|
193
|
+
return this.snapshot(s);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.fail(s, this.code(error, 'TRACE_STOP_FAILED'));
|
|
197
|
+
s.result.cleanup.status = 'failed';
|
|
198
|
+
return this.snapshot(s);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (s.timer) {
|
|
202
|
+
this.time.clear(s.timer);
|
|
203
|
+
s.timer = undefined;
|
|
204
|
+
}
|
|
205
|
+
if (operation === 'cancel')
|
|
206
|
+
s.discard = true;
|
|
207
|
+
const hadStartFailure = s.discard && s.result.state === 'failed' && operation !== 'cancel';
|
|
208
|
+
s.result.state = 'stopping';
|
|
209
|
+
s.result.cleanup.status = 'pending';
|
|
210
|
+
try {
|
|
211
|
+
await this.halt(s);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
this.fail(s, this.code(error, 'TRACE_STOP_FAILED'));
|
|
215
|
+
s.result.cleanup.status = 'failed';
|
|
216
|
+
return this.snapshot(s);
|
|
217
|
+
}
|
|
218
|
+
if (!s.discard) {
|
|
219
|
+
try {
|
|
220
|
+
await this.artifact(s);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.fail(s, this.code(error, 'TRACE_ARTIFACT_FAILED'));
|
|
224
|
+
s.result.cleanup.status = 'failed';
|
|
225
|
+
return this.snapshot(s);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await this.remove(s);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
this.fail(s, 'TRACE_STOP_FAILED');
|
|
233
|
+
s.result.cleanup.status = 'failed';
|
|
234
|
+
return this.snapshot(s);
|
|
235
|
+
}
|
|
236
|
+
if (hadStartFailure)
|
|
237
|
+
s.result.state = 'failed';
|
|
238
|
+
else {
|
|
239
|
+
s.result.state = s.discard ? 'cancelled' : 'completed';
|
|
240
|
+
s.result.reason = s.discard ? 'TRACE_CANCELLED' : null;
|
|
241
|
+
s.result.completion_reason = s.discard ? 'cancelled' : operation === 'duration' || this.time.mono() >= s.deadline ? 'duration_limit' : 'requested';
|
|
242
|
+
}
|
|
243
|
+
return this.snapshot(s);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async link(id, device, correlation) {
|
|
247
|
+
const s = this.get(id);
|
|
248
|
+
if (device.platform !== 'android' || device.id !== s.result.device.id)
|
|
249
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
250
|
+
const status = await this.control(id, 'status');
|
|
251
|
+
if (device.platform !== 'android' || device.id !== status.device.id || status.state !== 'recording')
|
|
252
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
253
|
+
// Validate again after the asynchronous status call: stop/cancel may have won.
|
|
254
|
+
return this.serial(s, async () => {
|
|
255
|
+
if (s.result.state !== 'recording' || this.time.mono() >= s.deadline)
|
|
256
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
257
|
+
if (!s.result.correlation_ids.includes(correlation)) {
|
|
258
|
+
if (s.result.correlation_ids.length >= 1000)
|
|
259
|
+
throw new TraceError('TRACE_BUSY');
|
|
260
|
+
s.result.correlation_ids.push(correlation);
|
|
261
|
+
}
|
|
262
|
+
s.linked.add(correlation);
|
|
263
|
+
let finish;
|
|
264
|
+
return () => finish ??= (async () => {
|
|
265
|
+
let recording = false;
|
|
266
|
+
try {
|
|
267
|
+
recording = (await this.control(id, 'status')).state === 'recording';
|
|
268
|
+
}
|
|
269
|
+
catch { /* Unavailable state cannot establish complete coverage. */ }
|
|
270
|
+
s.linked.delete(correlation);
|
|
271
|
+
return { trace_id: id, coverage: !recording || s.partial.has(correlation) || this.time.mono() >= s.deadline ? 'partial' : 'covered',
|
|
272
|
+
clock_alignment: 'unknown' };
|
|
273
|
+
})();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async shutdown() {
|
|
277
|
+
this.closing = true;
|
|
278
|
+
// Independent device cleanup in parallel under one overall deadline.
|
|
279
|
+
return runWithBudget(10000, async () => {
|
|
280
|
+
await Promise.allSettled([...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.control(s.result.trace_id, 'cancel')));
|
|
281
|
+
return [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s));
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
export const systemTraces = new SystemTraceSessions();
|
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':
|