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/interact/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { currentReadinessWait, observeReadinessWait, isDeviceUnavailable } from '../observe/readiness.js';
|
|
2
|
+
import { readinessDelay } from '../observe/readiness-service.js';
|
|
3
|
+
import { isConditionBlocked } from './conditional-action.js';
|
|
1
4
|
import { remainingBudget } from '../utils/operation-budget.js';
|
|
2
5
|
import { createHash } from 'crypto';
|
|
3
6
|
import { AndroidInteract } from './android.js';
|
|
@@ -419,6 +422,8 @@ export class ToolsInteract {
|
|
|
419
422
|
}
|
|
420
423
|
}
|
|
421
424
|
static async _captureFingerprint(platform, deviceId) {
|
|
425
|
+
if (isConditionBlocked())
|
|
426
|
+
return null;
|
|
422
427
|
try {
|
|
423
428
|
const fingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
424
429
|
return fingerprint?.fingerprint ?? null;
|
|
@@ -2068,7 +2073,8 @@ export class ToolsInteract {
|
|
|
2068
2073
|
};
|
|
2069
2074
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution };
|
|
2070
2075
|
}
|
|
2071
|
-
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }) {
|
|
2076
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false, resolveTapTarget = false }) {
|
|
2077
|
+
const readinessTracker = currentReadinessWait();
|
|
2072
2078
|
const overallStart = Date.now();
|
|
2073
2079
|
const requestedIndex = typeof match?.index === 'number' ? match.index : null;
|
|
2074
2080
|
const requested = {
|
|
@@ -2127,7 +2133,12 @@ export class ToolsInteract {
|
|
|
2127
2133
|
remainingBudget();
|
|
2128
2134
|
totalPollCount++;
|
|
2129
2135
|
try {
|
|
2136
|
+
const acquisitionStarted = Date.now();
|
|
2130
2137
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
2138
|
+
observeReadinessWait(tree, acquisitionStarted);
|
|
2139
|
+
// A failed acquisition cannot establish that the requested tap target is absent.
|
|
2140
|
+
if (resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error)))
|
|
2141
|
+
throw new Error('ACTION_TIMEOUT');
|
|
2131
2142
|
const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
|
|
2132
2143
|
const matches = [];
|
|
2133
2144
|
for (let i = 0; i < elements.length; i++) {
|
|
@@ -2213,7 +2224,8 @@ export class ToolsInteract {
|
|
|
2213
2224
|
conditionMet = false;
|
|
2214
2225
|
}
|
|
2215
2226
|
else if (condition === 'clickable') {
|
|
2216
|
-
matchedElement = chosen ? (ToolsInteract.
|
|
2227
|
+
matchedElement = chosen ? (resolveTapTarget && (!ToolsInteract._isVisibleElement(chosen.el) || chosen.el.enabled === false)
|
|
2228
|
+
? chosen : ToolsInteract._resolveActionableAncestor(elements, chosen) || chosen) : null;
|
|
2217
2229
|
if (matchedElement) {
|
|
2218
2230
|
const b = matchedElement.el.bounds;
|
|
2219
2231
|
const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
|
|
@@ -2224,6 +2236,13 @@ export class ToolsInteract {
|
|
|
2224
2236
|
else
|
|
2225
2237
|
conditionMet = false;
|
|
2226
2238
|
}
|
|
2239
|
+
// Waiting for a label to exist/be visible is distinct from resolving its
|
|
2240
|
+
// executable control. Reuse the clickable-wait ancestor policy without
|
|
2241
|
+
// promoting hidden or disabled labels into an unrelated enabled target.
|
|
2242
|
+
if (resolveTapTarget && conditionMet && matchedElement &&
|
|
2243
|
+
ToolsInteract._isVisibleElement(matchedElement.el) && matchedElement.el.enabled !== false) {
|
|
2244
|
+
matchedElement = ToolsInteract._resolveActionableAncestor(elements, matchedElement) ?? matchedElement;
|
|
2245
|
+
}
|
|
2227
2246
|
const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
|
|
2228
2247
|
const resolvedDeviceId = tree?.device?.id || deviceId;
|
|
2229
2248
|
lastMatchedCount = matchedCount;
|
|
@@ -2236,7 +2255,7 @@ export class ToolsInteract {
|
|
|
2236
2255
|
if (matchedAt === null)
|
|
2237
2256
|
matchedAt = now;
|
|
2238
2257
|
stableMatchCount++;
|
|
2239
|
-
if (stableMatchCount >= stableObservationCount) {
|
|
2258
|
+
if (stableMatchCount >= stableObservationCount && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
|
|
2240
2259
|
const latency_ms = now - overallStart;
|
|
2241
2260
|
const outEl = lastMatchedElement;
|
|
2242
2261
|
return {
|
|
@@ -2261,20 +2280,26 @@ export class ToolsInteract {
|
|
|
2261
2280
|
}
|
|
2262
2281
|
}
|
|
2263
2282
|
catch (e) {
|
|
2283
|
+
// Deadline expiry does not invalidate the last successfully observed state.
|
|
2264
2284
|
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
2265
2285
|
throw e;
|
|
2286
|
+
if (readinessTracker && !String(e).includes('READINESS_'))
|
|
2287
|
+
readinessTracker.unavailable();
|
|
2288
|
+
if (readinessTracker && isDeviceUnavailable(e))
|
|
2289
|
+
throw e;
|
|
2266
2290
|
// Non-fatal per-poll error; record and continue
|
|
2267
|
-
|
|
2291
|
+
if (!readinessTracker)
|
|
2292
|
+
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
|
|
2268
2293
|
}
|
|
2269
2294
|
if (singleAttempt)
|
|
2270
2295
|
break;
|
|
2271
2296
|
// Sleep until next poll
|
|
2272
|
-
await new Promise(r => setTimeout(r, effectivePoll || 50));
|
|
2297
|
+
await (readinessTracker ? readinessDelay(effectivePoll || 50) : new Promise(r => setTimeout(r, effectivePoll || 50)));
|
|
2273
2298
|
}
|
|
2274
2299
|
// Attempt timed out; if more attempts allowed, backoff then retry
|
|
2275
2300
|
if (attempts < maxAttempts) {
|
|
2276
2301
|
if (backoff > 0)
|
|
2277
|
-
await new Promise(r => setTimeout(r, backoff));
|
|
2302
|
+
await (readinessTracker ? readinessDelay(backoff) : new Promise(r => setTimeout(r, backoff)));
|
|
2278
2303
|
continue;
|
|
2279
2304
|
}
|
|
2280
2305
|
// Final failure for this call
|
|
@@ -2290,7 +2315,7 @@ export class ToolsInteract {
|
|
|
2290
2315
|
: ` observed ${lastMatchedCount} match(es)`;
|
|
2291
2316
|
return {
|
|
2292
2317
|
status: 'timeout',
|
|
2293
|
-
error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
|
|
2318
|
+
error: { code: readinessTracker ? 'TIMEOUT' : 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
|
|
2294
2319
|
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
|
|
2295
2320
|
requested,
|
|
2296
2321
|
observed
|
|
@@ -2298,17 +2323,19 @@ export class ToolsInteract {
|
|
|
2298
2323
|
}
|
|
2299
2324
|
}
|
|
2300
2325
|
catch (err) {
|
|
2301
|
-
if (err instanceof Error && err.message === 'ACTION_TIMEOUT')
|
|
2326
|
+
if (!readinessTracker && err instanceof Error && err.message === 'ACTION_TIMEOUT')
|
|
2327
|
+
throw err;
|
|
2328
|
+
if (readinessTracker && isDeviceUnavailable(err))
|
|
2302
2329
|
throw err;
|
|
2303
2330
|
const elapsed = Date.now() - overallStart;
|
|
2304
2331
|
return {
|
|
2305
2332
|
status: 'timeout',
|
|
2306
|
-
error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) },
|
|
2333
|
+
error: { code: readinessTracker && String(err).includes('ACTION_TIMEOUT') ? 'TIMEOUT' : 'INTERNAL_ERROR', message: readinessTracker ? 'Readiness predicate wait did not complete' : err instanceof Error ? err.message : String(err) },
|
|
2307
2334
|
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
|
|
2308
2335
|
requested,
|
|
2309
2336
|
observed: {
|
|
2310
2337
|
matched_count: lastMatchedCount,
|
|
2311
|
-
condition_satisfied: false,
|
|
2338
|
+
condition_satisfied: readinessTracker ? lastConditionSatisfied : false,
|
|
2312
2339
|
selected_index: lastMatchedElement?.index ?? null,
|
|
2313
2340
|
last_matched_element: lastMatchedElement
|
|
2314
2341
|
}
|
|
@@ -2393,6 +2420,7 @@ export class ToolsInteract {
|
|
|
2393
2420
|
};
|
|
2394
2421
|
}
|
|
2395
2422
|
static async waitForUIChangeHandler({ platform, deviceId, timeout_ms = 60000, stability_window_ms = 300, expected_change, scope = 'screen', target = null }) {
|
|
2423
|
+
const readinessTracker = currentReadinessWait();
|
|
2396
2424
|
const start = Date.now();
|
|
2397
2425
|
const pollIntervalMs = 300;
|
|
2398
2426
|
const stabilityWindow = Math.max(0, typeof stability_window_ms === 'number' ? stability_window_ms : 300);
|
|
@@ -2415,7 +2443,11 @@ export class ToolsInteract {
|
|
|
2415
2443
|
};
|
|
2416
2444
|
while (Date.now() - start < timeout_ms) {
|
|
2417
2445
|
try {
|
|
2446
|
+
if (readinessTracker)
|
|
2447
|
+
remainingBudget();
|
|
2448
|
+
const acquisitionStarted = Date.now();
|
|
2418
2449
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
2450
|
+
observeReadinessWait(tree, acquisitionStarted);
|
|
2419
2451
|
const scopedTree = ToolsInteract._resolveUiChangeScope(tree, scope, target);
|
|
2420
2452
|
if (scopedTree.error) {
|
|
2421
2453
|
lastScopeResolution = scopedTree.resolution;
|
|
@@ -2461,7 +2493,7 @@ export class ToolsInteract {
|
|
|
2461
2493
|
candidateSinceMs = Date.now();
|
|
2462
2494
|
}
|
|
2463
2495
|
const stableForMs = candidateSinceMs === null ? 0 : Date.now() - candidateSinceMs;
|
|
2464
|
-
if (stabilityWindow === 0 || stableForMs >= stabilityWindow) {
|
|
2496
|
+
if ((stabilityWindow === 0 || stableForMs >= stabilityWindow) && (!readinessTracker || readinessTracker.summary.state === 'settled')) {
|
|
2465
2497
|
return {
|
|
2466
2498
|
success: true,
|
|
2467
2499
|
observed_change: candidateObservedChange ?? observedChange,
|
|
@@ -2486,10 +2518,18 @@ export class ToolsInteract {
|
|
|
2486
2518
|
}
|
|
2487
2519
|
}
|
|
2488
2520
|
}
|
|
2489
|
-
catch {
|
|
2490
|
-
|
|
2521
|
+
catch (error) {
|
|
2522
|
+
if (readinessTracker && !String(error).includes('READINESS_'))
|
|
2523
|
+
readinessTracker.unavailable();
|
|
2524
|
+
if (readinessTracker && isDeviceUnavailable(error))
|
|
2525
|
+
throw error;
|
|
2526
|
+
if (readinessTracker && String(error).includes('ACTION_TIMEOUT')) {
|
|
2527
|
+
readinessTracker.deadline();
|
|
2528
|
+
break;
|
|
2529
|
+
}
|
|
2530
|
+
// Default waits retain best-effort polling.
|
|
2491
2531
|
}
|
|
2492
|
-
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
2532
|
+
await (readinessTracker ? readinessDelay(pollIntervalMs) : new Promise(resolve => setTimeout(resolve, pollIntervalMs)));
|
|
2493
2533
|
}
|
|
2494
2534
|
return {
|
|
2495
2535
|
success: false,
|
package/dist/interact/ios.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evidenceDispatch } from '../observe/evidence.js';
|
|
2
|
+
import { beforeInputDispatch } from './conditional-action.js';
|
|
1
3
|
import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js';
|
|
2
4
|
import { spawn } from "child_process";
|
|
3
5
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
|
|
@@ -24,9 +26,11 @@ export class iOSInteract {
|
|
|
24
26
|
if (targetUdid) {
|
|
25
27
|
args.push('--udid', targetUdid);
|
|
26
28
|
}
|
|
27
|
-
await
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
await beforeInputDispatch('ios', device.id);
|
|
30
|
+
const timeout = remainingBudget() || undefined;
|
|
31
|
+
markDispatchStarted();
|
|
32
|
+
await evidenceDispatch({ platform: 'ios', id: device.id }, () => new Promise((resolve, reject) => {
|
|
33
|
+
const proc = spawn(getIdbCmd(), args, { timeout });
|
|
30
34
|
let stderr = '';
|
|
31
35
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
32
36
|
proc.on('close', code => {
|
|
@@ -36,7 +40,7 @@ export class iOSInteract {
|
|
|
36
40
|
reject(new Error(`idb ui tap failed: ${stderr}`));
|
|
37
41
|
});
|
|
38
42
|
proc.on('error', err => reject(err));
|
|
39
|
-
});
|
|
43
|
+
}));
|
|
40
44
|
return { device, success: true, x, y };
|
|
41
45
|
}
|
|
42
46
|
catch (e) {
|
|
@@ -64,9 +68,11 @@ export class iOSInteract {
|
|
|
64
68
|
if (targetUdid) {
|
|
65
69
|
args.push('--udid', targetUdid);
|
|
66
70
|
}
|
|
67
|
-
await
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
await beforeInputDispatch('ios', device.id);
|
|
72
|
+
const timeout = remainingBudget() || undefined;
|
|
73
|
+
markDispatchStarted();
|
|
74
|
+
await evidenceDispatch({ platform: 'ios', id: device.id }, () => new Promise((resolve, reject) => {
|
|
75
|
+
const proc = spawn(getIdbCmd(), args, { timeout });
|
|
70
76
|
let stderr = '';
|
|
71
77
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
72
78
|
proc.on('close', code => {
|
|
@@ -76,7 +82,7 @@ export class iOSInteract {
|
|
|
76
82
|
reject(new Error(`idb ui swipe failed: ${stderr}`));
|
|
77
83
|
});
|
|
78
84
|
proc.on('error', err => reject(err));
|
|
79
|
-
});
|
|
85
|
+
}));
|
|
80
86
|
return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
|
|
81
87
|
}
|
|
82
88
|
catch (e) {
|
package/dist/manage/android.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evidenceDispatch } from '../observe/evidence.js';
|
|
1
2
|
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
2
3
|
import { promises as fs } from 'fs';
|
|
3
4
|
import { spawn } from 'child_process';
|
|
@@ -152,7 +153,7 @@ export class AndroidManage {
|
|
|
152
153
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
153
154
|
try {
|
|
154
155
|
markDispatchStarted();
|
|
155
|
-
const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs });
|
|
156
|
+
const output = await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs }));
|
|
156
157
|
const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null);
|
|
157
158
|
return {
|
|
158
159
|
device: deviceInfo,
|
|
@@ -178,7 +179,7 @@ export class AndroidManage {
|
|
|
178
179
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
179
180
|
try {
|
|
180
181
|
markDispatchStarted();
|
|
181
|
-
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
182
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'am', 'force-stop', appId], deviceId));
|
|
182
183
|
return { device: deviceInfo, appTerminated: true };
|
|
183
184
|
}
|
|
184
185
|
catch (e) {
|
|
@@ -211,7 +212,7 @@ export class AndroidManage {
|
|
|
211
212
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
212
213
|
try {
|
|
213
214
|
markDispatchStarted();
|
|
214
|
-
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
215
|
+
const output = await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'pm', 'clear', appId], deviceId));
|
|
215
216
|
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
216
217
|
}
|
|
217
218
|
catch (e) {
|
package/dist/manage/ios.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evidenceDispatch } from '../observe/evidence.js';
|
|
1
2
|
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
2
3
|
import { promises as fs } from "fs";
|
|
3
4
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -293,7 +294,7 @@ export class iOSManage {
|
|
|
293
294
|
validateBundleId(bundleId);
|
|
294
295
|
try {
|
|
295
296
|
markDispatchStarted();
|
|
296
|
-
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
297
|
+
const result = await evidenceDispatch({ platform: 'ios', id: deviceId }, () => execCommand(['simctl', 'launch', deviceId, bundleId], deviceId));
|
|
297
298
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
298
299
|
const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null;
|
|
299
300
|
const pidMatch = result.output.match(/:\s*(\d+)\s*$/);
|
|
@@ -320,7 +321,7 @@ export class iOSManage {
|
|
|
320
321
|
validateBundleId(bundleId);
|
|
321
322
|
try {
|
|
322
323
|
markDispatchStarted();
|
|
323
|
-
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
324
|
+
await evidenceDispatch({ platform: 'ios', id: deviceId }, () => execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId));
|
|
324
325
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
325
326
|
return { device, appTerminated: true };
|
|
326
327
|
}
|
package/dist/observe/android.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureEvidence } from './evidence.js';
|
|
1
2
|
import { spawn } from "child_process";
|
|
2
3
|
import { XMLParser } from "fast-xml-parser";
|
|
3
4
|
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "../utils/android/utils.js";
|
|
@@ -11,10 +12,14 @@ import { remainingBudget } from '../utils/operation-budget.js';
|
|
|
11
12
|
import { createObservationMeasurement } from './performance-metrics.js';
|
|
12
13
|
const activeLogStreams = new Map();
|
|
13
14
|
export class AndroidObserve {
|
|
15
|
+
getLogStreamDevice(sessionId = 'default') { return activeLogStreams.get(sessionId)?.device ?? null; }
|
|
14
16
|
async getDeviceMetadata(appId, deviceId) {
|
|
15
17
|
return getAndroidDeviceMetadata(appId, deviceId);
|
|
16
18
|
}
|
|
17
19
|
async getUITree(deviceId) {
|
|
20
|
+
return captureEvidence('ui_tree', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId));
|
|
21
|
+
}
|
|
22
|
+
async collectGetUITree(deviceId) {
|
|
18
23
|
const measurement = createObservationMeasurement('android');
|
|
19
24
|
let metadata;
|
|
20
25
|
try {
|
|
@@ -113,6 +118,9 @@ export class AndroidObserve {
|
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
async getLogs(filters = {}) {
|
|
121
|
+
return captureEvidence('logs', { platform: 'android', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters));
|
|
122
|
+
}
|
|
123
|
+
async collectGetLogs(filters = {}) {
|
|
116
124
|
const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
|
|
117
125
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
|
|
118
126
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
@@ -213,6 +221,9 @@ export class AndroidObserve {
|
|
|
213
221
|
}
|
|
214
222
|
}
|
|
215
223
|
async captureScreen(deviceId) {
|
|
224
|
+
return captureEvidence('screenshot', { platform: 'android', id: deviceId ?? '' }, () => this.collectCaptureScreen(deviceId));
|
|
225
|
+
}
|
|
226
|
+
async collectCaptureScreen(deviceId) {
|
|
216
227
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
217
228
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
218
229
|
return new Promise((resolve, reject) => {
|
|
@@ -331,6 +342,9 @@ export class AndroidObserve {
|
|
|
331
342
|
});
|
|
332
343
|
}
|
|
333
344
|
async getCurrentScreen(deviceId) {
|
|
345
|
+
return captureEvidence('activity', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetCurrentScreen(deviceId));
|
|
346
|
+
}
|
|
347
|
+
async collectGetCurrentScreen(deviceId) {
|
|
334
348
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
335
349
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
336
350
|
try {
|
|
@@ -385,12 +399,15 @@ export class AndroidObserve {
|
|
|
385
399
|
}
|
|
386
400
|
}
|
|
387
401
|
async getScreenFingerprint(deviceId) {
|
|
402
|
+
return captureEvidence('fingerprint', { platform: 'android', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId));
|
|
403
|
+
}
|
|
404
|
+
async collectGetScreenFingerprint(deviceId) {
|
|
388
405
|
try {
|
|
389
406
|
const tree = await this.getUITree(deviceId);
|
|
390
407
|
if (!tree || tree.error)
|
|
391
408
|
return { fingerprint: null, error: tree.error };
|
|
392
409
|
const current = await this.getCurrentScreen(deviceId).catch(() => null);
|
|
393
|
-
return computeScreenFingerprint(tree, current, 'android', 50);
|
|
410
|
+
return { ...computeScreenFingerprint(tree, current, 'android', 50), captured_at_ms: tree.captured_at_ms };
|
|
394
411
|
}
|
|
395
412
|
catch (e) {
|
|
396
413
|
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -411,7 +428,7 @@ export class AndroidObserve {
|
|
|
411
428
|
catch { }
|
|
412
429
|
activeLogStreams.delete(sessionId);
|
|
413
430
|
}
|
|
414
|
-
const args = ['logcat', `--pid=${pid}`, filter];
|
|
431
|
+
const args = [...(deviceId ? ['-s', deviceId] : []), 'logcat', `--pid=${pid}`, filter];
|
|
415
432
|
const proc = spawn(getAdbCmd(), args);
|
|
416
433
|
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
417
434
|
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
@@ -434,9 +451,10 @@ export class AndroidObserve {
|
|
|
434
451
|
});
|
|
435
452
|
proc.on('close', () => {
|
|
436
453
|
stream.end();
|
|
437
|
-
activeLogStreams.
|
|
454
|
+
if (activeLogStreams.get(sessionId)?.proc === proc)
|
|
455
|
+
activeLogStreams.delete(sessionId);
|
|
438
456
|
});
|
|
439
|
-
activeLogStreams.set(sessionId, { proc, file });
|
|
457
|
+
activeLogStreams.set(sessionId, { proc, file, device: { platform: 'android', id: deviceId ?? '' } });
|
|
440
458
|
return { success: true, stream_started: true };
|
|
441
459
|
}
|
|
442
460
|
catch (e) {
|
|
@@ -455,6 +473,9 @@ export class AndroidObserve {
|
|
|
455
473
|
return { success: true };
|
|
456
474
|
}
|
|
457
475
|
async readLogStream(sessionId = 'default', limit = 100, since) {
|
|
476
|
+
return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since));
|
|
477
|
+
}
|
|
478
|
+
async collectReadLogStream(sessionId = 'default', limit = 100, since) {
|
|
458
479
|
const entry = activeLogStreams.get(sessionId);
|
|
459
480
|
let file;
|
|
460
481
|
if (entry && entry.file)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
const sessionId = randomUUID();
|
|
4
|
+
const context = new AsyncLocalStorage();
|
|
5
|
+
const history = new Map();
|
|
6
|
+
const active = new Map();
|
|
7
|
+
const orders = new Map();
|
|
8
|
+
const stamp = () => ({ wall: Date.now(), mono: performance.now() });
|
|
9
|
+
const deviceKey = (d) => `${d.platform}:${d.id}`;
|
|
10
|
+
const sameDevice = (a, b) => !!a && !!b && deviceKey(a) === deviceKey(b);
|
|
11
|
+
function validDevice(d) {
|
|
12
|
+
return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
|
|
13
|
+
? { platform: d.platform, id: d.id } : null;
|
|
14
|
+
}
|
|
15
|
+
function orderFor(device) {
|
|
16
|
+
if (!device)
|
|
17
|
+
return null;
|
|
18
|
+
const key = deviceKey(device);
|
|
19
|
+
let order = orders.get(key);
|
|
20
|
+
if (!order) {
|
|
21
|
+
order = { sequence: 0, active: 0, lastStarted: -Infinity };
|
|
22
|
+
orders.set(key, order);
|
|
23
|
+
if (orders.size > 1000) {
|
|
24
|
+
for (const [oldKey, oldOrder] of orders) {
|
|
25
|
+
if (oldKey !== key && oldOrder.active === 0)
|
|
26
|
+
orders.delete(oldKey);
|
|
27
|
+
if (orders.size <= 1000)
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return order;
|
|
33
|
+
}
|
|
34
|
+
function prune() {
|
|
35
|
+
const now = performance.now();
|
|
36
|
+
for (const [key, record] of history) {
|
|
37
|
+
if (record.completed !== null && now - record.completed >= 600000)
|
|
38
|
+
history.delete(key);
|
|
39
|
+
}
|
|
40
|
+
while (history.size > 1000)
|
|
41
|
+
history.delete(history.keys().next().value);
|
|
42
|
+
}
|
|
43
|
+
export function validateActionRef(value) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
47
|
+
Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key)))
|
|
48
|
+
throw new Error('INVALID_ARGUMENT: invalid actionRef');
|
|
49
|
+
const ref = value;
|
|
50
|
+
for (const v of [ref.sessionId, ref.actionId])
|
|
51
|
+
if (typeof v !== 'string' || v.length < 1 || v.length > 128)
|
|
52
|
+
throw new Error('INVALID_ARGUMENT: invalid actionRef');
|
|
53
|
+
return { sessionId: ref.sessionId, actionId: ref.actionId };
|
|
54
|
+
}
|
|
55
|
+
export function currentEvidenceActionId() { return context.getStore()?.action?.id; }
|
|
56
|
+
export function bindEvidenceDevice(device) {
|
|
57
|
+
const current = context.getStore();
|
|
58
|
+
const target = validDevice(device);
|
|
59
|
+
if (!current || !target)
|
|
60
|
+
return;
|
|
61
|
+
current.device ??= target;
|
|
62
|
+
if (current.isAction && current.action)
|
|
63
|
+
current.action.device ??= target;
|
|
64
|
+
}
|
|
65
|
+
export function markEvidenceVerification() {
|
|
66
|
+
const current = context.getStore();
|
|
67
|
+
if (current) {
|
|
68
|
+
current.verification = stamp();
|
|
69
|
+
if (current.isAction && current.action)
|
|
70
|
+
current.action.verification = current.verification;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function evidenceDispatch(device, operation) {
|
|
74
|
+
const current = context.getStore();
|
|
75
|
+
if (!current?.isAction || !current.action)
|
|
76
|
+
return operation();
|
|
77
|
+
bindEvidenceDevice(device);
|
|
78
|
+
const record = current.action;
|
|
79
|
+
const start = stamp();
|
|
80
|
+
record.dispatchStart ??= start;
|
|
81
|
+
record.dispatchEnd = null;
|
|
82
|
+
const order = orderFor(validDevice(device));
|
|
83
|
+
if (order) {
|
|
84
|
+
order.sequence++;
|
|
85
|
+
order.active++;
|
|
86
|
+
order.lastStarted = start.mono;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return await operation();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
record.dispatchEnd = stamp();
|
|
93
|
+
if (order)
|
|
94
|
+
order.active--;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function omitEvidence(kind) {
|
|
98
|
+
const current = context.getStore();
|
|
99
|
+
if (!current)
|
|
100
|
+
return;
|
|
101
|
+
const start = stamp();
|
|
102
|
+
current.observations.push({ value: {
|
|
103
|
+
evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
|
|
104
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
105
|
+
freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
|
|
106
|
+
}, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false });
|
|
107
|
+
}
|
|
108
|
+
export async function captureEvidence(kind, device, operation) {
|
|
109
|
+
const current = context.getStore();
|
|
110
|
+
if (!current)
|
|
111
|
+
return operation();
|
|
112
|
+
const target = validDevice(device);
|
|
113
|
+
if (target)
|
|
114
|
+
bindEvidenceDevice(target);
|
|
115
|
+
const order = orderFor(target);
|
|
116
|
+
const start = stamp();
|
|
117
|
+
const acquisition = { value: {
|
|
118
|
+
evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
|
|
119
|
+
capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
|
|
120
|
+
freshness: 'unknown', freshness_reasons: [], device: target
|
|
121
|
+
}, start, end: null, order, startSequence: order?.sequence ?? 0,
|
|
122
|
+
endSequence: 0, overlapping: !!order?.active, reused: false };
|
|
123
|
+
current.observations.push(acquisition);
|
|
124
|
+
try {
|
|
125
|
+
const result = await operation();
|
|
126
|
+
const value = acquisition.value;
|
|
127
|
+
const resolved = validDevice(result?.device) ?? target;
|
|
128
|
+
value.device = resolved;
|
|
129
|
+
if (resolved)
|
|
130
|
+
bindEvidenceDevice(resolved);
|
|
131
|
+
const failure = result?.error ?? result?.meta?.error;
|
|
132
|
+
value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured';
|
|
133
|
+
if (failure)
|
|
134
|
+
value.error = failure;
|
|
135
|
+
const source = result?.captured_at_ms;
|
|
136
|
+
if (Number.isFinite(source) && source > 0) {
|
|
137
|
+
value.source_captured_at_ms = source;
|
|
138
|
+
acquisition.reused = source < start.wall;
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'ui_tree') {
|
|
141
|
+
value.snapshot_session_id = result?.snapshot_session_id;
|
|
142
|
+
value.snapshot_revision = result?.snapshot_revision;
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'logs') {
|
|
145
|
+
const entries = result?.logs ?? result?.entries ?? [];
|
|
146
|
+
const times = entries.map((e) => {
|
|
147
|
+
const t = e?.timestamp ?? e?._iso;
|
|
148
|
+
return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN;
|
|
149
|
+
}).filter((t) => Number.isFinite(t));
|
|
150
|
+
value.clock = 'device_or_unknown';
|
|
151
|
+
value.event_time_comparable = false;
|
|
152
|
+
value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null;
|
|
153
|
+
value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null;
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
acquisition.value.error = error instanceof Error ? error.message : String(error);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
acquisition.end = stamp();
|
|
163
|
+
acquisition.value.capture_completed_at_ms = acquisition.end.wall;
|
|
164
|
+
acquisition.endSequence = order?.sequence ?? 0;
|
|
165
|
+
acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence;
|
|
166
|
+
if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
|
|
167
|
+
acquisition.value.source_captured_at_ms = acquisition.end.wall;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function association(current) {
|
|
172
|
+
if (current.isAction)
|
|
173
|
+
return { association: 'self', association_reason: null };
|
|
174
|
+
if (!current.ref)
|
|
175
|
+
return { association: 'none', association_reason: null };
|
|
176
|
+
const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
|
|
177
|
+
!sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null;
|
|
178
|
+
return { association: reason ? 'unavailable' : 'explicit', association_reason: reason };
|
|
179
|
+
}
|
|
180
|
+
function assemble(current) {
|
|
181
|
+
const end = stamp();
|
|
182
|
+
const link = association(current);
|
|
183
|
+
const action = link.association === 'unavailable' ? null : current.action;
|
|
184
|
+
const clockChanged = end.wall < current.start.wall;
|
|
185
|
+
const observations = current.observations.map(a => {
|
|
186
|
+
const v = { ...a.value, freshness_reasons: [] };
|
|
187
|
+
if (v.status === 'omitted')
|
|
188
|
+
return v;
|
|
189
|
+
const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null;
|
|
190
|
+
const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono;
|
|
191
|
+
const reason = (freshness, code) => { v.freshness = freshness; v.freshness_reasons.push(code); };
|
|
192
|
+
if (v.kind === 'logs')
|
|
193
|
+
reason('unknown', 'DEVICE_CLOCK_UNVERIFIED');
|
|
194
|
+
else if (clockChanged || (a.end && a.end.wall < a.start.wall))
|
|
195
|
+
reason('unknown', 'CLOCK_DISCONTINUITY');
|
|
196
|
+
else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall)
|
|
197
|
+
reason('unknown', 'CLOCK_DISCONTINUITY');
|
|
198
|
+
else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end)
|
|
199
|
+
reason('unknown', 'CAPTURE_TIME_UNKNOWN');
|
|
200
|
+
else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono)
|
|
201
|
+
reason('stale', 'BEFORE_ACTION');
|
|
202
|
+
else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted)))
|
|
203
|
+
reason('stale', 'SUPERSEDED_BY_ACTION');
|
|
204
|
+
else if (a.overlapping)
|
|
205
|
+
reason('unknown', 'OVERLAPPING_ACTION');
|
|
206
|
+
else if (a.reused)
|
|
207
|
+
reason('unknown', 'REUSED_OBSERVATION');
|
|
208
|
+
else {
|
|
209
|
+
v.freshness = 'fresh';
|
|
210
|
+
}
|
|
211
|
+
if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
|
|
212
|
+
v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms);
|
|
213
|
+
}
|
|
214
|
+
return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
|
|
215
|
+
action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association };
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
|
|
219
|
+
action_id: action?.id ?? null, device: current.device, ...link,
|
|
220
|
+
...(current.ref ? { requested_action_ref: current.ref } : {}),
|
|
221
|
+
operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
|
|
222
|
+
dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
|
|
223
|
+
dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
|
|
224
|
+
verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
|
|
225
|
+
observations, trace_context: { supported: false, trace_id: null, markers: [] }
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export async function withEvidence(options, operation) {
|
|
229
|
+
prune();
|
|
230
|
+
const parent = context.getStore();
|
|
231
|
+
const id = randomUUID();
|
|
232
|
+
const record = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
|
|
233
|
+
dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null;
|
|
234
|
+
const current = { correlationId: options.action ? record.correlationId : options.journey ? id : randomUUID(),
|
|
235
|
+
action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action };
|
|
236
|
+
if (options.action && record)
|
|
237
|
+
active.set(id, record);
|
|
238
|
+
if (parent && options.action)
|
|
239
|
+
parent.children.push(current);
|
|
240
|
+
try {
|
|
241
|
+
return await context.run(current, async () => {
|
|
242
|
+
const result = await operation();
|
|
243
|
+
current.end = stamp();
|
|
244
|
+
return { result, evidence: assemble(current) };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (options.action && record) {
|
|
249
|
+
active.delete(id);
|
|
250
|
+
record.completed = performance.now();
|
|
251
|
+
history.set(id, record);
|
|
252
|
+
prune();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function refreshJourneyEvidence(steps) {
|
|
257
|
+
const current = context.getStore();
|
|
258
|
+
if (!current)
|
|
259
|
+
return;
|
|
260
|
+
for (const step of steps) {
|
|
261
|
+
const previous = step.result?.evidence;
|
|
262
|
+
const child = current.children.find(c => c.action?.id === previous?.action_id);
|
|
263
|
+
if (child && step.result)
|
|
264
|
+
step.result.evidence = assemble(child);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export function completeJourneyVerification(actionId) {
|
|
268
|
+
const child = context.getStore()?.children.find(c => c.action?.id === actionId);
|
|
269
|
+
if (child?.action) {
|
|
270
|
+
child.verification = stamp();
|
|
271
|
+
child.action.verification = child.verification;
|
|
272
|
+
}
|
|
273
|
+
}
|