mobile-debug-mcp 0.32.2 → 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.
Files changed (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -0,0 +1,255 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ export interface EvidenceDevice { platform: 'android' | 'ios'; id: string }
5
+ export interface ActionRef { sessionId: string; actionId: string }
6
+ type Kind = 'screenshot' | 'ui_tree' | 'fingerprint' | 'activity' | 'logs'
7
+ type Freshness = 'fresh' | 'stale' | 'unknown' | 'not_applicable'
8
+ interface Stamp { wall: number; mono: number }
9
+ interface DeviceOrder { sequence: number; active: number; lastStarted: number }
10
+ interface ActionRecord {
11
+ id: string; correlationId: string; device: EvidenceDevice | null
12
+ dispatchStart: Stamp | null; dispatchEnd: Stamp | null; verification: Stamp | null; completed: number | null
13
+ }
14
+ interface Component {
15
+ evidence_id: string; kind: Kind; status: 'captured' | 'failed' | 'omitted'
16
+ capture_started_at_ms: number | null; capture_completed_at_ms: number | null
17
+ source_captured_at_ms: number | null; age_ms: number | null
18
+ freshness: Freshness; freshness_reasons: string[]
19
+ device: EvidenceDevice | null; snapshot_session_id?: string; snapshot_revision?: number
20
+ error?: unknown; clock?: 'device_or_unknown'; event_time_comparable?: false
21
+ earliest_entry_at_ms?: number | null; latest_entry_at_ms?: number | null
22
+ }
23
+ interface Acquisition {
24
+ value: Component; start: Stamp; end: Stamp | null; order: DeviceOrder | null
25
+ startSequence: number; endSequence: number; overlapping: boolean; reused: boolean
26
+ }
27
+ interface EvidenceContext {
28
+ correlationId: string; action: ActionRecord | null; ref?: ActionRef
29
+ device: EvidenceDevice | null; start: Stamp; end: Stamp | null; verification: Stamp | null
30
+ observations: Acquisition[]; isAction: boolean; children: EvidenceContext[]
31
+ }
32
+ const sessionId = randomUUID()
33
+ const context = new AsyncLocalStorage<EvidenceContext>()
34
+ const history = new Map<string, ActionRecord>()
35
+ const active = new Map<string, ActionRecord>()
36
+ const orders = new Map<string, DeviceOrder>()
37
+ const stamp = (): Stamp => ({ wall: Date.now(), mono: performance.now() })
38
+ const deviceKey = (d: EvidenceDevice) => `${d.platform}:${d.id}`
39
+ const sameDevice = (a: EvidenceDevice | null, b: EvidenceDevice | null) => !!a && !!b && deviceKey(a) === deviceKey(b)
40
+ function validDevice(d: any): EvidenceDevice | null {
41
+ return d && ['android', 'ios'].includes(d.platform) && typeof d.id === 'string' && d.id && !['default', 'booted'].includes(d.id)
42
+ ? { platform: d.platform, id: d.id } : null
43
+ }
44
+ function orderFor(device: EvidenceDevice | null) {
45
+ if (!device) return null
46
+ const key = deviceKey(device)
47
+ let order = orders.get(key)
48
+ if (!order) {
49
+ order = { sequence: 0, active: 0, lastStarted: -Infinity }
50
+ orders.set(key, order)
51
+ if (orders.size > 1000) {
52
+ for (const [oldKey, oldOrder] of orders) {
53
+ if (oldKey !== key && oldOrder.active === 0) orders.delete(oldKey)
54
+ if (orders.size <= 1000) break
55
+ }
56
+ }
57
+ }
58
+ return order
59
+ }
60
+ function prune() {
61
+ const now = performance.now()
62
+ for (const [key, record] of history) {
63
+ if (record.completed !== null && now - record.completed >= 600000) history.delete(key)
64
+ }
65
+ while (history.size > 1000) history.delete(history.keys().next().value!)
66
+ }
67
+ export function validateActionRef(value: unknown): ActionRef | undefined {
68
+ if (value === undefined) return undefined
69
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
70
+ Object.keys(value).some(key => !['sessionId', 'actionId'].includes(key))) throw new Error('INVALID_ARGUMENT: invalid actionRef')
71
+ const ref = value as ActionRef
72
+ for (const v of [ref.sessionId, ref.actionId]) if (typeof v !== 'string' || v.length < 1 || v.length > 128) throw new Error('INVALID_ARGUMENT: invalid actionRef')
73
+ return { sessionId: ref.sessionId, actionId: ref.actionId }
74
+ }
75
+ export function currentEvidenceActionId() { return context.getStore()?.action?.id }
76
+ export function bindEvidenceDevice(device: { platform: string; id: string }) {
77
+ const current = context.getStore()
78
+ const target = validDevice(device)
79
+ if (!current || !target) return
80
+ current.device ??= target
81
+ if (current.isAction && current.action) current.action.device ??= target
82
+ }
83
+ export function markEvidenceVerification() {
84
+ const current = context.getStore()
85
+ if (current) {
86
+ current.verification = stamp()
87
+ if (current.isAction && current.action) current.action.verification = current.verification
88
+ }
89
+ }
90
+ export async function evidenceDispatch<T>(device: EvidenceDevice, operation: () => Promise<T>): Promise<T> {
91
+ const current = context.getStore()
92
+ if (!current?.isAction || !current.action) return operation()
93
+ bindEvidenceDevice(device)
94
+ const record = current.action
95
+ const start = stamp()
96
+ record.dispatchStart ??= start
97
+ record.dispatchEnd = null
98
+ const order = orderFor(validDevice(device))
99
+ if (order) { order.sequence++; order.active++; order.lastStarted = start.mono }
100
+ try { return await operation() }
101
+ finally {
102
+ record.dispatchEnd = stamp()
103
+ if (order) order.active--
104
+ }
105
+ }
106
+ export function omitEvidence(kind: Kind) {
107
+ const current = context.getStore()
108
+ if (!current) return
109
+ const start = stamp()
110
+ current.observations.push({ value: {
111
+ evidence_id: randomUUID(), kind, status: 'omitted', capture_started_at_ms: null,
112
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
113
+ freshness: kind === 'logs' ? 'not_applicable' : 'unknown', freshness_reasons: [], device: current.device
114
+ }, start, end: null, order: null, startSequence: 0, endSequence: 0, overlapping: false, reused: false })
115
+ }
116
+ export async function captureEvidence<T>(kind: Kind, device: EvidenceDevice | null, operation: () => Promise<T>): Promise<T> {
117
+ const current = context.getStore()
118
+ if (!current) return operation()
119
+ const target = validDevice(device)
120
+ if (target) bindEvidenceDevice(target)
121
+ const order = orderFor(target)
122
+ const start = stamp()
123
+ const acquisition: Acquisition = { value: {
124
+ evidence_id: randomUUID(), kind, status: 'failed', capture_started_at_ms: start.wall,
125
+ capture_completed_at_ms: null, source_captured_at_ms: null, age_ms: null,
126
+ freshness: 'unknown', freshness_reasons: [], device: target
127
+ }, start, end: null, order, startSequence: order?.sequence ?? 0,
128
+ endSequence: 0, overlapping: !!order?.active, reused: false }
129
+ current.observations.push(acquisition)
130
+ try {
131
+ const result = await operation() as any
132
+ const value = acquisition.value
133
+ const resolved = validDevice(result?.device) ?? target
134
+ value.device = resolved
135
+ if (resolved) bindEvidenceDevice(resolved)
136
+ const failure = result?.error ?? result?.meta?.error
137
+ value.status = failure || result?.success === false || (kind === 'fingerprint' && !result?.fingerprint) ? 'failed' : 'captured'
138
+ if (failure) value.error = failure
139
+ const source = result?.captured_at_ms
140
+ if (Number.isFinite(source) && source > 0) {
141
+ value.source_captured_at_ms = source
142
+ acquisition.reused = source < start.wall
143
+ }
144
+ if (kind === 'ui_tree') {
145
+ value.snapshot_session_id = result?.snapshot_session_id
146
+ value.snapshot_revision = result?.snapshot_revision
147
+ }
148
+ if (kind === 'logs') {
149
+ const entries = result?.logs ?? result?.entries ?? []
150
+ const times = entries.map((e: any) => {
151
+ const t = e?.timestamp ?? e?._iso
152
+ return typeof t === 'number' ? t : typeof t === 'string' ? Date.parse(t) : NaN
153
+ }).filter((t: number) => Number.isFinite(t)) as number[]
154
+ value.clock = 'device_or_unknown'; value.event_time_comparable = false
155
+ value.earliest_entry_at_ms = times.length ? times.reduce((a, b) => Math.min(a, b)) : null
156
+ value.latest_entry_at_ms = times.length ? times.reduce((a, b) => Math.max(a, b)) : null
157
+ }
158
+ return result
159
+ } catch (error) {
160
+ acquisition.value.error = error instanceof Error ? error.message : String(error)
161
+ throw error
162
+ } finally {
163
+ acquisition.end = stamp()
164
+ acquisition.value.capture_completed_at_ms = acquisition.end.wall
165
+ acquisition.endSequence = order?.sequence ?? 0
166
+ acquisition.overlapping ||= !!order?.active || acquisition.startSequence !== acquisition.endSequence
167
+ if (acquisition.value.status === 'captured' && acquisition.value.source_captured_at_ms === null && kind !== 'logs') {
168
+ acquisition.value.source_captured_at_ms = acquisition.end.wall
169
+ }
170
+ }
171
+ }
172
+ function association(current: EvidenceContext) {
173
+ if (current.isAction) return { association: 'self', association_reason: null }
174
+ if (!current.ref) return { association: 'none', association_reason: null }
175
+ const reason = current.ref.sessionId !== sessionId ? 'SESSION_MISMATCH' : !current.action ? 'ACTION_UNAVAILABLE' :
176
+ !sameDevice(current.device, current.action.device) ? 'DEVICE_MISMATCH' : null
177
+ return { association: reason ? 'unavailable' : 'explicit', association_reason: reason }
178
+ }
179
+ function assemble(current: EvidenceContext) {
180
+ const end = stamp()
181
+ const link = association(current)
182
+ const action = link.association === 'unavailable' ? null : current.action
183
+ const clockChanged = end.wall < current.start.wall
184
+ const observations = current.observations.map(a => {
185
+ const v = { ...a.value, freshness_reasons: [] as string[] }
186
+ if (v.status === 'omitted') return v
187
+ const componentAction = sameDevice(v.device, action?.device ?? null) ? action : null
188
+ const sourceMono = a.end && v.source_captured_at_ms !== null ? a.end.mono + v.source_captured_at_ms - a.end.wall : a.end?.mono
189
+ const reason = (freshness: Freshness, code: string) => { v.freshness = freshness; v.freshness_reasons.push(code) }
190
+ if (v.kind === 'logs') reason('unknown', 'DEVICE_CLOCK_UNVERIFIED')
191
+ else if (clockChanged || (a.end && a.end.wall < a.start.wall)) reason('unknown', 'CLOCK_DISCONTINUITY')
192
+ else if (v.source_captured_at_ms !== null && a.end && v.source_captured_at_ms > a.end.wall) reason('unknown', 'CLOCK_DISCONTINUITY')
193
+ else if (!v.device || !a.order || orders.get(deviceKey(v.device)) !== a.order || v.status !== 'captured' || !a.end) reason('unknown', 'CAPTURE_TIME_UNKNOWN')
194
+ else if (componentAction?.dispatchStart && sourceMono !== undefined && sourceMono < componentAction.dispatchStart.mono) reason('stale', 'BEFORE_ACTION')
195
+ else if (a.order && (a.order.sequence > a.endSequence || (a.reused && sourceMono !== undefined && sourceMono < a.order.lastStarted))) reason('stale', 'SUPERSEDED_BY_ACTION')
196
+ else if (a.overlapping) reason('unknown', 'OVERLAPPING_ACTION')
197
+ else if (a.reused) reason('unknown', 'REUSED_OBSERVATION')
198
+ else { v.freshness = 'fresh' }
199
+ if (!clockChanged && a.end && a.end.wall >= a.start.wall && v.source_captured_at_ms !== null && v.source_captured_at_ms <= end.wall) {
200
+ v.age_ms = Math.max(0, end.mono - a.end.mono + a.end.wall - v.source_captured_at_ms)
201
+ }
202
+ return { ...v, session_id: sessionId, correlation_id: componentAction?.correlationId ?? current.correlationId,
203
+ action_id: componentAction?.id ?? null, association: componentAction ? 'internal' : link.association }
204
+ })
205
+ return {
206
+ session_id: sessionId, correlation_id: action?.correlationId ?? current.correlationId,
207
+ action_id: action?.id ?? null, device: current.device, ...link,
208
+ ...(current.ref ? { requested_action_ref: current.ref } : {}),
209
+ operation_started_at_ms: current.start.wall, operation_completed_at_ms: current.end?.wall ?? end.wall,
210
+ dispatch_started_at_ms: action?.dispatchStart?.wall ?? null,
211
+ dispatch_completed_at_ms: action?.dispatchEnd?.wall ?? null,
212
+ verification_completed_at_ms: current.verification?.wall ?? action?.verification?.wall ?? null,
213
+ observations, trace_context: { supported: false, trace_id: null, markers: [] }
214
+ }
215
+ }
216
+ export async function withEvidence<T>(options: { action?: boolean; journey?: boolean; ref?: ActionRef }, operation: () => Promise<T>): Promise<{ result: T; evidence: ReturnType<typeof assemble> }> {
217
+ prune()
218
+ const parent = context.getStore()
219
+ const id = randomUUID()
220
+ const record: ActionRecord | null = options.action ? { id, correlationId: parent?.correlationId ?? id, device: null,
221
+ dispatchStart: null, dispatchEnd: null, verification: null, completed: null } : options.ref?.sessionId === sessionId ? active.get(options.ref.actionId) ?? history.get(options.ref.actionId) ?? null : null
222
+ const current: EvidenceContext = { correlationId: options.action ? record!.correlationId : options.journey ? id : randomUUID(),
223
+ action: record, ref: options.ref, device: null, start: stamp(), end: null, verification: null, observations: [], children: [], isAction: !!options.action }
224
+ if (options.action && record) active.set(id, record)
225
+ if (parent && options.action) parent.children.push(current)
226
+ try {
227
+ return await context.run(current, async () => {
228
+ const result = await operation()
229
+ current.end = stamp()
230
+ return { result, evidence: assemble(current) }
231
+ })
232
+ } finally {
233
+ if (options.action && record) {
234
+ active.delete(id); record.completed = performance.now(); history.set(id, record); prune()
235
+ }
236
+ }
237
+ }
238
+
239
+ export function refreshJourneyEvidence(steps: Array<{ result?: { evidence?: unknown } }>) {
240
+ const current = context.getStore()
241
+ if (!current) return
242
+ for (const step of steps) {
243
+ const previous = step.result?.evidence as { action_id?: string } | undefined
244
+ const child = current.children.find(c => c.action?.id === previous?.action_id)
245
+ if (child && step.result) step.result.evidence = assemble(child)
246
+ }
247
+ }
248
+
249
+ export function completeJourneyVerification(actionId: string) {
250
+ const child = context.getStore()?.children.find(c => c.action?.id === actionId)
251
+ if (child?.action) {
252
+ child.verification = stamp()
253
+ child.action.verification = child.verification
254
+ }
255
+ }
@@ -1,4 +1,7 @@
1
+ import { omitEvidence } from './evidence.js'
2
+ import { withResolvedDevice } from '../utils/resolve-device.js'
1
3
  import { measure } from '../utils/timing.js'
