agent-scenario-loop 0.1.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/LICENSE +21 -0
- package/README.md +119 -0
- package/app/profile-session.ts +812 -0
- package/core/config-template.json +41 -0
- package/dist/core/agent-summary.d.ts +15 -0
- package/dist/core/agent-summary.js +177 -0
- package/dist/core/artifact-contract.d.ts +151 -0
- package/dist/core/artifact-contract.js +897 -0
- package/dist/core/artifact-layout.d.ts +56 -0
- package/dist/core/artifact-layout.js +61 -0
- package/dist/core/artifact-writer.d.ts +44 -0
- package/dist/core/artifact-writer.js +55 -0
- package/dist/core/comparison.d.ts +133 -0
- package/dist/core/comparison.js +294 -0
- package/dist/core/evidence-interpreter.d.ts +28 -0
- package/dist/core/evidence-interpreter.js +69 -0
- package/dist/core/execution-plan.d.ts +44 -0
- package/dist/core/execution-plan.js +95 -0
- package/dist/core/planner.d.ts +132 -0
- package/dist/core/planner.js +812 -0
- package/dist/core/ports.d.ts +198 -0
- package/dist/core/ports.js +146 -0
- package/dist/core/run-index.d.ts +62 -0
- package/dist/core/run-index.js +143 -0
- package/dist/core/schema-validator.d.ts +86 -0
- package/dist/core/schema-validator.js +407 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +27 -0
- package/dist/runner/agent-device-driver.d.ts +126 -0
- package/dist/runner/agent-device-driver.js +168 -0
- package/dist/runner/agent-device.d.ts +295 -0
- package/dist/runner/agent-device.js +1271 -0
- package/dist/runner/android-adb-driver.d.ts +175 -0
- package/dist/runner/android-adb-driver.js +399 -0
- package/dist/runner/android-adb.d.ts +254 -0
- package/dist/runner/android-adb.js +1618 -0
- package/dist/runner/argent-driver.d.ts +183 -0
- package/dist/runner/argent-driver.js +297 -0
- package/dist/runner/argent.d.ts +349 -0
- package/dist/runner/argent.js +1211 -0
- package/dist/runner/check-plan.d.ts +45 -0
- package/dist/runner/check-plan.js +210 -0
- package/dist/runner/cli.d.ts +20 -0
- package/dist/runner/cli.js +23 -0
- package/dist/runner/compare-latest.d.ts +99 -0
- package/dist/runner/compare-latest.js +233 -0
- package/dist/runner/compare.d.ts +58 -0
- package/dist/runner/compare.js +157 -0
- package/dist/runner/demo-loop.d.ts +45 -0
- package/dist/runner/demo-loop.js +170 -0
- package/dist/runner/example-android-live.d.ts +137 -0
- package/dist/runner/example-android-live.js +454 -0
- package/dist/runner/example-ios-live.d.ts +137 -0
- package/dist/runner/example-ios-live.js +471 -0
- package/dist/runner/host-doctor.d.ts +131 -0
- package/dist/runner/host-doctor.js +628 -0
- package/dist/runner/init-project.d.ts +88 -0
- package/dist/runner/init-project.js +263 -0
- package/dist/runner/ios-simctl-driver.d.ts +69 -0
- package/dist/runner/ios-simctl-driver.js +97 -0
- package/dist/runner/ios-simctl.d.ts +254 -0
- package/dist/runner/ios-simctl.js +1415 -0
- package/dist/runner/live-android.d.ts +137 -0
- package/dist/runner/live-android.js +539 -0
- package/dist/runner/live-comparison.d.ts +67 -0
- package/dist/runner/live-comparison.js +147 -0
- package/dist/runner/live-ios.d.ts +137 -0
- package/dist/runner/live-ios.js +460 -0
- package/dist/runner/live-proof-summary.d.ts +263 -0
- package/dist/runner/live-proof-summary.js +465 -0
- package/dist/runner/live-proof.d.ts +467 -0
- package/dist/runner/live-proof.js +920 -0
- package/dist/runner/local-env.d.ts +64 -0
- package/dist/runner/local-env.js +155 -0
- package/dist/runner/profile-android.d.ts +82 -0
- package/dist/runner/profile-android.js +671 -0
- package/dist/runner/profile-ios.d.ts +108 -0
- package/dist/runner/profile-ios.js +532 -0
- package/dist/runner/profile-mobile.d.ts +254 -0
- package/dist/runner/profile-mobile.js +1307 -0
- package/dist/runner/validate-project.d.ts +273 -0
- package/dist/runner/validate-project.js +1501 -0
- package/docs/adapters.md +145 -0
- package/docs/api.md +94 -0
- package/docs/authoring.md +196 -0
- package/docs/concepts.md +136 -0
- package/docs/consumer-rehearsal.md +115 -0
- package/docs/contracts.md +267 -0
- package/docs/live-proofs.md +270 -0
- package/docs/principles.md +46 -0
- package/examples/event-logs/app-startup-baseline.log +4 -0
- package/examples/event-logs/app-startup-current.log +4 -0
- package/examples/minimal-app/README.md +70 -0
- package/examples/mobile-app/README.md +302 -0
- package/examples/mobile-app/app.json +22 -0
- package/examples/mobile-app/asl/package-scripts.json +32 -0
- package/examples/mobile-app/asl.config.json +37 -0
- package/examples/mobile-app/event-logs/android-app-startup.log +4 -0
- package/examples/mobile-app/event-logs/android-open-close-cycle.log +12 -0
- package/examples/mobile-app/event-logs/android-scroll-settle.log +12 -0
- package/examples/mobile-app/event-logs/app-startup.log +4 -0
- package/examples/mobile-app/event-logs/open-close-cycle.log +12 -0
- package/examples/mobile-app/event-logs/scroll-settle.log +12 -0
- package/examples/mobile-app/index.ts +20 -0
- package/examples/mobile-app/metro.config.js +20 -0
- package/examples/mobile-app/package.json +62 -0
- package/examples/mobile-app/patches/expo-modules-jsi@56.0.10.patch +19 -0
- package/examples/mobile-app/plugins/with-ios-build-compat.js +271 -0
- package/examples/mobile-app/pnpm-lock.yaml +4440 -0
- package/examples/mobile-app/runner-manifests/evidence-provider.json +79 -0
- package/examples/mobile-app/runner-manifests/primary-runner.json +19 -0
- package/examples/mobile-app/scenarios/android/app-startup-video.json +73 -0
- package/examples/mobile-app/scenarios/android/app-startup.json +44 -0
- package/examples/mobile-app/scenarios/android/open-close-cycle.json +54 -0
- package/examples/mobile-app/scenarios/android/scroll-settle.json +49 -0
- package/examples/mobile-app/scenarios/ios/app-startup.json +44 -0
- package/examples/mobile-app/scenarios/ios/open-close-cycle.json +54 -0
- package/examples/mobile-app/scenarios/ios/scroll-settle.json +49 -0
- package/examples/mobile-app/scenarios/mobile/app-startup.json +91 -0
- package/examples/mobile-app/scenarios/mobile/open-close-cycle.json +160 -0
- package/examples/mobile-app/scenarios/mobile/scroll-settle.json +148 -0
- package/examples/mobile-app/scripts/asl-capture-accessibility-provider.mjs +112 -0
- package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +127 -0
- package/examples/mobile-app/src/devtools/profile-session.ts +7 -0
- package/examples/mobile-app/src/example-screen.tsx +322 -0
- package/examples/mobile-app/tsconfig.json +16 -0
- package/examples/mobile-app/tsconfig.typecheck.json +13 -0
- package/examples/runners/README.md +44 -0
- package/examples/runners/adb-android.json +25 -0
- package/examples/runners/agent-device-android.json +27 -0
- package/examples/runners/agent-device-ios.json +27 -0
- package/examples/runners/argent-android.json +32 -0
- package/examples/runners/argent-ios.json +32 -0
- package/examples/runners/argent-react-profiler-provider.json +15 -0
- package/examples/runners/axe-accessibility-provider.json +24 -0
- package/examples/runners/manual-log-ingest.json +9 -0
- package/examples/runners/rozenite-profiler-provider.json +9 -0
- package/examples/runners/script-accessibility-provider.json +24 -0
- package/examples/runners/script-memory-provider.json +24 -0
- package/examples/runners/script-network-provider.json +24 -0
- package/examples/runners/script-profiler-provider.json +30 -0
- package/examples/runners/xcodebuildmcp-ios.json +29 -0
- package/examples/scenarios/ios/app-startup.json +28 -0
- package/examples/scenarios/ios/open-close-cycle.json +35 -0
- package/examples/scenarios/mobile/app-startup.json +72 -0
- package/examples/scenarios/mobile/media-open-close.json +141 -0
- package/examples/scenarios/mobile/open-close-cycle.json +135 -0
- package/examples/scenarios/mobile/scroll-settle.json +106 -0
- package/package.json +240 -0
- package/schemas/budget-verdict.schema.json +115 -0
- package/schemas/causal-run.schema.json +279 -0
- package/schemas/comparison.schema.json +196 -0
- package/schemas/health.schema.json +108 -0
- package/schemas/live-proof-set.schema.json +195 -0
- package/schemas/live-proof.schema.json +413 -0
- package/schemas/manifest.schema.json +204 -0
- package/schemas/metrics.schema.json +137 -0
- package/schemas/project-validation.schema.json +343 -0
- package/schemas/runner-capabilities.schema.json +217 -0
- package/schemas/scenario.schema.json +400 -0
- package/schemas/verdict.schema.json +88 -0
- package/templates/evidence-provider.json +83 -0
- package/templates/gitignore-snippet +9 -0
- package/templates/integration-readme.md +125 -0
- package/templates/mobile-scenario.json +133 -0
- package/templates/package-scripts.json +32 -0
- package/templates/primary-runner.json +19 -0
- package/templates/project.config.json +37 -0
- package/templates/scripts/asl-capture-accessibility-provider.mjs +112 -0
- package/templates/scripts/asl-capture-profiler-provider.mjs +127 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
3
|
+
import { Linking, NativeModules, Platform } from 'react-native';
|
|
4
|
+
import * as ExpoLinking from 'expo-linking';
|
|
5
|
+
|
|
6
|
+
export type ProfileSessionState = {
|
|
7
|
+
active: boolean;
|
|
8
|
+
scenario: string | null;
|
|
9
|
+
runId: string | null;
|
|
10
|
+
startedAt: number | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ProfileSessionCommand = {
|
|
14
|
+
id: string;
|
|
15
|
+
scenario?: string;
|
|
16
|
+
runId?: string;
|
|
17
|
+
command: string;
|
|
18
|
+
source?: 'deeplink' | 'storage';
|
|
19
|
+
timestamp: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ProfileSignalKind = 'js' | 'memory' | 'network';
|
|
23
|
+
export type ProfileEventPhase =
|
|
24
|
+
| 'intent'
|
|
25
|
+
| 'navigation'
|
|
26
|
+
| 'domain'
|
|
27
|
+
| 'query'
|
|
28
|
+
| 'network'
|
|
29
|
+
| 'render'
|
|
30
|
+
| 'native'
|
|
31
|
+
| 'visual'
|
|
32
|
+
| 'completion';
|
|
33
|
+
export type ProfileEventStatus = 'started' | 'completed' | 'failed' | 'skipped' | 'observed';
|
|
34
|
+
|
|
35
|
+
type ProfileEventMetadata = {
|
|
36
|
+
flowId?: string;
|
|
37
|
+
owner?: string;
|
|
38
|
+
phase?: ProfileEventPhase;
|
|
39
|
+
status?: ProfileEventStatus;
|
|
40
|
+
route?: string;
|
|
41
|
+
atMs?: number;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ProfileSignalMetadata = {
|
|
46
|
+
flowId?: string;
|
|
47
|
+
owner?: string;
|
|
48
|
+
route?: string;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type StoredProfileEvent = {
|
|
53
|
+
scenario: string;
|
|
54
|
+
runId: string;
|
|
55
|
+
event: string;
|
|
56
|
+
timestamp: number;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type StoredProfileSessionEntry = {
|
|
61
|
+
kind: 'start' | 'stop' | 'command';
|
|
62
|
+
scenario: string;
|
|
63
|
+
runId: string;
|
|
64
|
+
timestamp: number;
|
|
65
|
+
startedAt?: number;
|
|
66
|
+
stoppedAt?: number;
|
|
67
|
+
command?: string;
|
|
68
|
+
id?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type StoredProfileSignals = Record<ProfileSignalKind, Record<string, unknown>>;
|
|
72
|
+
|
|
73
|
+
const INITIAL_STATE: ProfileSessionState = {
|
|
74
|
+
active: false,
|
|
75
|
+
scenario: null,
|
|
76
|
+
runId: null,
|
|
77
|
+
startedAt: null,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const STORAGE_PREFIX = 'agent-scenario-loop';
|
|
81
|
+
const PROFILE_STORAGE_SCHEMA = '1';
|
|
82
|
+
const PROFILE_EVENT_STORAGE_KEY = `${STORAGE_PREFIX}.profile-events.${PROFILE_STORAGE_SCHEMA}`;
|
|
83
|
+
const PROFILE_SIGNAL_STORAGE_KEY = `${STORAGE_PREFIX}.profile-signals.${PROFILE_STORAGE_SCHEMA}`;
|
|
84
|
+
const PROFILE_SESSION_STORAGE_KEY = `${STORAGE_PREFIX}.profile-session.${PROFILE_STORAGE_SCHEMA}`;
|
|
85
|
+
const PROFILE_COMMAND_STORAGE_KEY = `${STORAGE_PREFIX}.profile-commands.${PROFILE_STORAGE_SCHEMA}`;
|
|
86
|
+
const PROFILE_SESSION_ENTRIES_STORAGE_KEY = `${STORAGE_PREFIX}.profile-session-entries.${PROFILE_STORAGE_SCHEMA}`;
|
|
87
|
+
const PROFILE_STORAGE_POLL_INTERVAL_MS = 350;
|
|
88
|
+
const PROFILE_SESSION_MAX_AGE_MS = 2 * 60 * 60_000;
|
|
89
|
+
const PROFILE_COMMAND_DUPLICATE_WINDOW_MS = 750;
|
|
90
|
+
const PROCESSED_PROFILE_COMMAND_ID_LIMIT = 120;
|
|
91
|
+
const MAX_STORED_PROFILE_EVENTS = 300;
|
|
92
|
+
const MAX_STORED_PROFILE_SESSION_ENTRIES = 120;
|
|
93
|
+
|
|
94
|
+
let profileSessionState: ProfileSessionState = INITIAL_STATE;
|
|
95
|
+
let profileStorageWriteChain = Promise.resolve();
|
|
96
|
+
const listeners = new Set<() => void>();
|
|
97
|
+
const profileCommandListeners = new Set<(command: ProfileSessionCommand) => void>();
|
|
98
|
+
const profileCommandTargetHandlers = new Map<string, () => void>();
|
|
99
|
+
const pendingProfileCommands: ProfileSessionCommand[] = [];
|
|
100
|
+
const processedProfileCommandIds = new Set<string>();
|
|
101
|
+
let lastProfileCommandSignature: string | null = null;
|
|
102
|
+
let lastProfileCommandTimestamp = 0;
|
|
103
|
+
|
|
104
|
+
function writeProfileLog(line: string) {
|
|
105
|
+
if (Platform.OS === 'ios') {
|
|
106
|
+
const profileLogger = (
|
|
107
|
+
NativeModules as typeof NativeModules & {
|
|
108
|
+
ProfileLogger?: { log?: (message: string) => void };
|
|
109
|
+
}
|
|
110
|
+
).ProfileLogger;
|
|
111
|
+
|
|
112
|
+
if (typeof profileLogger?.log === 'function') {
|
|
113
|
+
try {
|
|
114
|
+
profileLogger.log(line);
|
|
115
|
+
return;
|
|
116
|
+
} catch {
|
|
117
|
+
// Fall back to JS logging if the native bridge is unavailable.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const nativeLoggingHook = (
|
|
123
|
+
globalThis as typeof globalThis & {
|
|
124
|
+
nativeLoggingHook?: ((message: string, level: number) => void) | undefined;
|
|
125
|
+
}
|
|
126
|
+
).nativeLoggingHook;
|
|
127
|
+
|
|
128
|
+
if (typeof nativeLoggingHook === 'function') {
|
|
129
|
+
try {
|
|
130
|
+
nativeLoggingHook(line, 0);
|
|
131
|
+
return;
|
|
132
|
+
} catch {
|
|
133
|
+
// Fall back to console output if the native hook is unavailable.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.info(line);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildLogLine(kind: 'profile-session' | 'profile-event', payload: Record<string, unknown>) {
|
|
141
|
+
return [
|
|
142
|
+
`[${kind}]`,
|
|
143
|
+
...Object.entries(payload).map(([key, value]) => `${key}=${String(value)}`),
|
|
144
|
+
].join(' ');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function notifyListeners() {
|
|
148
|
+
for (const listener of listeners) {
|
|
149
|
+
listener();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function queueProfileStorageMutation(mutation: () => Promise<void>) {
|
|
154
|
+
profileStorageWriteChain = profileStorageWriteChain.then(mutation).catch(() => {});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function readStoredJson<Value>(key: string, fallback: Value): Promise<Value> {
|
|
158
|
+
try {
|
|
159
|
+
const value = await AsyncStorage.getItem(key);
|
|
160
|
+
if (!value) {
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return JSON.parse(value) as Value;
|
|
165
|
+
} catch {
|
|
166
|
+
return fallback;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function writeStoredJson(key: string, value: unknown) {
|
|
171
|
+
await AsyncStorage.setItem(key, JSON.stringify(value));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function appendStoredProfileSessionEntry(entry: StoredProfileSessionEntry) {
|
|
175
|
+
queueProfileStorageMutation(async () => {
|
|
176
|
+
const currentEntries = await readStoredJson<StoredProfileSessionEntry[]>(
|
|
177
|
+
PROFILE_SESSION_ENTRIES_STORAGE_KEY,
|
|
178
|
+
[],
|
|
179
|
+
);
|
|
180
|
+
const nextEntries = [...currentEntries, entry].slice(-MAX_STORED_PROFILE_SESSION_ENTRIES);
|
|
181
|
+
await writeStoredJson(PROFILE_SESSION_ENTRIES_STORAGE_KEY, nextEntries);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function appendStoredProfileEvent(event: StoredProfileEvent) {
|
|
186
|
+
queueProfileStorageMutation(async () => {
|
|
187
|
+
const currentEvents = await readStoredJson<StoredProfileEvent[]>(PROFILE_EVENT_STORAGE_KEY, []);
|
|
188
|
+
const nextEvents = [...currentEvents, event].slice(-MAX_STORED_PROFILE_EVENTS);
|
|
189
|
+
await writeStoredJson(PROFILE_EVENT_STORAGE_KEY, nextEvents);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resetStoredProfileArtifacts() {
|
|
194
|
+
queueProfileStorageMutation(async () => {
|
|
195
|
+
await Promise.all([
|
|
196
|
+
AsyncStorage.removeItem(PROFILE_EVENT_STORAGE_KEY),
|
|
197
|
+
AsyncStorage.removeItem(PROFILE_SIGNAL_STORAGE_KEY),
|
|
198
|
+
AsyncStorage.removeItem(PROFILE_SESSION_ENTRIES_STORAGE_KEY),
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function clearPendingProfileCommands() {
|
|
204
|
+
pendingProfileCommands.length = 0;
|
|
205
|
+
queueProfileStorageMutation(async () => {
|
|
206
|
+
await AsyncStorage.removeItem(PROFILE_COMMAND_STORAGE_KEY);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Clears command replay guards when a scenario session boundary changes.
|
|
212
|
+
*/
|
|
213
|
+
function clearProfileCommandDedupe() {
|
|
214
|
+
lastProfileCommandSignature = null;
|
|
215
|
+
lastProfileCommandTimestamp = 0;
|
|
216
|
+
processedProfileCommandIds.clear();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function setProfileSessionState(nextState: ProfileSessionState) {
|
|
220
|
+
profileSessionState = nextState;
|
|
221
|
+
queueProfileStorageMutation(async () => {
|
|
222
|
+
if (!nextState.active || !nextState.scenario || !nextState.runId) {
|
|
223
|
+
await AsyncStorage.removeItem(PROFILE_SESSION_STORAGE_KEY);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await writeStoredJson(PROFILE_SESSION_STORAGE_KEY, nextState);
|
|
228
|
+
});
|
|
229
|
+
notifyListeners();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Returns the numeric start time for a profile session when one is available.
|
|
234
|
+
*
|
|
235
|
+
* @param {ProfileSessionState | null} session
|
|
236
|
+
* @returns {number | null}
|
|
237
|
+
*/
|
|
238
|
+
function readProfileSessionStartedAt(session: ProfileSessionState | null): number | null {
|
|
239
|
+
return typeof session?.startedAt === 'number' && Number.isFinite(session.startedAt)
|
|
240
|
+
? session.startedAt
|
|
241
|
+
: null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Guards the in-memory session from older AsyncStorage snapshots.
|
|
246
|
+
*
|
|
247
|
+
* @param {ProfileSessionState} storedSession
|
|
248
|
+
* @returns {boolean}
|
|
249
|
+
*/
|
|
250
|
+
function shouldApplyStoredProfileSession(storedSession: ProfileSessionState): boolean {
|
|
251
|
+
if (!profileSessionState.active || !profileSessionState.scenario || !profileSessionState.runId) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
profileSessionState.scenario === storedSession.scenario &&
|
|
257
|
+
profileSessionState.runId === storedSession.runId
|
|
258
|
+
) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const storedStartedAt = readProfileSessionStartedAt(storedSession);
|
|
263
|
+
const activeStartedAt = readProfileSessionStartedAt(profileSessionState);
|
|
264
|
+
return storedStartedAt !== null && activeStartedAt !== null && storedStartedAt > activeStartedAt;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Returns true when a stored session is fresh enough to revive after app startup.
|
|
269
|
+
*
|
|
270
|
+
* @param {Pick<ProfileSessionState, 'active' | 'startedAt'>} session
|
|
271
|
+
* @param {number} [now]
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
export function isProfileSessionFresh(
|
|
275
|
+
session: Pick<ProfileSessionState, 'active' | 'startedAt'>,
|
|
276
|
+
now = Date.now(),
|
|
277
|
+
): boolean {
|
|
278
|
+
if (!session.active) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (typeof session.startedAt !== 'number' || !Number.isFinite(session.startedAt)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return now - session.startedAt <= PROFILE_SESSION_MAX_AGE_MS;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function logProfileSession(kind: 'start' | 'stop' | 'command', payload: Record<string, unknown>) {
|
|
288
|
+
writeProfileLog(buildLogLine('profile-session', { kind, ...payload }));
|
|
289
|
+
|
|
290
|
+
const scenario = typeof payload.scenario === 'string' ? payload.scenario : null;
|
|
291
|
+
const runId = typeof payload.runId === 'string' ? payload.runId : null;
|
|
292
|
+
if (!scenario || !runId) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const timestamp = Date.now();
|
|
297
|
+
const entry: StoredProfileSessionEntry = {
|
|
298
|
+
kind,
|
|
299
|
+
scenario,
|
|
300
|
+
runId,
|
|
301
|
+
timestamp,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (kind === 'start' && typeof payload.startedAt === 'number') {
|
|
305
|
+
entry.startedAt = payload.startedAt;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (kind === 'stop' && typeof payload.stoppedAt === 'number') {
|
|
309
|
+
entry.stoppedAt = payload.stoppedAt;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (kind === 'command') {
|
|
313
|
+
if (typeof payload.command === 'string') {
|
|
314
|
+
entry.command = payload.command;
|
|
315
|
+
}
|
|
316
|
+
if (typeof payload.id === 'string') {
|
|
317
|
+
entry.id = payload.id;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
appendStoredProfileSessionEntry(entry);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getProfileSessionRoute(url: string): {
|
|
325
|
+
action: 'start' | 'stop' | 'command';
|
|
326
|
+
scenario?: string;
|
|
327
|
+
runId?: string;
|
|
328
|
+
command?: string;
|
|
329
|
+
} | null {
|
|
330
|
+
const parsed = ExpoLinking.parse(url);
|
|
331
|
+
const segments = [parsed.hostname, parsed.path]
|
|
332
|
+
.filter((segment): segment is string => typeof segment === 'string' && segment.length > 0)
|
|
333
|
+
.flatMap((segment) => segment.split('/').filter(Boolean));
|
|
334
|
+
|
|
335
|
+
if (segments[0] !== 'profile-session') {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const action = segments[1];
|
|
340
|
+
if (action !== 'start' && action !== 'stop' && action !== 'command') {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const scenario =
|
|
345
|
+
typeof parsed.queryParams?.scenario === 'string' ? parsed.queryParams.scenario : undefined;
|
|
346
|
+
const runId =
|
|
347
|
+
typeof parsed.queryParams?.runId === 'string' ? parsed.queryParams.runId : undefined;
|
|
348
|
+
const command =
|
|
349
|
+
typeof parsed.queryParams?.command === 'string' ? parsed.queryParams.command : undefined;
|
|
350
|
+
|
|
351
|
+
return { action, scenario, runId, command };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function queuePendingProfileCommand(command: ProfileSessionCommand) {
|
|
355
|
+
pendingProfileCommands.push(command);
|
|
356
|
+
queueProfileStorageMutation(async () => {
|
|
357
|
+
await writeStoredJson(PROFILE_COMMAND_STORAGE_KEY, pendingProfileCommands);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolves the semantic target id for runner commands that activate app-owned controls.
|
|
363
|
+
*/
|
|
364
|
+
function getProfileCommandTargetId(command: string): string | null {
|
|
365
|
+
if (!command.startsWith('activate-target:')) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const targetId = command.slice('activate-target:'.length);
|
|
370
|
+
return targetId.length > 0 ? targetId : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function dispatchProfileCommandTarget(command: ProfileSessionCommand) {
|
|
374
|
+
const targetId = getProfileCommandTargetId(command.command);
|
|
375
|
+
if (!targetId) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const handler = profileCommandTargetHandlers.get(targetId);
|
|
380
|
+
if (!handler) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
handler();
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Builds a stable key for duplicate command suppression across delivery lanes.
|
|
390
|
+
*/
|
|
391
|
+
function getProfileCommandSignature(command: ProfileSessionCommand): string {
|
|
392
|
+
return [
|
|
393
|
+
command.scenario ?? '',
|
|
394
|
+
command.runId ?? '',
|
|
395
|
+
command.command,
|
|
396
|
+
].join('|');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Detects duplicate runner commands delivered repeatedly during one short native handoff window.
|
|
401
|
+
*/
|
|
402
|
+
function shouldSkipProfileCommandForDuplicateWindow(
|
|
403
|
+
command: ProfileSessionCommand,
|
|
404
|
+
commandTimestamp: number,
|
|
405
|
+
): boolean {
|
|
406
|
+
if (command.source === 'storage') {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const signature = getProfileCommandSignature(command);
|
|
411
|
+
return (
|
|
412
|
+
signature === lastProfileCommandSignature &&
|
|
413
|
+
commandTimestamp - lastProfileCommandTimestamp >= 0 &&
|
|
414
|
+
commandTimestamp - lastProfileCommandTimestamp <= PROFILE_COMMAND_DUPLICATE_WINDOW_MS
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Returns true when a stored command id has already been replayed into the app.
|
|
420
|
+
*/
|
|
421
|
+
function hasProcessedProfileCommandId(command: ProfileSessionCommand): boolean {
|
|
422
|
+
return command.id.length > 0 && processedProfileCommandIds.has(command.id);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Records a stored command id while keeping the replay cache bounded.
|
|
427
|
+
*/
|
|
428
|
+
function markProfileCommandIdProcessed(command: ProfileSessionCommand) {
|
|
429
|
+
if (!command.id) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
processedProfileCommandIds.add(command.id);
|
|
434
|
+
while (processedProfileCommandIds.size > PROCESSED_PROFILE_COMMAND_ID_LIMIT) {
|
|
435
|
+
const oldestId = processedProfileCommandIds.keys().next().value;
|
|
436
|
+
if (typeof oldestId !== 'string') {
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
processedProfileCommandIds.delete(oldestId);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function notifyProfileCommandListeners(command: ProfileSessionCommand) {
|
|
444
|
+
const commandTimestamp = Number.isFinite(command.timestamp) ? command.timestamp : Date.now();
|
|
445
|
+
if (shouldSkipProfileCommandForDuplicateWindow(command, commandTimestamp)) {
|
|
446
|
+
logProfileSession('command', {
|
|
447
|
+
...command,
|
|
448
|
+
status: 'skipped',
|
|
449
|
+
reason: 'duplicate-command-window',
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
lastProfileCommandSignature = getProfileCommandSignature(command);
|
|
455
|
+
lastProfileCommandTimestamp = commandTimestamp;
|
|
456
|
+
|
|
457
|
+
const targetDispatched = dispatchProfileCommandTarget(command);
|
|
458
|
+
if (targetDispatched) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (profileCommandListeners.size === 0) {
|
|
463
|
+
queuePendingProfileCommand(command);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const listener of profileCommandListeners) {
|
|
468
|
+
listener(command);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function flushPendingProfileCommands(listener: (command: ProfileSessionCommand) => void) {
|
|
473
|
+
if (pendingProfileCommands.length === 0) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const queuedCommands = [...pendingProfileCommands];
|
|
478
|
+
clearPendingProfileCommands();
|
|
479
|
+
|
|
480
|
+
for (const command of queuedCommands) {
|
|
481
|
+
listener(command);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function startProfileSessionInternal(nextState: ProfileSessionState) {
|
|
486
|
+
clearPendingProfileCommands();
|
|
487
|
+
clearProfileCommandDedupe();
|
|
488
|
+
resetStoredProfileArtifacts();
|
|
489
|
+
setProfileSessionState(nextState);
|
|
490
|
+
logProfileSession('start', {
|
|
491
|
+
scenario: nextState.scenario ?? 'unknown',
|
|
492
|
+
runId: nextState.runId ?? 'unknown',
|
|
493
|
+
startedAt: nextState.startedAt ?? Date.now(),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function stopProfileSessionInternal() {
|
|
498
|
+
const previousState = profileSessionState;
|
|
499
|
+
clearPendingProfileCommands();
|
|
500
|
+
clearProfileCommandDedupe();
|
|
501
|
+
setProfileSessionState(INITIAL_STATE);
|
|
502
|
+
logProfileSession('stop', {
|
|
503
|
+
scenario: previousState.scenario ?? 'unknown',
|
|
504
|
+
runId: previousState.runId ?? 'unknown',
|
|
505
|
+
stoppedAt: Date.now(),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Starts a profile session from app code or a parsed control URL.
|
|
511
|
+
*/
|
|
512
|
+
export function startProfileSession(params: { scenario: string; runId: string; startedAt?: number }): void {
|
|
513
|
+
startProfileSessionInternal({
|
|
514
|
+
active: true,
|
|
515
|
+
scenario: params.scenario,
|
|
516
|
+
runId: params.runId,
|
|
517
|
+
startedAt: params.startedAt ?? Date.now(),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Stops the active profile session and clears pending runner commands.
|
|
523
|
+
*/
|
|
524
|
+
export function stopProfileSession(): void {
|
|
525
|
+
stopProfileSessionInternal();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Applies one profile-session deep link to the in-app control plane.
|
|
530
|
+
*/
|
|
531
|
+
export function applyProfileSessionUrl(url: string | null | undefined): boolean {
|
|
532
|
+
if (!url) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const route = getProfileSessionRoute(url);
|
|
537
|
+
if (!route) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (route.action === 'command' && route.command) {
|
|
542
|
+
const timestamp = Date.now();
|
|
543
|
+
if (
|
|
544
|
+
route.scenario &&
|
|
545
|
+
route.runId &&
|
|
546
|
+
(!profileSessionState.active ||
|
|
547
|
+
profileSessionState.scenario !== route.scenario ||
|
|
548
|
+
profileSessionState.runId !== route.runId)
|
|
549
|
+
) {
|
|
550
|
+
setProfileSessionState({
|
|
551
|
+
active: true,
|
|
552
|
+
scenario: route.scenario,
|
|
553
|
+
runId: route.runId,
|
|
554
|
+
startedAt: timestamp,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const command = {
|
|
559
|
+
id: `${timestamp}-${route.scenario ?? 'profile'}-${route.command}`,
|
|
560
|
+
scenario: route.scenario,
|
|
561
|
+
runId: route.runId,
|
|
562
|
+
command: route.command,
|
|
563
|
+
source: 'deeplink' as const,
|
|
564
|
+
timestamp,
|
|
565
|
+
};
|
|
566
|
+
logProfileSession('command', command);
|
|
567
|
+
notifyProfileCommandListeners(command);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (route.action === 'start' && route.scenario && route.runId) {
|
|
572
|
+
startProfileSession({
|
|
573
|
+
scenario: route.scenario,
|
|
574
|
+
runId: route.runId,
|
|
575
|
+
});
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
stopProfileSession();
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Emits one app-owned truth event for the active profile session.
|
|
585
|
+
*/
|
|
586
|
+
export function emitProfileEvent(event: string, metadata?: ProfileEventMetadata): void {
|
|
587
|
+
const session = profileSessionState;
|
|
588
|
+
if (!session.active || !session.scenario || !session.runId) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const timestamp = Date.now();
|
|
593
|
+
const atMs =
|
|
594
|
+
typeof metadata?.atMs === 'number' && Number.isFinite(metadata.atMs)
|
|
595
|
+
? metadata.atMs
|
|
596
|
+
: typeof session.startedAt === 'number' && Number.isFinite(session.startedAt)
|
|
597
|
+
? Math.max(0, timestamp - session.startedAt)
|
|
598
|
+
: undefined;
|
|
599
|
+
|
|
600
|
+
const eventPayload: StoredProfileEvent = {
|
|
601
|
+
scenario: session.scenario,
|
|
602
|
+
runId: session.runId,
|
|
603
|
+
event,
|
|
604
|
+
timestamp,
|
|
605
|
+
...(atMs !== undefined ? { atMs } : {}),
|
|
606
|
+
...(metadata ?? {}),
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
writeProfileLog(buildLogLine('profile-event', eventPayload));
|
|
610
|
+
appendStoredProfileEvent(eventPayload);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Stores one app-owned measurement signal for the active profile session.
|
|
615
|
+
*/
|
|
616
|
+
export function storeProfileSignal(
|
|
617
|
+
kind: ProfileSignalKind,
|
|
618
|
+
name: string,
|
|
619
|
+
value: unknown,
|
|
620
|
+
metadata?: ProfileSignalMetadata,
|
|
621
|
+
): boolean {
|
|
622
|
+
const session = profileSessionState;
|
|
623
|
+
if (!session.active || !session.scenario || !session.runId || !name) {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
queueProfileStorageMutation(async () => {
|
|
628
|
+
const currentSignals = await readStoredJson<StoredProfileSignals>(PROFILE_SIGNAL_STORAGE_KEY, {
|
|
629
|
+
js: {},
|
|
630
|
+
memory: {},
|
|
631
|
+
network: {},
|
|
632
|
+
});
|
|
633
|
+
const nextSignals: StoredProfileSignals = {
|
|
634
|
+
...currentSignals,
|
|
635
|
+
[kind]: {
|
|
636
|
+
...(currentSignals[kind] ?? {}),
|
|
637
|
+
[name]: {
|
|
638
|
+
scenario: session.scenario,
|
|
639
|
+
runId: session.runId,
|
|
640
|
+
capturedAt: Date.now(),
|
|
641
|
+
...(metadata ?? {}),
|
|
642
|
+
value,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
await writeStoredJson(PROFILE_SIGNAL_STORAGE_KEY, nextSignals);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Subscribes React components to the current profile session state.
|
|
654
|
+
*/
|
|
655
|
+
export function useProfileSession(): ProfileSessionState {
|
|
656
|
+
return useSyncExternalStore(
|
|
657
|
+
(listener) => {
|
|
658
|
+
listeners.add(listener);
|
|
659
|
+
return () => {
|
|
660
|
+
listeners.delete(listener);
|
|
661
|
+
};
|
|
662
|
+
},
|
|
663
|
+
() => profileSessionState,
|
|
664
|
+
() => profileSessionState,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Subscribes to runner commands delivered through profile-session deep links.
|
|
670
|
+
*/
|
|
671
|
+
export function subscribeToProfileCommands(listener: (command: ProfileSessionCommand) => void): () => void {
|
|
672
|
+
profileCommandListeners.add(listener);
|
|
673
|
+
flushPendingProfileCommands(listener);
|
|
674
|
+
return () => {
|
|
675
|
+
profileCommandListeners.delete(listener);
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Registers a named command target for `activate-target:<targetId>` commands.
|
|
681
|
+
*/
|
|
682
|
+
export function registerProfileCommandTargetHandler(targetId: string, handler: () => void): () => void {
|
|
683
|
+
profileCommandTargetHandlers.set(targetId, handler);
|
|
684
|
+
return () => {
|
|
685
|
+
const currentHandler = profileCommandTargetHandlers.get(targetId);
|
|
686
|
+
if (currentHandler === handler) {
|
|
687
|
+
profileCommandTargetHandlers.delete(targetId);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Boots the profile-session deep-link and storage bridge near the app root.
|
|
694
|
+
*/
|
|
695
|
+
export function useProfileSessionBootstrap(): void {
|
|
696
|
+
useEffect(() => {
|
|
697
|
+
let isMounted = true;
|
|
698
|
+
|
|
699
|
+
const syncStoredProfileState = async () => {
|
|
700
|
+
const [storedSession, storedCommands] = await Promise.all([
|
|
701
|
+
readStoredJson<ProfileSessionState | null>(PROFILE_SESSION_STORAGE_KEY, null),
|
|
702
|
+
readStoredJson<ProfileSessionCommand[]>(PROFILE_COMMAND_STORAGE_KEY, []),
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
if (
|
|
706
|
+
storedSession &&
|
|
707
|
+
storedSession.active &&
|
|
708
|
+
typeof storedSession.scenario === 'string' &&
|
|
709
|
+
typeof storedSession.runId === 'string'
|
|
710
|
+
) {
|
|
711
|
+
if (!isProfileSessionFresh(storedSession)) {
|
|
712
|
+
stopProfileSessionInternal();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const shouldStartFromStorage =
|
|
717
|
+
shouldApplyStoredProfileSession(storedSession) &&
|
|
718
|
+
(!profileSessionState.active ||
|
|
719
|
+
profileSessionState.scenario !== storedSession.scenario ||
|
|
720
|
+
profileSessionState.runId !== storedSession.runId);
|
|
721
|
+
|
|
722
|
+
if (shouldStartFromStorage) {
|
|
723
|
+
startProfileSessionInternal({
|
|
724
|
+
active: true,
|
|
725
|
+
scenario: storedSession.scenario,
|
|
726
|
+
runId: storedSession.runId,
|
|
727
|
+
startedAt:
|
|
728
|
+
typeof storedSession.startedAt === 'number' ? storedSession.startedAt : Date.now(),
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
} else if (profileSessionState.active) {
|
|
732
|
+
stopProfileSessionInternal();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!Array.isArray(storedCommands) || storedCommands.length === 0) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const activeSession =
|
|
740
|
+
storedSession &&
|
|
741
|
+
storedSession.active &&
|
|
742
|
+
typeof storedSession.scenario === 'string' &&
|
|
743
|
+
typeof storedSession.runId === 'string'
|
|
744
|
+
? storedSession
|
|
745
|
+
: null;
|
|
746
|
+
if (!activeSession) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
clearPendingProfileCommands();
|
|
751
|
+
for (const command of storedCommands) {
|
|
752
|
+
if (
|
|
753
|
+
!command ||
|
|
754
|
+
typeof command.id !== 'string' ||
|
|
755
|
+
typeof command.command !== 'string' ||
|
|
756
|
+
typeof command.timestamp !== 'number'
|
|
757
|
+
) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (
|
|
762
|
+
command.scenario !== activeSession.scenario ||
|
|
763
|
+
command.runId !== activeSession.runId ||
|
|
764
|
+
(typeof activeSession.startedAt === 'number' && command.timestamp < activeSession.startedAt)
|
|
765
|
+
) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const storageCommand = {
|
|
770
|
+
...command,
|
|
771
|
+
source: 'storage' as const,
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
if (hasProcessedProfileCommandId(storageCommand)) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
markProfileCommandIdProcessed(storageCommand);
|
|
779
|
+
logProfileSession('command', storageCommand);
|
|
780
|
+
notifyProfileCommandListeners(storageCommand);
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
syncStoredProfileState()
|
|
785
|
+
.catch(() => {})
|
|
786
|
+
.finally(() => {
|
|
787
|
+
Linking.getInitialURL()
|
|
788
|
+
.then((url) => {
|
|
789
|
+
if (isMounted) {
|
|
790
|
+
applyProfileSessionUrl(url);
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
.catch(() => {});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const subscription = Linking.addEventListener('url', ({ url }) => {
|
|
797
|
+
applyProfileSessionUrl(url);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
const storagePollInterval = setInterval(() => {
|
|
801
|
+
if (isMounted) {
|
|
802
|
+
syncStoredProfileState().catch(() => {});
|
|
803
|
+
}
|
|
804
|
+
}, PROFILE_STORAGE_POLL_INTERVAL_MS);
|
|
805
|
+
|
|
806
|
+
return () => {
|
|
807
|
+
isMounted = false;
|
|
808
|
+
subscription.remove();
|
|
809
|
+
clearInterval(storagePollInterval);
|
|
810
|
+
};
|
|
811
|
+
}, []);
|
|
812
|
+
}
|