@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn: spawnProcess } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const defaultDeviceLogBuffers = ['main', 'system', 'crash'];
|
|
6
|
+
const sensitiveDeviceLogBuffers = new Set(['radio', 'security', 'kernel']);
|
|
7
|
+
const allowedDeviceLogBuffers = new Set([...defaultDeviceLogBuffers, ...sensitiveDeviceLogBuffers]);
|
|
8
|
+
|
|
9
|
+
class ObservationCollector {
|
|
10
|
+
constructor({
|
|
11
|
+
rawRunner,
|
|
12
|
+
recordEvidence,
|
|
13
|
+
recordDeviceLog,
|
|
14
|
+
spawn = spawnProcess,
|
|
15
|
+
clock = Date.now,
|
|
16
|
+
timers = globalThis,
|
|
17
|
+
pollIntervalMs = 1_000,
|
|
18
|
+
statusIntervalMs = 5_000,
|
|
19
|
+
initialBackoffMs = 1_000,
|
|
20
|
+
maxBackoffMs = 30_000,
|
|
21
|
+
deviceLogFlushMs = 250,
|
|
22
|
+
deviceLogBatchLines = 100,
|
|
23
|
+
deviceLogBatchBytes = 64 * 1024,
|
|
24
|
+
maxDeviceLogQueueLines = 2_000,
|
|
25
|
+
maxDeviceLogQueueBytes = 1024 * 1024,
|
|
26
|
+
maxDeviceLogPendingBatches = 8,
|
|
27
|
+
deviceLogRestartMs = 1_000,
|
|
28
|
+
maxDeviceLogRestartMs = 30_000,
|
|
29
|
+
actionTimelineLimit = 64,
|
|
30
|
+
actionTimelineTtlMs = 30_000,
|
|
31
|
+
maxTargets = 32,
|
|
32
|
+
inactiveTargetTtlMs = 30 * 60 * 1_000,
|
|
33
|
+
} = {}) {
|
|
34
|
+
if (typeof rawRunner !== 'function') throw new TypeError('rawRunner must be a function');
|
|
35
|
+
if (typeof recordEvidence !== 'function') throw new TypeError('recordEvidence must be a function');
|
|
36
|
+
if (typeof recordDeviceLog !== 'function') throw new TypeError('recordDeviceLog must be a function');
|
|
37
|
+
if (typeof spawn !== 'function') throw new TypeError('spawn must be a function');
|
|
38
|
+
if (typeof clock !== 'function') throw new TypeError('clock must be a function');
|
|
39
|
+
if (!timers || typeof timers.setTimeout !== 'function' || typeof timers.clearTimeout !== 'function') {
|
|
40
|
+
throw new TypeError('timers must provide setTimeout and clearTimeout');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.rawRunner = rawRunner;
|
|
44
|
+
this.recordEvidence = recordEvidence;
|
|
45
|
+
this.recordDeviceLog = recordDeviceLog;
|
|
46
|
+
this.spawn = spawn;
|
|
47
|
+
this.clock = clock;
|
|
48
|
+
this.timers = timers;
|
|
49
|
+
this.pollIntervalMs = positiveNumber(pollIntervalMs, 'pollIntervalMs');
|
|
50
|
+
this.statusIntervalMs = positiveNumber(statusIntervalMs, 'statusIntervalMs');
|
|
51
|
+
this.initialBackoffMs = positiveNumber(initialBackoffMs, 'initialBackoffMs');
|
|
52
|
+
this.maxBackoffMs = positiveNumber(maxBackoffMs, 'maxBackoffMs');
|
|
53
|
+
this.deviceLogFlushMs = positiveNumber(deviceLogFlushMs, 'deviceLogFlushMs');
|
|
54
|
+
this.deviceLogBatchLines = positiveInteger(deviceLogBatchLines, 'deviceLogBatchLines');
|
|
55
|
+
this.deviceLogBatchBytes = positiveInteger(deviceLogBatchBytes, 'deviceLogBatchBytes');
|
|
56
|
+
this.maxDeviceLogQueueLines = positiveInteger(maxDeviceLogQueueLines, 'maxDeviceLogQueueLines');
|
|
57
|
+
this.maxDeviceLogQueueBytes = positiveInteger(maxDeviceLogQueueBytes, 'maxDeviceLogQueueBytes');
|
|
58
|
+
this.maxDeviceLogPendingBatches = positiveInteger(maxDeviceLogPendingBatches, 'maxDeviceLogPendingBatches');
|
|
59
|
+
this.deviceLogRestartMs = positiveNumber(deviceLogRestartMs, 'deviceLogRestartMs');
|
|
60
|
+
this.maxDeviceLogRestartMs = positiveNumber(maxDeviceLogRestartMs, 'maxDeviceLogRestartMs');
|
|
61
|
+
this.actionTimelineLimit = positiveInteger(actionTimelineLimit, 'actionTimelineLimit');
|
|
62
|
+
this.actionTimelineTtlMs = positiveNumber(actionTimelineTtlMs, 'actionTimelineTtlMs');
|
|
63
|
+
this.maxTargets = positiveInteger(maxTargets, 'maxTargets');
|
|
64
|
+
this.inactiveTargetTtlMs = positiveNumber(inactiveTargetTtlMs, 'inactiveTargetTtlMs');
|
|
65
|
+
this.running = false;
|
|
66
|
+
this.targets = new Map();
|
|
67
|
+
this.deviceLogs = new Map();
|
|
68
|
+
this.retiredDeviceLogDrains = new Set();
|
|
69
|
+
this.targetEvictions = 0;
|
|
70
|
+
this.targetExpirations = 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
register(command, args = {}) {
|
|
74
|
+
const target = observationTargetFor(command, args);
|
|
75
|
+
if (!target) {
|
|
76
|
+
return { registered: false, ignored: true, reason: 'observable_target_required' };
|
|
77
|
+
}
|
|
78
|
+
const now = this.clock();
|
|
79
|
+
this.pruneInactiveTargets(now);
|
|
80
|
+
const existing = this.targets.get(target.key);
|
|
81
|
+
if (existing) {
|
|
82
|
+
existing.args = { ...existing.args, ...target.args };
|
|
83
|
+
if (target.deviceLogScope === 'device') {
|
|
84
|
+
existing.deviceLogScope = 'device';
|
|
85
|
+
existing.deviceLogBuffers = orderedDeviceLogBuffers([
|
|
86
|
+
...existing.deviceLogBuffers,
|
|
87
|
+
...target.deviceLogBuffers,
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
existing.lastRegisteredAtMs = now;
|
|
91
|
+
existing.updatedAtMs = now;
|
|
92
|
+
this.registerDeviceLogTarget(existing);
|
|
93
|
+
return { registered: false, target: publicTarget(existing) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
while (this.targets.size >= this.maxTargets) {
|
|
97
|
+
const leastRecentlyUsed = [...this.targets.values()].sort(compareTargetActivity)[0];
|
|
98
|
+
if (!leastRecentlyUsed || !this.removeTarget(leastRecentlyUsed, 'evicted')) break;
|
|
99
|
+
}
|
|
100
|
+
const state = {
|
|
101
|
+
...target,
|
|
102
|
+
registeredAtMs: now,
|
|
103
|
+
lastRegisteredAtMs: now,
|
|
104
|
+
updatedAtMs: now,
|
|
105
|
+
lastStatusAtMs: null,
|
|
106
|
+
lastPollAtMs: null,
|
|
107
|
+
lastSuccessAtMs: null,
|
|
108
|
+
nextPollAtMs: null,
|
|
109
|
+
runtimeEpoch: null,
|
|
110
|
+
generationChanges: 0,
|
|
111
|
+
failureCount: 0,
|
|
112
|
+
lastError: null,
|
|
113
|
+
lastActionId: null,
|
|
114
|
+
lastActionAtMs: null,
|
|
115
|
+
actionTimeline: [],
|
|
116
|
+
pollTimer: null,
|
|
117
|
+
expiryTimer: null,
|
|
118
|
+
polling: false,
|
|
119
|
+
};
|
|
120
|
+
this.targets.set(state.key, state);
|
|
121
|
+
this.registerDeviceLogTarget(state);
|
|
122
|
+
if (this.running) this.startTarget(state);
|
|
123
|
+
return { registered: true, target: publicTarget(state) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
noteAction(target, actionId, timings = {}) {
|
|
127
|
+
const key = targetKeyValue(target);
|
|
128
|
+
const state = key ? this.targets.get(key) : null;
|
|
129
|
+
if (!state) return false;
|
|
130
|
+
const now = this.clock();
|
|
131
|
+
const normalizedActionId = actionId === undefined || actionId === null ? null : String(actionId);
|
|
132
|
+
state.lastActionId = normalizedActionId;
|
|
133
|
+
state.lastActionAtMs = now;
|
|
134
|
+
state.lastRegisteredAtMs = now;
|
|
135
|
+
state.updatedAtMs = now;
|
|
136
|
+
if (normalizedActionId === null) return true;
|
|
137
|
+
|
|
138
|
+
let action = state.actionTimeline.find((candidate) => candidate.actionId === normalizedActionId);
|
|
139
|
+
if (!action) {
|
|
140
|
+
action = {
|
|
141
|
+
actionId: normalizedActionId,
|
|
142
|
+
requestedAtMs: null,
|
|
143
|
+
startedAtMs: null,
|
|
144
|
+
completedAtMs: null,
|
|
145
|
+
touchedAtMs: now,
|
|
146
|
+
};
|
|
147
|
+
state.actionTimeline.push(action);
|
|
148
|
+
}
|
|
149
|
+
for (const field of ['requestedAtMs', 'startedAtMs', 'completedAtMs']) {
|
|
150
|
+
const value = optionalTimestamp(timings?.[field]);
|
|
151
|
+
if (value !== null) action[field] = value;
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
action.requestedAtMs === null
|
|
155
|
+
&& action.startedAtMs === null
|
|
156
|
+
&& action.completedAtMs === null
|
|
157
|
+
) {
|
|
158
|
+
action.requestedAtMs = now;
|
|
159
|
+
}
|
|
160
|
+
action.touchedAtMs = now;
|
|
161
|
+
this.pruneActionTimeline(state, now);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
start() {
|
|
166
|
+
if (this.running) return this.status();
|
|
167
|
+
this.running = true;
|
|
168
|
+
for (const target of this.targets.values()) {
|
|
169
|
+
this.startTarget(target);
|
|
170
|
+
}
|
|
171
|
+
return this.status();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async stop() {
|
|
175
|
+
this.running = false;
|
|
176
|
+
for (const target of this.targets.values()) {
|
|
177
|
+
if (target.pollTimer !== null) this.timers.clearTimeout(target.pollTimer);
|
|
178
|
+
if (target.expiryTimer !== null) this.timers.clearTimeout(target.expiryTimer);
|
|
179
|
+
target.pollTimer = null;
|
|
180
|
+
target.expiryTimer = null;
|
|
181
|
+
target.nextPollAtMs = null;
|
|
182
|
+
}
|
|
183
|
+
for (const deviceLog of this.deviceLogs.values()) {
|
|
184
|
+
if (deviceLog.flushTimer !== null) this.timers.clearTimeout(deviceLog.flushTimer);
|
|
185
|
+
if (deviceLog.restartTimer !== null) this.timers.clearTimeout(deviceLog.restartTimer);
|
|
186
|
+
deviceLog.flushTimer = null;
|
|
187
|
+
deviceLog.restartTimer = null;
|
|
188
|
+
if (deviceLog.partialLine) {
|
|
189
|
+
this.enqueueDeviceLogLine(deviceLog, deviceLog.partialLine);
|
|
190
|
+
deviceLog.partialLine = '';
|
|
191
|
+
}
|
|
192
|
+
while (deviceLog.lines.length > 0) this.flushDeviceLog(deviceLog, false);
|
|
193
|
+
if (deviceLog.child && typeof deviceLog.child.kill === 'function') deviceLog.child.kill();
|
|
194
|
+
deviceLog.child = null;
|
|
195
|
+
}
|
|
196
|
+
await Promise.allSettled([
|
|
197
|
+
...[...this.deviceLogs.values()].map((deviceLog) => deviceLog.drainPromise).filter(Boolean),
|
|
198
|
+
...this.retiredDeviceLogDrains,
|
|
199
|
+
]);
|
|
200
|
+
return this.status();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
status() {
|
|
204
|
+
return {
|
|
205
|
+
running: this.running,
|
|
206
|
+
targetCount: this.targets.size,
|
|
207
|
+
maxTargets: this.maxTargets,
|
|
208
|
+
inactiveTargetTtlMs: this.inactiveTargetTtlMs,
|
|
209
|
+
targetEvictions: this.targetEvictions,
|
|
210
|
+
targetExpirations: this.targetExpirations,
|
|
211
|
+
targets: [...this.targets.values()].map((target) => {
|
|
212
|
+
const actionTimeline = this.actionTimelineSnapshot(target);
|
|
213
|
+
return {
|
|
214
|
+
...publicTarget(target),
|
|
215
|
+
...(target.kind === 'android' ? { deviceLog: this.targetDeviceLogStatus(target) } : {}),
|
|
216
|
+
runtimeEpoch: target.runtimeEpoch,
|
|
217
|
+
failureCount: target.failureCount,
|
|
218
|
+
lastError: target.lastError,
|
|
219
|
+
lastActionId: target.lastActionId,
|
|
220
|
+
actionTimeline,
|
|
221
|
+
backgroundPolling: target.kind !== 'android',
|
|
222
|
+
expiresAtMs: target.lastRegisteredAtMs + this.inactiveTargetTtlMs,
|
|
223
|
+
lastPollAtMs: target.lastPollAtMs,
|
|
224
|
+
lastSuccessAtMs: target.lastSuccessAtMs,
|
|
225
|
+
nextPollAtMs: target.nextPollAtMs,
|
|
226
|
+
generationChanges: target.generationChanges,
|
|
227
|
+
};
|
|
228
|
+
}),
|
|
229
|
+
deviceLogs: [...this.deviceLogs.values()].map((deviceLog) => ({
|
|
230
|
+
serial: deviceLog.serial,
|
|
231
|
+
buffers: [...deviceLog.buffers],
|
|
232
|
+
running: Boolean(deviceLog.child),
|
|
233
|
+
targetKeys: [...deviceLog.targetKeys],
|
|
234
|
+
queuedLines: deviceLog.lines.length,
|
|
235
|
+
queuedBytes: deviceLog.queuedBytes,
|
|
236
|
+
partialBytes: Buffer.byteLength(deviceLog.partialLine, 'utf8'),
|
|
237
|
+
pendingBatches: deviceLog.pendingBatches.length,
|
|
238
|
+
droppedLines: deviceLog.droppedLines,
|
|
239
|
+
droppedBytes: deviceLog.droppedBytes,
|
|
240
|
+
droppedBatches: deviceLog.droppedBatches,
|
|
241
|
+
restartFailures: deviceLog.restartFailures,
|
|
242
|
+
nextRestartAtMs: deviceLog.nextRestartAtMs,
|
|
243
|
+
lastError: deviceLog.lastError,
|
|
244
|
+
})),
|
|
245
|
+
dropped: {
|
|
246
|
+
deviceLogLines: sumDeviceLogMetric(this.deviceLogs, 'droppedLines'),
|
|
247
|
+
deviceLogBytes: sumDeviceLogMetric(this.deviceLogs, 'droppedBytes'),
|
|
248
|
+
deviceLogBatches: sumDeviceLogMetric(this.deviceLogs, 'droppedBatches'),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
targetDeviceLogStatus(target) {
|
|
254
|
+
if (target.deviceLogScope !== 'device') {
|
|
255
|
+
return { enabled: false, reason: 'device_scope_opt_in_required' };
|
|
256
|
+
}
|
|
257
|
+
if (!target.serial) return { enabled: false, reason: 'serial_required' };
|
|
258
|
+
const deviceLog = this.deviceLogs.get(target.serial);
|
|
259
|
+
if (!deviceLog) return { enabled: false, reason: 'not_registered' };
|
|
260
|
+
return {
|
|
261
|
+
enabled: true,
|
|
262
|
+
running: Boolean(deviceLog.child),
|
|
263
|
+
serial: target.serial,
|
|
264
|
+
buffers: [...deviceLog.buffers],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
startTarget(target) {
|
|
269
|
+
if (target.kind === 'android') {
|
|
270
|
+
// Android evidence is read explicitly. An unsolicited SDK connection can
|
|
271
|
+
// block ADB while a background app is being launched; TTL needs no I/O.
|
|
272
|
+
this.scheduleTargetExpiry(target);
|
|
273
|
+
} else {
|
|
274
|
+
this.schedulePoll(target, 0);
|
|
275
|
+
}
|
|
276
|
+
this.startDeviceLogForTarget(target);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
scheduleTargetExpiry(target) {
|
|
280
|
+
if (!this.running || target.expiryTimer !== null || this.targets.get(target.key) !== target) return;
|
|
281
|
+
const delayMs = Math.max(0, target.lastRegisteredAtMs + this.inactiveTargetTtlMs - this.clock());
|
|
282
|
+
target.expiryTimer = this.timers.setTimeout(() => {
|
|
283
|
+
target.expiryTimer = null;
|
|
284
|
+
if (!this.running || this.targets.get(target.key) !== target) return;
|
|
285
|
+
if (this.targetExpired(target)) this.removeTarget(target, 'expired');
|
|
286
|
+
else this.scheduleTargetExpiry(target);
|
|
287
|
+
}, delayMs);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
schedulePoll(target, delayMs) {
|
|
291
|
+
if (
|
|
292
|
+
!this.running
|
|
293
|
+
|| target.pollTimer !== null
|
|
294
|
+
|| this.targets.get(target.key) !== target
|
|
295
|
+
) return;
|
|
296
|
+
target.nextPollAtMs = this.clock() + delayMs;
|
|
297
|
+
target.pollTimer = this.timers.setTimeout(() => {
|
|
298
|
+
target.pollTimer = null;
|
|
299
|
+
target.nextPollAtMs = null;
|
|
300
|
+
void this.pollTarget(target);
|
|
301
|
+
}, delayMs);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async pollTarget(target) {
|
|
305
|
+
if (!this.running || target.polling || this.targets.get(target.key) !== target) return;
|
|
306
|
+
if (this.targetExpired(target, this.clock())) {
|
|
307
|
+
this.removeTarget(target, 'expired');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
target.polling = true;
|
|
311
|
+
target.lastPollAtMs = this.clock();
|
|
312
|
+
try {
|
|
313
|
+
if (target.lastStatusAtMs === null || this.clock() - target.lastStatusAtMs >= this.statusIntervalMs) {
|
|
314
|
+
await this.pullStatus(target);
|
|
315
|
+
}
|
|
316
|
+
target.failureCount = 0;
|
|
317
|
+
target.lastError = null;
|
|
318
|
+
target.lastSuccessAtMs = this.clock();
|
|
319
|
+
target.updatedAtMs = this.clock();
|
|
320
|
+
this.schedulePoll(target, this.pollIntervalMs);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
target.failureCount += 1;
|
|
323
|
+
target.lastError = firstErrorLine(error);
|
|
324
|
+
target.updatedAtMs = this.clock();
|
|
325
|
+
const delayMs = Math.min(
|
|
326
|
+
this.maxBackoffMs,
|
|
327
|
+
this.initialBackoffMs * (2 ** Math.max(0, target.failureCount - 1)),
|
|
328
|
+
);
|
|
329
|
+
this.schedulePoll(target, delayMs);
|
|
330
|
+
} finally {
|
|
331
|
+
target.polling = false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async pullStatus(target) {
|
|
336
|
+
const command = commandFor(target.kind, 'status');
|
|
337
|
+
const result = await this.rawRunner(command, { ...target.args });
|
|
338
|
+
assertSuccessfulResult(command, result);
|
|
339
|
+
const runtimeEpoch = runtimeEpochFor(target.kind, result);
|
|
340
|
+
const changed = target.runtimeEpoch !== null
|
|
341
|
+
&& runtimeEpoch !== null
|
|
342
|
+
&& target.runtimeEpoch !== runtimeEpoch;
|
|
343
|
+
if (changed) {
|
|
344
|
+
target.generationChanges += 1;
|
|
345
|
+
}
|
|
346
|
+
if (runtimeEpoch !== null) target.runtimeEpoch = runtimeEpoch;
|
|
347
|
+
target.lastStatusAtMs = this.clock();
|
|
348
|
+
await this.recordEvidence(command, { ...target.args }, result, this.evidenceContext(target, {
|
|
349
|
+
generationChanged: changed,
|
|
350
|
+
sinceId: null,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
evidenceContext(target, extra = {}) {
|
|
355
|
+
const observedAtMs = this.clock();
|
|
356
|
+
return {
|
|
357
|
+
collector: 'observation',
|
|
358
|
+
target: publicTarget(target),
|
|
359
|
+
targetKey: target.key,
|
|
360
|
+
runtimeEpoch: target.runtimeEpoch,
|
|
361
|
+
actionId: target.lastActionId,
|
|
362
|
+
actionTimeline: this.actionTimelineSnapshot(target, observedAtMs),
|
|
363
|
+
observedAtMs,
|
|
364
|
+
ingestedAtMs: observedAtMs,
|
|
365
|
+
...extra,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
pruneActionTimeline(target, now = this.clock()) {
|
|
370
|
+
const oldestAllowedAtMs = now - this.actionTimelineTtlMs;
|
|
371
|
+
target.actionTimeline = target.actionTimeline.filter((action) => action.touchedAtMs >= oldestAllowedAtMs);
|
|
372
|
+
if (target.actionTimeline.length > this.actionTimelineLimit) {
|
|
373
|
+
target.actionTimeline.splice(0, target.actionTimeline.length - this.actionTimelineLimit);
|
|
374
|
+
}
|
|
375
|
+
if (
|
|
376
|
+
target.lastActionId !== null
|
|
377
|
+
&& !target.actionTimeline.some((action) => action.actionId === target.lastActionId)
|
|
378
|
+
) {
|
|
379
|
+
target.lastActionId = null;
|
|
380
|
+
target.lastActionAtMs = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
actionTimelineSnapshot(target, now = this.clock()) {
|
|
385
|
+
this.pruneActionTimeline(target, now);
|
|
386
|
+
return target.actionTimeline.map(publicActionTiming);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
actionTimelineForTargets(targetKeys, now = this.clock()) {
|
|
390
|
+
return targetKeys.flatMap((key) => {
|
|
391
|
+
const target = this.targets.get(key);
|
|
392
|
+
return target ? this.actionTimelineSnapshot(target, now) : [];
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
targetExpired(target, now = this.clock()) {
|
|
397
|
+
return now - target.lastRegisteredAtMs >= this.inactiveTargetTtlMs;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
pruneInactiveTargets(now = this.clock()) {
|
|
401
|
+
for (const target of [...this.targets.values()]) {
|
|
402
|
+
if (this.targetExpired(target, now)) this.removeTarget(target, 'expired');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
removeTarget(targetOrKey, reason) {
|
|
407
|
+
const target = typeof targetOrKey === 'string'
|
|
408
|
+
? this.targets.get(targetOrKey)
|
|
409
|
+
: targetOrKey;
|
|
410
|
+
if (!target || this.targets.get(target.key) !== target) return false;
|
|
411
|
+
if (target.pollTimer !== null) this.timers.clearTimeout(target.pollTimer);
|
|
412
|
+
if (target.expiryTimer !== null) this.timers.clearTimeout(target.expiryTimer);
|
|
413
|
+
target.pollTimer = null;
|
|
414
|
+
target.expiryTimer = null;
|
|
415
|
+
target.nextPollAtMs = null;
|
|
416
|
+
this.targets.delete(target.key);
|
|
417
|
+
if (reason === 'evicted') this.targetEvictions += 1;
|
|
418
|
+
if (reason === 'expired') this.targetExpirations += 1;
|
|
419
|
+
if (target.kind === 'android' && target.serial) {
|
|
420
|
+
const deviceLog = this.deviceLogs.get(target.serial);
|
|
421
|
+
if (deviceLog) {
|
|
422
|
+
deviceLog.targetKeys.delete(target.key);
|
|
423
|
+
if (deviceLog.targetKeys.size === 0) this.retireDeviceLog(deviceLog);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
retireDeviceLog(deviceLog) {
|
|
430
|
+
if (deviceLog.flushTimer !== null) this.timers.clearTimeout(deviceLog.flushTimer);
|
|
431
|
+
if (deviceLog.restartTimer !== null) this.timers.clearTimeout(deviceLog.restartTimer);
|
|
432
|
+
deviceLog.flushTimer = null;
|
|
433
|
+
deviceLog.restartTimer = null;
|
|
434
|
+
deviceLog.nextRestartAtMs = null;
|
|
435
|
+
if (deviceLog.partialLine) {
|
|
436
|
+
this.enqueueDeviceLogLine(deviceLog, deviceLog.partialLine);
|
|
437
|
+
deviceLog.partialLine = '';
|
|
438
|
+
}
|
|
439
|
+
while (deviceLog.lines.length > 0) this.flushDeviceLog(deviceLog, false);
|
|
440
|
+
const drainPromise = deviceLog.drainPromise;
|
|
441
|
+
if (drainPromise) {
|
|
442
|
+
this.retiredDeviceLogDrains.add(drainPromise);
|
|
443
|
+
void drainPromise.then(
|
|
444
|
+
() => this.retiredDeviceLogDrains.delete(drainPromise),
|
|
445
|
+
() => this.retiredDeviceLogDrains.delete(drainPromise),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const child = deviceLog.child;
|
|
449
|
+
deviceLog.child = null;
|
|
450
|
+
if (child && typeof child.kill === 'function') child.kill();
|
|
451
|
+
this.deviceLogs.delete(deviceLog.serial);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
registerDeviceLogTarget(target) {
|
|
455
|
+
if (target.kind !== 'android' || target.deviceLogScope !== 'device' || !target.serial) return;
|
|
456
|
+
let deviceLog = this.deviceLogs.get(target.serial);
|
|
457
|
+
if (!deviceLog) {
|
|
458
|
+
deviceLog = {
|
|
459
|
+
serial: target.serial,
|
|
460
|
+
adbPath: target.args.adbPath || target.args.adb || 'adb',
|
|
461
|
+
buffers: [...defaultDeviceLogBuffers],
|
|
462
|
+
targetKeys: new Set(),
|
|
463
|
+
child: null,
|
|
464
|
+
partialLine: '',
|
|
465
|
+
lines: [],
|
|
466
|
+
queuedBytes: 0,
|
|
467
|
+
pendingBatches: [],
|
|
468
|
+
drainPromise: null,
|
|
469
|
+
flushTimer: null,
|
|
470
|
+
restartTimer: null,
|
|
471
|
+
restartFailures: 0,
|
|
472
|
+
nextRestartAtMs: null,
|
|
473
|
+
droppedLines: 0,
|
|
474
|
+
droppedBytes: 0,
|
|
475
|
+
droppedBatches: 0,
|
|
476
|
+
lastError: null,
|
|
477
|
+
};
|
|
478
|
+
this.deviceLogs.set(target.serial, deviceLog);
|
|
479
|
+
}
|
|
480
|
+
const previousBuffers = deviceLog.buffers.join(',');
|
|
481
|
+
deviceLog.targetKeys.add(target.key);
|
|
482
|
+
deviceLog.buffers = orderedDeviceLogBuffers([
|
|
483
|
+
...deviceLog.buffers,
|
|
484
|
+
...target.deviceLogBuffers,
|
|
485
|
+
]);
|
|
486
|
+
if (
|
|
487
|
+
this.running
|
|
488
|
+
&& deviceLog.child
|
|
489
|
+
&& previousBuffers !== deviceLog.buffers.join(',')
|
|
490
|
+
) {
|
|
491
|
+
const previousChild = deviceLog.child;
|
|
492
|
+
deviceLog.child = null;
|
|
493
|
+
if (typeof previousChild.kill === 'function') previousChild.kill();
|
|
494
|
+
deviceLog.restartFailures = 0;
|
|
495
|
+
this.startDeviceLogForTarget(target);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
startDeviceLogForTarget(target) {
|
|
500
|
+
if (target.kind !== 'android' || target.deviceLogScope !== 'device' || !target.serial) return;
|
|
501
|
+
const deviceLog = this.deviceLogs.get(target.serial);
|
|
502
|
+
if (!deviceLog || deviceLog.child) return;
|
|
503
|
+
const args = [
|
|
504
|
+
'-s', deviceLog.serial,
|
|
505
|
+
'logcat', '-v', 'epoch',
|
|
506
|
+
// Follow from the current tail instead of replaying the device's entire
|
|
507
|
+
// historical ring buffer into the persistent fact cache.
|
|
508
|
+
'-T', '1',
|
|
509
|
+
...deviceLog.buffers.flatMap((buffer) => ['-b', buffer]),
|
|
510
|
+
];
|
|
511
|
+
let child;
|
|
512
|
+
try {
|
|
513
|
+
child = this.spawn(deviceLog.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
514
|
+
} catch (error) {
|
|
515
|
+
deviceLog.lastError = firstErrorLine(error);
|
|
516
|
+
this.scheduleDeviceLogRestart(deviceLog);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (!child || !child.stdout || typeof child.stdout.on !== 'function') {
|
|
520
|
+
deviceLog.lastError = 'logcat spawn returned no readable stdout';
|
|
521
|
+
this.scheduleDeviceLogRestart(deviceLog);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
deviceLog.child = child;
|
|
525
|
+
deviceLog.nextRestartAtMs = null;
|
|
526
|
+
deviceLog.lastError = null;
|
|
527
|
+
if (typeof child.stdout.setEncoding === 'function') child.stdout.setEncoding('utf8');
|
|
528
|
+
if (child.stderr && typeof child.stderr.setEncoding === 'function') child.stderr.setEncoding('utf8');
|
|
529
|
+
child.stdout.on('data', (chunk) => this.acceptDeviceLogChunk(deviceLog, chunk));
|
|
530
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
531
|
+
const line = String(chunk || '').trim();
|
|
532
|
+
if (line) deviceLog.lastError = line.slice(0, 500);
|
|
533
|
+
});
|
|
534
|
+
child.on?.('error', (error) => {
|
|
535
|
+
deviceLog.lastError = firstErrorLine(error);
|
|
536
|
+
});
|
|
537
|
+
child.on?.('exit', (code, signal) => {
|
|
538
|
+
if (deviceLog.child !== child) return;
|
|
539
|
+
deviceLog.child = null;
|
|
540
|
+
if (!this.running) return;
|
|
541
|
+
deviceLog.lastError = `logcat exited (${code ?? 'null'}, ${signal || 'no signal'})`;
|
|
542
|
+
this.scheduleDeviceLogRestart(deviceLog);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
scheduleDeviceLogRestart(deviceLog) {
|
|
547
|
+
if (!this.running || deviceLog.restartTimer !== null) return;
|
|
548
|
+
deviceLog.restartFailures += 1;
|
|
549
|
+
const delayMs = Math.min(
|
|
550
|
+
this.maxDeviceLogRestartMs,
|
|
551
|
+
this.deviceLogRestartMs * (2 ** Math.max(0, deviceLog.restartFailures - 1)),
|
|
552
|
+
);
|
|
553
|
+
deviceLog.nextRestartAtMs = this.clock() + delayMs;
|
|
554
|
+
deviceLog.restartTimer = this.timers.setTimeout(() => {
|
|
555
|
+
deviceLog.restartTimer = null;
|
|
556
|
+
deviceLog.nextRestartAtMs = null;
|
|
557
|
+
const target = [...deviceLog.targetKeys]
|
|
558
|
+
.map((key) => this.targets.get(key))
|
|
559
|
+
.find(Boolean);
|
|
560
|
+
if (target) this.startDeviceLogForTarget(target);
|
|
561
|
+
}, delayMs);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
acceptDeviceLogChunk(deviceLog, chunk) {
|
|
565
|
+
if (!this.running) return;
|
|
566
|
+
deviceLog.restartFailures = 0;
|
|
567
|
+
const text = deviceLog.partialLine + String(chunk || '');
|
|
568
|
+
const parts = text.split(/\r?\n/);
|
|
569
|
+
deviceLog.partialLine = parts.pop() || '';
|
|
570
|
+
const partialBytes = Buffer.byteLength(deviceLog.partialLine, 'utf8');
|
|
571
|
+
if (partialBytes > this.deviceLogBatchBytes) {
|
|
572
|
+
const bounded = utf8Tail(deviceLog.partialLine, this.deviceLogBatchBytes);
|
|
573
|
+
deviceLog.droppedBytes += partialBytes - Buffer.byteLength(bounded, 'utf8');
|
|
574
|
+
deviceLog.partialLine = bounded;
|
|
575
|
+
}
|
|
576
|
+
for (const line of parts) {
|
|
577
|
+
if (line) this.enqueueDeviceLogLine(deviceLog, line);
|
|
578
|
+
}
|
|
579
|
+
if (deviceLog.lines.length >= this.deviceLogBatchLines || deviceLog.queuedBytes >= this.deviceLogBatchBytes) {
|
|
580
|
+
this.flushDeviceLog(deviceLog);
|
|
581
|
+
} else if (deviceLog.lines.length > 0 && deviceLog.flushTimer === null) {
|
|
582
|
+
deviceLog.flushTimer = this.timers.setTimeout(() => {
|
|
583
|
+
deviceLog.flushTimer = null;
|
|
584
|
+
this.flushDeviceLog(deviceLog);
|
|
585
|
+
}, this.deviceLogFlushMs);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
enqueueDeviceLogLine(deviceLog, line) {
|
|
590
|
+
const entry = { text: line, bytes: Buffer.byteLength(line, 'utf8') + 1 };
|
|
591
|
+
deviceLog.lines.push(entry);
|
|
592
|
+
deviceLog.queuedBytes += entry.bytes;
|
|
593
|
+
while (
|
|
594
|
+
deviceLog.lines.length > this.maxDeviceLogQueueLines
|
|
595
|
+
|| deviceLog.queuedBytes > this.maxDeviceLogQueueBytes
|
|
596
|
+
) {
|
|
597
|
+
const dropped = deviceLog.lines.shift();
|
|
598
|
+
if (!dropped) break;
|
|
599
|
+
deviceLog.queuedBytes -= dropped.bytes;
|
|
600
|
+
deviceLog.droppedLines += 1;
|
|
601
|
+
deviceLog.droppedBytes += dropped.bytes;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
flushDeviceLog(deviceLog, scheduleRemaining = true) {
|
|
606
|
+
if (deviceLog.flushTimer !== null) {
|
|
607
|
+
this.timers.clearTimeout(deviceLog.flushTimer);
|
|
608
|
+
deviceLog.flushTimer = null;
|
|
609
|
+
}
|
|
610
|
+
if (deviceLog.lines.length === 0) return;
|
|
611
|
+
const lines = [];
|
|
612
|
+
let bytes = 0;
|
|
613
|
+
while (deviceLog.lines.length > 0 && lines.length < this.deviceLogBatchLines) {
|
|
614
|
+
const next = deviceLog.lines[0];
|
|
615
|
+
if (lines.length > 0 && bytes + next.bytes > this.deviceLogBatchBytes) break;
|
|
616
|
+
deviceLog.lines.shift();
|
|
617
|
+
deviceLog.queuedBytes -= next.bytes;
|
|
618
|
+
lines.push(next.text);
|
|
619
|
+
bytes += next.bytes;
|
|
620
|
+
}
|
|
621
|
+
if (deviceLog.pendingBatches.length >= this.maxDeviceLogPendingBatches) {
|
|
622
|
+
deviceLog.droppedBatches += 1;
|
|
623
|
+
deviceLog.droppedLines += lines.length;
|
|
624
|
+
deviceLog.droppedBytes += bytes;
|
|
625
|
+
} else {
|
|
626
|
+
deviceLog.pendingBatches.push({ lines, bytes });
|
|
627
|
+
this.drainDeviceLog(deviceLog);
|
|
628
|
+
}
|
|
629
|
+
if (scheduleRemaining && deviceLog.lines.length > 0 && deviceLog.flushTimer === null) {
|
|
630
|
+
deviceLog.flushTimer = this.timers.setTimeout(() => {
|
|
631
|
+
deviceLog.flushTimer = null;
|
|
632
|
+
this.flushDeviceLog(deviceLog);
|
|
633
|
+
}, 0);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
drainDeviceLog(deviceLog) {
|
|
638
|
+
if (deviceLog.drainPromise) return;
|
|
639
|
+
deviceLog.drainPromise = (async () => {
|
|
640
|
+
while (deviceLog.pendingBatches.length > 0) {
|
|
641
|
+
const batch = deviceLog.pendingBatches.shift();
|
|
642
|
+
const targetKeys = [...deviceLog.targetKeys];
|
|
643
|
+
const observedAtMs = this.clock();
|
|
644
|
+
const actionTimeline = this.actionTimelineForTargets(targetKeys, observedAtMs);
|
|
645
|
+
const actionId = latestActionIdFromTimeline(actionTimeline);
|
|
646
|
+
const buffers = [...deviceLog.buffers];
|
|
647
|
+
try {
|
|
648
|
+
await this.recordDeviceLog({
|
|
649
|
+
serial: deviceLog.serial,
|
|
650
|
+
deviceLogScope: 'device',
|
|
651
|
+
buffers,
|
|
652
|
+
}, {
|
|
653
|
+
lines: batch.lines,
|
|
654
|
+
count: batch.lines.length,
|
|
655
|
+
byteCount: batch.bytes,
|
|
656
|
+
observedAtMs,
|
|
657
|
+
buffers,
|
|
658
|
+
dropped: deviceLog.droppedLines,
|
|
659
|
+
}, {
|
|
660
|
+
collector: 'observation',
|
|
661
|
+
provider: 'android',
|
|
662
|
+
serial: deviceLog.serial,
|
|
663
|
+
targetKeys,
|
|
664
|
+
actionId,
|
|
665
|
+
actionTimeline,
|
|
666
|
+
runtimeEpoch: latestRuntimeEpoch(targetKeys, this.targets),
|
|
667
|
+
lineCount: batch.lines.length,
|
|
668
|
+
byteCount: batch.bytes,
|
|
669
|
+
observedAtMs,
|
|
670
|
+
ingestedAtMs: this.clock(),
|
|
671
|
+
droppedLines: deviceLog.droppedLines,
|
|
672
|
+
droppedBytes: deviceLog.droppedBytes,
|
|
673
|
+
droppedBatches: deviceLog.droppedBatches,
|
|
674
|
+
});
|
|
675
|
+
} catch (error) {
|
|
676
|
+
deviceLog.lastError = firstErrorLine(error);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
})().finally(() => {
|
|
680
|
+
deviceLog.drainPromise = null;
|
|
681
|
+
if (deviceLog.pendingBatches.length > 0) this.drainDeviceLog(deviceLog);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function observationTargetFor(command, args = {}) {
|
|
687
|
+
const name = String(command || '');
|
|
688
|
+
if (name.startsWith('ios-')) {
|
|
689
|
+
const deviceId = stringPart(args.deviceId);
|
|
690
|
+
const bundleId = stringPart(args.bundleId);
|
|
691
|
+
// A device id alone identifies hardware, not an observable app runtime.
|
|
692
|
+
if (!bundleId && !args.runtimeUrl) return null;
|
|
693
|
+
return {
|
|
694
|
+
kind: 'ios',
|
|
695
|
+
key: identityKey('ios', deviceId, bundleId),
|
|
696
|
+
args: pickDefined(args, ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl']),
|
|
697
|
+
deviceId,
|
|
698
|
+
bundleId,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
if (name.startsWith('web-')) {
|
|
702
|
+
// Web ingress commits authoritative facts before its SDK acknowledgement.
|
|
703
|
+
// Pulling those facts into a second observer would duplicate evidence and
|
|
704
|
+
// infer action ownership from whichever command happened to run last.
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
const packageName = stringPart(args.packageName);
|
|
708
|
+
if (!packageName) return null;
|
|
709
|
+
const serial = stringPart(args.serial);
|
|
710
|
+
return {
|
|
711
|
+
kind: 'android',
|
|
712
|
+
key: identityKey('android', serial, packageName),
|
|
713
|
+
args: pickDefined(args, ['serial', 'packageName', 'port', 'adb', 'adbPath']),
|
|
714
|
+
serial,
|
|
715
|
+
packageName,
|
|
716
|
+
deviceLogScope: normalizeDeviceLogScope(args.deviceLogScope),
|
|
717
|
+
deviceLogBuffers: normalizeDeviceLogBuffers(args.deviceLogBuffers),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function publicTarget(target) {
|
|
722
|
+
const base = { kind: target.kind, key: target.key };
|
|
723
|
+
if (target.kind === 'android') return { ...base, serial: target.serial, packageName: target.packageName };
|
|
724
|
+
return { ...base, deviceId: target.deviceId, bundleId: target.bundleId };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function targetKeyValue(target) {
|
|
728
|
+
if (typeof target === 'string') return target;
|
|
729
|
+
return target && typeof target === 'object' ? target.key : null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function commandFor(kind, stream) {
|
|
733
|
+
if (kind === 'android') return stream;
|
|
734
|
+
return `${kind}-${stream}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function runtimeEpochFor(kind, result) {
|
|
738
|
+
const value = result?.debugBridge?.runtimeEpoch;
|
|
739
|
+
return value === undefined || value === null || value === '' ? null : String(value);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function assertSuccessfulResult(command, result) {
|
|
743
|
+
if (result && typeof result === 'object' && result.ok === false) {
|
|
744
|
+
throw new Error(`${command}: ${result.error || result.message || 'failed'}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function identityKey(kind, first, second) {
|
|
749
|
+
return `${kind}:${JSON.stringify([first, second])}`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function stringPart(value) {
|
|
753
|
+
return value === undefined || value === null ? '' : String(value);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function pickDefined(source, keys) {
|
|
757
|
+
const result = {};
|
|
758
|
+
for (const key of keys) {
|
|
759
|
+
if (source[key] !== undefined && source[key] !== null && source[key] !== '') result[key] = source[key];
|
|
760
|
+
}
|
|
761
|
+
return result;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function positiveNumber(value, name) {
|
|
765
|
+
const number = Number(value);
|
|
766
|
+
if (!Number.isFinite(number) || number <= 0) throw new TypeError(`${name} must be a positive number`);
|
|
767
|
+
return number;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function positiveInteger(value, name) {
|
|
771
|
+
const number = Number(value);
|
|
772
|
+
if (!Number.isInteger(number) || number <= 0) throw new TypeError(`${name} must be a positive integer`);
|
|
773
|
+
return number;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function optionalTimestamp(value) {
|
|
777
|
+
if (value === undefined || value === null || value === '') return null;
|
|
778
|
+
const number = Number(value);
|
|
779
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function publicActionTiming(action) {
|
|
783
|
+
return {
|
|
784
|
+
actionId: action.actionId,
|
|
785
|
+
requestedAtMs: action.requestedAtMs,
|
|
786
|
+
startedAtMs: action.startedAtMs,
|
|
787
|
+
completedAtMs: action.completedAtMs,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function compareTargetActivity(left, right) {
|
|
792
|
+
return left.lastRegisteredAtMs - right.lastRegisteredAtMs
|
|
793
|
+
|| left.registeredAtMs - right.registeredAtMs
|
|
794
|
+
|| left.key.localeCompare(right.key);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function firstErrorLine(error) {
|
|
798
|
+
return String(error?.message || error || 'unknown error').split(/\r?\n/, 1)[0];
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function normalizeDeviceLogBuffers(value) {
|
|
802
|
+
const requested = Array.isArray(value)
|
|
803
|
+
? value
|
|
804
|
+
: String(value || '').split(',');
|
|
805
|
+
return orderedDeviceLogBuffers([
|
|
806
|
+
...defaultDeviceLogBuffers,
|
|
807
|
+
...requested.map((entry) => String(entry).trim()).filter(Boolean),
|
|
808
|
+
]);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function normalizeDeviceLogScope(value) {
|
|
812
|
+
return String(value || '').trim().toLowerCase() === 'device' ? 'device' : null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function orderedDeviceLogBuffers(values) {
|
|
816
|
+
const unique = new Set(values.filter((value) => allowedDeviceLogBuffers.has(value)));
|
|
817
|
+
return [...defaultDeviceLogBuffers, ...['radio', 'security', 'kernel']]
|
|
818
|
+
.filter((value) => unique.has(value));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function latestActionIdFromTimeline(actionTimeline) {
|
|
822
|
+
let selected = null;
|
|
823
|
+
let selectedAtMs = Number.NEGATIVE_INFINITY;
|
|
824
|
+
for (const action of actionTimeline) {
|
|
825
|
+
const atMs = action.completedAtMs ?? action.startedAtMs ?? action.requestedAtMs;
|
|
826
|
+
if (atMs === null || atMs < selectedAtMs) continue;
|
|
827
|
+
selected = action.actionId;
|
|
828
|
+
selectedAtMs = atMs;
|
|
829
|
+
}
|
|
830
|
+
return selected;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function latestRuntimeEpoch(targetKeys, targets) {
|
|
834
|
+
let selected = null;
|
|
835
|
+
let selectedAtMs = Number.NEGATIVE_INFINITY;
|
|
836
|
+
for (const key of targetKeys) {
|
|
837
|
+
const target = targets.get(key);
|
|
838
|
+
if (!target || target.runtimeEpoch === null || target.lastSuccessAtMs < selectedAtMs) continue;
|
|
839
|
+
selected = target.runtimeEpoch;
|
|
840
|
+
selectedAtMs = target.lastSuccessAtMs;
|
|
841
|
+
}
|
|
842
|
+
return selected;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function sumDeviceLogMetric(deviceLogs, name) {
|
|
846
|
+
let total = 0;
|
|
847
|
+
for (const deviceLog of deviceLogs.values()) total += deviceLog[name];
|
|
848
|
+
return total;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function utf8Tail(value, maxBytes) {
|
|
852
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
853
|
+
if (bytes.length <= maxBytes) return value;
|
|
854
|
+
let start = bytes.length - maxBytes;
|
|
855
|
+
while (start < bytes.length && (bytes[start] & 0xC0) === 0x80) start += 1;
|
|
856
|
+
return bytes.subarray(start).toString('utf8');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
module.exports = {
|
|
860
|
+
ObservationCollector,
|
|
861
|
+
observationTargetFor,
|
|
862
|
+
};
|