4
+ import { ReadinessTracker } from './readiness.js'
2
5
  import { resolveTargetDevice } from '../utils/resolve-device.js'
3
6
  import { AndroidObserve } from './android.js'
4
7
  import { iOSObserve } from './ios.js'
@@ -176,15 +179,20 @@ export class ToolsObserve {
176
179
 
177
180
  static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
178
181
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
182
+ const acquisitionStarted = Date.now()
179
183
  const tree = await observe.getUITree(resolved.id) as any
184
+ tree.readiness = new ReadinessTracker(resolved.platform, resolved.id).observe(tree, acquisitionStarted)
180
185
  if (responseMode !== 'compact') return tree
181
186
  const revision = tree?.snapshot_revision ?? null
187
+ const session = { snapshot_session_id: tree?.snapshot_session_id, readiness: tree.readiness }
188
+ if (tree?.error) return { ...session, delta_available: false, snapshot_revision: revision, error: tree.error }
182
189
  const baseRevision = sinceSnapshotRevision
183
190
  if (!Number.isInteger(baseRevision) || revision === null) {
184
- return { delta_available: false, snapshot_revision: revision }
191
+ return { ...session, delta_available: false, snapshot_revision: revision }
185
192
  }
186
193
  const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
187
- return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
194
+ return delta ? { ...session, delta_available: true, state_delta: delta, snapshot_revision: revision } : {
195
+ ...session,
188
196
  delta_available: false,
189
197
  snapshot_revision: revision,
190
198
  reason: 'BASE_REVISION_UNAVAILABLE'
@@ -233,16 +241,24 @@ export class ToolsObserve {
233
241
  }
234
242
  }
235
243
 
236
- static async readLogStreamHandler({ platform, sessionId, limit, since }: { platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
244
+ static async readLogStreamHandler({ platform, sessionId, limit, since, deviceId }: { deviceId?: string, platform?: 'android' | 'ios', sessionId?: string, limit?: number, since?: string }) {
237
245
  const sid = sessionId || 'default'
238
- const { observe } = await ToolsObserve.resolveObserve(platform)
239
- return await (observe as any).readLogStream(sid, limit ?? 100, since)
246
+ const android = new AndroidObserve()
247
+ const ios = new iOSObserve()
248
+ const observe = platform === 'android' ? android : platform === 'ios' ? ios :
249
+ ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android
250
+ const streamDevice = observe.getLogStreamDevice(sid)
251
+ if (deviceId && streamDevice && streamDevice.id !== deviceId) throw new Error('LOG_STREAM_DEVICE_MISMATCH')
252
+ return await observe.readLogStream(sid, limit ?? 100, since)
240
253
  }
241
254
 
242
255
  static async stopLogStreamHandler({ platform, sessionId }: { platform?: 'android' | 'ios', sessionId?: string }) {
243
256
  const sid = sessionId || 'default'
244
- const { observe } = await ToolsObserve.resolveObserve(platform)
245
- return await (observe as any).stopLogStream(sid)
257
+ const android = new AndroidObserve()
258
+ const ios = new iOSObserve()
259
+ const observe = platform === 'android' ? android : platform === 'ios' ? ios :
260
+ ios.getLogStreamDevice(sid) && !android.getLogStreamDevice(sid) ? ios : android
261
+ return await observe.stopLogStream(sid)
246
262
  }
247
263
 
248
264
  static async captureScreenshotHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
@@ -261,6 +277,14 @@ export class ToolsObserve {
261
277
  }
262
278
 
263
279
  static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
280
+ const { resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
281
+ return withResolvedDevice(resolved, () => ToolsObserve.collectDebugSnapshot({ reason, includeLogs, includeScreenshot, logLines,
282
+ platform: resolved.platform as 'android' | 'ios', appId, deviceId: resolved.id, sessionId }))
283
+ }
284
+
285
+ private static async collectDebugSnapshot({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
286
+ if (!includeScreenshot) omitEvidence('screenshot')
287
+ if (!includeLogs) omitEvidence('logs')
264
288
  const timestamp = Date.now()
265
289
  const raw: CaptureDebugSnapshotRawResponse = {
266
290
  timestamp,
@@ -281,7 +305,7 @@ export class ToolsObserve {
281
305
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
282
306
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
283
307
  uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
284
- readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
308
+ readLogStream: includeLogs ? ToolsObserve.readLogStreamHandler({ platform, deviceId, sessionId: sid, limit: logLines }) : Promise.resolve({ entries: [] }),
285
309
  }
286
310
 
287
311
  const results = await Promise.allSettled(Object.values(tasks))
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { captureEvidence } from './evidence.js'
1
3
  import { spawn } from "child_process"
2
4
  import { remainingBudget } from '../utils/operation-budget.js'
3
5
  import { execCmd } from '../utils/exec.js'
@@ -283,10 +285,10 @@ export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentI
283
285
  return currentIndex;
284
286
  }
285
287
 
286
- const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
288
+ const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string, device?: { platform: 'android' | 'ios'; id: string } }> = new Map()
287
289
 
288
- export function _setIOSActiveLogStream(sessionId: string, file: string) {
289
- iosActiveLogStreams.set(sessionId, { proc: {} as any, file })
290
+ export function _setIOSActiveLogStream(sessionId: string, file: string, device?: { platform: 'android' | 'ios'; id: string }) {
291
+ iosActiveLogStreams.set(sessionId, { proc: {} as any, file, device })
290
292
  }
291
293
 
292
294
  export function _clearIOSActiveLogStream(sessionId: string) {
@@ -294,11 +296,17 @@ export function _clearIOSActiveLogStream(sessionId: string) {
294
296
  }
295
297
 
296
298
  export class iOSObserve {
299
+ getLogStreamDevice(sessionId: string = 'default') { return iosActiveLogStreams.get(sessionId)?.device ?? null }
300
+
297
301
  async getDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
298
302
  return getIOSDeviceMetadata(deviceId);
299
303
  }
300
304
 
301
305
  async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
306
+ return captureEvidence('logs', { platform: 'ios', id: filters.deviceId ?? '' }, () => this.collectGetLogs(filters))
307
+ }
308
+
309
+ private async collectGetLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
302
310
  const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
303
311
  const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
304
312
 
@@ -454,8 +462,12 @@ export class iOSObserve {
454
462
  }
455
463
 
456
464
  async captureScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
465
+ return captureEvidence('screenshot', { platform: 'ios', id: deviceId ?? '' }, () => this.collectCaptureScreenshot(deviceId))
466
+ }
467
+
468
+ private async collectCaptureScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
457
469
  const device = await getIOSDeviceMetadata(deviceId)
458
- const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
470
+ const tmpFile = `/tmp/mcp-ios-screenshot-${randomUUID()}.png`
459
471
 
460
472
  try {
461
473
  await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
@@ -527,6 +539,10 @@ export class iOSObserve {
527
539
  }
528
540
 
529
541
  async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
542
+ return captureEvidence('ui_tree', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetUITree(deviceId))
543
+ }
544
+
545
+ private async collectGetUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
530
546
  const measurement = createObservationMeasurement('ios')
531
547
  let device
532
548
  try {
@@ -653,12 +669,16 @@ export class iOSObserve {
653
669
  }
654
670
  }
655
671
 
656
- async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string }> {
672
+ async getScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
673
+ return captureEvidence('fingerprint', { platform: 'ios', id: deviceId ?? '' }, () => this.collectGetScreenFingerprint(deviceId))
674
+ }
675
+
676
+ private async collectGetScreenFingerprint(deviceId: string = 'booted'): Promise<{ fingerprint: string | null; activity?: string; error?: string; captured_at_ms?: number }> {
657
677
  try {
658
678
  const tree = await this.getUITree(deviceId)
659
679
  if (!tree || tree.error) return { fingerprint: null, error: tree && (tree as any).error }
660
680
 
661
- return computeScreenFingerprint(tree, null, 'ios', 50)
681
+ return { ...computeScreenFingerprint(tree, null, 'ios', 50), captured_at_ms: tree.captured_at_ms }
662
682
  } catch (e) {
663
683
  return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
664
684
  }
@@ -701,10 +721,10 @@ export class iOSObserve {
701
721
 
702
722
  proc.on('close', () => {
703
723
  stream.end()
704
- iosActiveLogStreams.delete(sessionId)
724
+ if (iosActiveLogStreams.get(sessionId)?.proc === proc) iosActiveLogStreams.delete(sessionId)
705
725
  })
706
726
 
707
- iosActiveLogStreams.set(sessionId, { proc, file })
727
+ iosActiveLogStreams.set(sessionId, { proc, file, device: { platform: 'ios', id: deviceId ?? '' } })
708
728
  return { success: true, stream_started: true }
709
729
  } catch {
710
730
  return { success: false, error: 'log_stream_start_failed' }
@@ -720,6 +740,10 @@ export class iOSObserve {
720
740
  }
721
741
 
722
742
  async readLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
743
+ return captureEvidence('logs', this.getLogStreamDevice(sessionId), () => this.collectReadLogStream(sessionId, limit, since))
744
+ }
745
+
746
+ private async collectReadLogStream(sessionId: string = 'default', limit: number = 100, since?: string): Promise<{ entries: any[], crash_summary?: { crash_detected: boolean, exception?: string, sample?: string } }> {
723
747
  const entry = iosActiveLogStreams.get(sessionId)
724
748
  if (!entry) return { entries: [] }
725
749
  try {
@@ -0,0 +1,89 @@
1
+ import type { DeviceInfo } from '../types.js'
2
+ import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
3
+ import { remainingBudget, runWithBudget } from '../utils/operation-budget.js'
4
+ import { ToolsObserve } from './index.js'
5
+ import { isDeviceUnavailable, ReadinessTracker, withReadinessTracker } from './readiness.js'
6
+
7
+ type Target = { platform?: 'android' | 'ios'; deviceId?: string }
8
+ function budgetExpired(error: unknown) {
9
+ if (/ACTION_TIMEOUT/.test(String(error))) return true
10
+ try { remainingBudget(); return false } catch { return true }
11
+ }
12
+ async function resolve(target: Target) {
13
+ if (target.platform) return resolveTargetDevice({ ...target, platform: target.platform })
14
+ try { return await resolveTargetDevice({ ...target, platform: 'android' }) }
15
+ catch { return resolveTargetDevice({ ...target, platform: 'ios' }) }
16
+ }
17
+ export async function readinessDelay(ms: number) {
18
+ await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))))
19
+ remainingBudget()
20
+ }
21
+
22
+ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabilityMs?: number; pollIntervalMs?: number }, wait = false) {
23
+ const started = performance.now()
24
+ let device: DeviceInfo | null = null
25
+ let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '')
26
+ let failureCode: string | undefined
27
+ await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
28
+ try {
29
+ device = await resolve(args)
30
+ remainingBudget()
31
+ tracker = new ReadinessTracker(device.platform, device.id, args.stabilityMs ?? 500)
32
+ await withResolvedDevice(device, async () => {
33
+ let count = 0
34
+ while (wait || count < 2) {
35
+ remainingBudget()
36
+ const captureStarted = Date.now()
37
+ try {
38
+ const tree = await ToolsObserve.getUITreeHandler({ platform: device!.platform as 'android' | 'ios', deviceId: device!.id })
39
+ remainingBudget()
40
+ tracker.observe(tree, captureStarted)
41
+ if (isDeviceUnavailable((tree as any)?.error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
42
+ } catch (error) {
43
+ if (/ACTION_TIMEOUT/.test(String(error))) throw error
44
+ tracker.unavailable()
45
+ if (isDeviceUnavailable(error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
46
+ }
47
+ count++
48
+ if (tracker.summary.state === 'settled' || (!wait && tracker.summary.state === 'loading')) return
49
+ if (!wait && count === 2) return
50
+ await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500)
51
+ }
52
+ })
53
+ } catch (error) {
54
+ const expired = budgetExpired(error)
55
+ if (expired) tracker.deadline()
56
+ else tracker.unavailable()
57
+ failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE'
58
+ }
59
+ })
60
+ const state = tracker.summary.state
61
+ const success = !failureCode && (wait ? state === 'settled' : state !== 'unknown')
62
+ return { success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
63
+ ...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {}) }
64
+ }
65
+
66
+ export async function withReadyWait<T extends object | undefined, F extends object>(target: Target, timeoutMs: number, operation: (device: DeviceInfo) => Promise<T>, failure: (code: string, elapsedMs: number, samples: number) => F) {
67
+ const started = performance.now()
68
+ let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '')
69
+ let resolved = false
70
+ return runWithBudget(timeoutMs, async () => {
71
+ try {
72
+ const device = await resolve(target)
73
+ resolved = true
74
+ remainingBudget()
75
+ tracker = new ReadinessTracker(device.platform, device.id)
76
+ const result = await withResolvedDevice(device, () => withReadinessTracker(tracker, () => operation(device)))
77
+ try { remainingBudget() } catch {
78
+ const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false))
79
+ return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() }
80
+ }
81
+ return { ...result, readiness: tracker.summary }
82
+ } catch (error) {
83
+ const expired = budgetExpired(error)
84
+ if (expired) tracker.deadline()
85
+ else tracker.unavailable()
86
+ return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary }
87
+ }
88
+ })
89
+ }
@@ -0,0 +1,78 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { ReadinessSummary } from '../types.js'
3
+ import { remainingBudget } from '../utils/operation-budget.js'
4
+ import { computeSnapshotSignature, getSnapshotSessionId } from './snapshot-metadata.js'
5
+
6
+ const baselineLimitations = ['NORMALIZED_UI_ONLY', 'FOREGROUND_APP_UNVERIFIED', 'UNRESPONSIVE_SIGNAL_UNSUPPORTED', 'DEBUGGER_SIGNAL_UNSUPPORTED']
7
+ const capabilities = { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: false }
8
+
9
+ export function isDeviceUnavailable(error: unknown) {
10
+ return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error))
11
+ }
12
+
13
+ export class ReadinessTracker {
14
+ private signature: string | null = null
15
+ private stableSince = 0
16
+ summary: ReadinessSummary = {
17
+ state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
18
+ sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
19
+ evidence: [], limitations: [...baselineLimitations, 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities }
20
+ }
21
+
22
+ constructor(private platform: string, private deviceId: string, private stabilityMs = 500) {}
23
+
24
+ unavailable(code = 'OBSERVATION_FAILED'): ReadinessSummary {
25
+ this.signature = null
26
+ this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
27
+ sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, code],
28
+ evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8) }
29
+ return this.summary
30
+ }
31
+
32
+ deadline(): ReadinessSummary {
33
+ this.summary = { ...this.summary, limitations: [...new Set([...this.summary.limitations, 'DEADLINE_EXCEEDED'])] }
34
+ return this.summary
35
+ }
36
+
37
+ observe(tree: any, acquisitionStartedAtMs: number, now = performance.now()): ReadinessSummary {
38
+ if (tree?.error) return this.unavailable('OBSERVATION_FAILED')
39
+ if (!tree || tree.device?.platform !== this.platform || tree.device?.id !== this.deviceId ||
40
+ tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision <= 0 ||
41
+ !Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms < acquisitionStartedAtMs || tree.captured_at_ms <= 0 ||
42
+ !Array.isArray(tree.elements) || !tree.elements.length || tree.elements.some((element: unknown) => !element || typeof element !== 'object') ||
43
+ !Number.isFinite(tree.resolution?.width) || !Number.isFinite(tree.resolution?.height) || tree.resolution.width <= 0 || tree.resolution.height <= 0) return this.unavailable('OBSERVATION_INVALID')
44
+ const signature = computeSnapshotSignature(tree)
45
+ if (!signature) return this.unavailable('OBSERVATION_INVALID')
46
+ const nativeLoading = tree.elements.some((element: any) => element.visible === true && ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator'].includes(element.type))
47
+ const textLoading = tree.elements.some((element: any) => element.visible === true && [element.text, element.contentDescription].some(label =>
48
+ typeof label === 'string' && /^(loading|please\s+wait)[.\s…]*$/i.test(label.trim())))
49
+ const previous = this.signature
50
+ const unchanged = previous === signature
51
+ if (!unchanged || nativeLoading || textLoading) this.stableSince = now
52
+ this.signature = signature
53
+ const stableFor = unchanged && !nativeLoading && !textLoading ? Math.max(0, now - this.stableSince) : 0
54
+ const state = nativeLoading || textLoading ? 'loading' : !previous ? 'unknown' : !unchanged ? 'transitioning' : stableFor >= this.stabilityMs ? 'settled' : 'unknown'
55
+ const code = nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES'
56
+ this.summary = {
57
+ state, scope: 'device_ui', basis: textLoading && !nativeLoading ? 'heuristic' : state === 'unknown' ? 'insufficient' : 'observed',
58
+ captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
59
+ sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
60
+ evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
61
+ limitations: [...baselineLimitations, ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities }
62
+ }
63
+ return this.summary
64
+ }
65
+ }
66
+
67
+ const waits = new AsyncLocalStorage<ReadinessTracker>()
68
+ export function currentReadinessWait() { return waits.getStore() }
69
+ export function withReadinessTracker<T>(tracker: ReadinessTracker, operation: () => Promise<T>) { return waits.run(tracker, operation) }
70
+
71
+ export function observeReadinessWait(tree: unknown, startedAtMs: number) {
72
+ const tracker = waits.getStore()
73
+ if (!tracker) return
74
+ remainingBudget()
75
+ const summary = tracker.observe(tree, startedAtMs)
76
+ if (isDeviceUnavailable((tree as any)?.error)) throw new Error('READINESS_DEVICE_UNAVAILABLE')
77
+ if (summary.limitations.some(code => ['OBSERVATION_INVALID', 'OBSERVATION_FAILED'].includes(code))) throw new Error('READINESS_SAMPLE_INVALID')
78
+ }
@@ -14,8 +14,18 @@ interface SnapshotState {
14
14
  }
15
15
 
16
16
  let nextRevision = 1
17
+ const snapshotSessionId = crypto.randomUUID()
17
18
  const snapshotStateByDevice = new Map<string, SnapshotState>()
18
19
 
20
+ export function getSnapshotSessionId() { return snapshotSessionId }
21
+
22
+ export function hasSnapshotBaseline(deviceKey: string, revision: number) {
23
+ const state = snapshotStateByDevice.get(deviceKey)
24
+ const capturedAt = state?.historyTimes.get(revision)
25
+ return !!state && capturedAt !== undefined && state.history.has(revision) &&
26
+ Date.now() - capturedAt <= 10 * 60 * 1000 && Date.now() - state.updatedAt <= 10 * 60 * 1000
27
+ }
28
+
19
29
  function normalize(value: unknown): string {
20
30
  if (value === null || value === undefined) return ''
21
31
  return String(value).trim().toLowerCase()
@@ -163,7 +173,7 @@ export function deriveSnapshotMetadata(
163
173
  const oversized = previous?.oversized ?? new Map<number, number>()
164
174
  if (hasValidTree) {
165
175
  history.set(revision, currentElements)
166
- if (!historyTimes.has(revision)) historyTimes.set(revision, Date.now())
176
+ historyTimes.set(revision, Date.now())
167
177
  if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
168
178
  }
169
179
  for (const [id, timestamp] of historyTimes) {
@@ -191,6 +201,7 @@ export function deriveSnapshotMetadata(
191
201
  })
192
202
 
193
203
  return {
204
+ snapshot_session_id: snapshotSessionId,
194
205
  snapshot_revision: revision,
195
206
  captured_at_ms: Date.now(),
196
207
  snapshot_delta: hasValidTree ? summarizeSnapshotDelta(previous, currentElementSignatures) : null,