@qubiqlabs/mobiflow 0.9.0 → 1.0.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 (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,1506 @@
1
+ """Maestro CLI adapter: status, devices, YAML gen, live run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import shutil
11
+ import tempfile
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from mobiflow.llm import (
18
+ ChatUsage,
19
+ extract_fenced_files,
20
+ extract_yaml_fence,
21
+ invoke_chat_text,
22
+ merge_usage_list,
23
+ profile_to_llm_config,
24
+ )
25
+ from mobiflow.llm_catalog import ModelEntry
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ ProgressFn = Optional[Callable[[str], None]]
30
+
31
+
32
+ @dataclass
33
+ class FlowBundle:
34
+ """Maestro flow YAML plus optional companion JavaScript files."""
35
+
36
+ flow_yaml: str
37
+ scripts: dict[str, str] = field(default_factory=dict)
38
+ usage: ChatUsage = field(default_factory=ChatUsage)
39
+
40
+ @property
41
+ def has_js(self) -> bool:
42
+ return bool(self.scripts) or bool(
43
+ re.search(r"(?m)^\s*-\s*(evalScript|runScript)\b", self.flow_yaml or "")
44
+ )
45
+
46
+ _KNOWN_APP_IDS = {
47
+ "wikipedia": {"android": "org.wikipedia", "ios": "org.wikimedia.wikipedia"},
48
+ "settings": {"android": "com.android.settings", "ios": "com.apple.Preferences"},
49
+ "chrome": {"android": "com.android.chrome", "ios": "com.google.chrome.ios"},
50
+ "safari": {"android": "com.android.chrome", "ios": "com.apple.mobilesafari"},
51
+ # FOSS sample apps (install yourself — see docs/SAMPLE_APPS.md)
52
+ "joplin": {"android": "net.cozic.joplin", "ios": "net.cozic.joplin"},
53
+ "bitwarden": {"android": "com.x8bit.bitwarden", "ios": "com.8bit.bitwarden"},
54
+ }
55
+
56
+
57
+ def resolve_maestro_binary() -> str | None:
58
+ which = shutil.which("maestro")
59
+ if which:
60
+ return which
61
+ home = Path.home() / ".maestro" / "bin" / "maestro"
62
+ if home.is_file() and os.access(home, os.X_OK):
63
+ return str(home)
64
+ return None
65
+
66
+
67
+ def resolve_java_home() -> str | None:
68
+ jh = os.environ.get("JAVA_HOME")
69
+ if jh and Path(jh).is_dir():
70
+ return jh
71
+ # Common macOS Homebrew / system locations
72
+ for candidate in (
73
+ "/opt/homebrew/opt/openjdk",
74
+ "/usr/local/opt/openjdk",
75
+ ):
76
+ if Path(candidate).is_dir():
77
+ return candidate
78
+ # Windows: JAVA_HOME usually set by installer; also check Program Files
79
+ if platform_system() == "Windows":
80
+ for base in (
81
+ Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Java",
82
+ Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Microsoft",
83
+ Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Eclipse Adoptium",
84
+ ):
85
+ if base.is_dir():
86
+ # pick first jdk-* / jre-* child
87
+ for child in sorted(base.glob("jdk*")) + sorted(base.glob("jre*")):
88
+ if child.is_dir():
89
+ return str(child)
90
+ return jh
91
+
92
+
93
+ def platform_system() -> str:
94
+ import platform
95
+
96
+ return platform.system()
97
+
98
+
99
+ def _maestro_env() -> dict[str, str]:
100
+ env = dict(os.environ)
101
+ env.setdefault("MAESTRO_CLI_NO_ANALYTICS", "1")
102
+ jh = resolve_java_home()
103
+ if jh:
104
+ env.setdefault("JAVA_HOME", jh)
105
+ # Maestro's JVM uses ANDROID_HOME to find adb. On Windows it is often unset
106
+ # even when `adb` works in PATH — then `--device emulator-5554` fails.
107
+ from mobiflow.devices import _sdk_roots, resolve_adb
108
+
109
+ sdk = env.get("ANDROID_HOME") or env.get("ANDROID_SDK_ROOT") or ""
110
+ if not sdk:
111
+ for root in _sdk_roots():
112
+ if (root / "platform-tools").is_dir():
113
+ sdk = str(root)
114
+ break
115
+ if sdk:
116
+ env.setdefault("ANDROID_HOME", sdk)
117
+ env.setdefault("ANDROID_SDK_ROOT", sdk)
118
+ plat = str(Path(sdk) / "platform-tools")
119
+ env["PATH"] = plat + os.pathsep + env.get("PATH", "")
120
+ adb = resolve_adb()
121
+ if adb:
122
+ env["PATH"] = str(Path(adb).parent) + os.pathsep + env.get("PATH", "")
123
+ maestro_bin = resolve_maestro_binary()
124
+ if maestro_bin:
125
+ bin_dir = str(Path(maestro_bin).parent)
126
+ env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
127
+ return env
128
+
129
+
130
+ async def _run_cmd(
131
+ args: list[str],
132
+ *,
133
+ timeout: float = 120.0,
134
+ cwd: str | None = None,
135
+ ) -> dict[str, Any]:
136
+ try:
137
+ proc = await asyncio.create_subprocess_exec(
138
+ *args,
139
+ stdout=asyncio.subprocess.PIPE,
140
+ stderr=asyncio.subprocess.PIPE,
141
+ cwd=cwd,
142
+ env=_maestro_env(),
143
+ )
144
+ except FileNotFoundError as e:
145
+ return {
146
+ "ok": False,
147
+ "returncode": -1,
148
+ "stdout": "",
149
+ "stderr": str(e),
150
+ "error": "executable_not_found",
151
+ }
152
+ try:
153
+ stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
154
+ except TimeoutError:
155
+ try:
156
+ proc.kill()
157
+ except ProcessLookupError:
158
+ pass
159
+ return {
160
+ "ok": False,
161
+ "returncode": -1,
162
+ "stdout": "",
163
+ "stderr": f"Timed out after {timeout}s",
164
+ "error": "timeout",
165
+ }
166
+ stdout = (stdout_b or b"").decode("utf-8", errors="replace")
167
+ stderr = (stderr_b or b"").decode("utf-8", errors="replace")
168
+ code = proc.returncode if proc.returncode is not None else -1
169
+ return {
170
+ "ok": code == 0,
171
+ "returncode": code,
172
+ "stdout": stdout,
173
+ "stderr": stderr,
174
+ "error": None if code == 0 else "nonzero_exit",
175
+ }
176
+
177
+
178
+ async def list_devices() -> list[dict[str, str]]:
179
+ """Online devices only (adb + booted iOS sims)."""
180
+ from mobiflow.devices import list_connected_devices
181
+
182
+ return await list_connected_devices()
183
+
184
+
185
+ async def list_device_targets() -> list[dict[str, str]]:
186
+ """Online + startable AVDs / iOS simulators."""
187
+ from mobiflow.devices import list_all_targets
188
+
189
+ return await list_all_targets()
190
+
191
+
192
+ async def get_maestro_version() -> str | None:
193
+ binary = resolve_maestro_binary()
194
+ if not binary:
195
+ return None
196
+ result = await _run_cmd([binary, "--version"], timeout=20.0)
197
+ text = "\n".join(
198
+ [(result.get("stdout") or "").strip(), (result.get("stderr") or "").strip()]
199
+ )
200
+ for line in text.splitlines():
201
+ m = re.search(r"\b(\d+\.\d+(?:\.\d+)?)\b", line)
202
+ if m:
203
+ return m.group(1)
204
+ return None
205
+
206
+
207
+ async def get_status() -> dict[str, Any]:
208
+ from mobiflow.devices import (
209
+ host_capabilities,
210
+ list_all_targets,
211
+ list_connected_devices,
212
+ )
213
+
214
+ binary = resolve_maestro_binary()
215
+ installed = binary is not None
216
+ java_home = resolve_java_home()
217
+ version = await get_maestro_version() if installed else None
218
+ devices = await list_connected_devices()
219
+ targets = await list_all_targets()
220
+ caps = host_capabilities()
221
+ startable = [t for t in targets if t.get("startable") == "true"]
222
+ ready = bool(installed and java_home and (devices or startable))
223
+ if not installed:
224
+ message = "Maestro CLI not found. Install: curl -Ls https://get.maestro.mobile.dev | bash"
225
+ elif not java_home:
226
+ message = "JAVA_HOME not set — Maestro needs a JDK."
227
+ elif devices:
228
+ message = f"Maestro {version or '?'} ready · {len(devices)} device(s) online"
229
+ elif startable:
230
+ message = (
231
+ f"Maestro {version or '?'} installed · no device online, "
232
+ f"but {len(startable)} emulator/simulator(s) can be auto-started"
233
+ )
234
+ else:
235
+ message = (
236
+ f"Maestro {version or '?'} installed, but no devices/emulators found. "
237
+ "Install Android Studio (AVD) and/or Xcode (macOS)."
238
+ )
239
+ return {
240
+ "installed": installed,
241
+ "binary": binary,
242
+ "java_home": java_home,
243
+ "version": version,
244
+ "devices": devices,
245
+ "targets": targets,
246
+ "device_count": len(devices),
247
+ "startable_count": len(startable),
248
+ "host": caps,
249
+ "ready": ready,
250
+ "message": message,
251
+ }
252
+
253
+
254
+ def infer_platform(device_id: str, fallback: str = "android") -> str:
255
+ did = (device_id or "").strip()
256
+ if re.fullmatch(r"[0-9A-Fa-f-]{36}", did):
257
+ return "ios"
258
+ if did.startswith("emulator-") or ":" in did:
259
+ return "android"
260
+ return (fallback or "android").lower()
261
+
262
+
263
+ def resolve_app_id(app_id: str, platform: str, goal: str = "") -> str:
264
+ if (app_id or "").strip():
265
+ return app_id.strip()
266
+ g = (goal or "").lower()
267
+ plat = (platform or "android").lower()
268
+ for key, mapping in _KNOWN_APP_IDS.items():
269
+ if key in g:
270
+ return mapping.get(plat) or mapping["android"]
271
+ return mapping_default(plat)
272
+
273
+
274
+ def mapping_default(platform: str) -> str:
275
+ return (
276
+ "com.apple.Preferences"
277
+ if platform == "ios"
278
+ else "com.android.settings"
279
+ )
280
+
281
+
282
+ def looks_like_maestro_yaml(text: str) -> bool:
283
+ t = (text or "").strip()
284
+ if not t:
285
+ return False
286
+ if re.search(r"(?m)^appId:\s*\S+", t) and "---" in t:
287
+ return True
288
+ if re.search(r"(?m)^\s*-\s*(launchApp|tapOn|assertVisible|openLink)\b", t):
289
+ return True
290
+ return False
291
+
292
+
293
+ def ensure_flow_yaml(yaml_text: str, app_id: str) -> str:
294
+ text = (yaml_text or "").strip()
295
+ if not text:
296
+ aid = app_id or "com.android.settings"
297
+ return f"appId: {aid}\nname: Generated mobile flow\n---\n- launchApp\n"
298
+ if not re.search(r"(?m)^appId:\s*", text):
299
+ aid = app_id or "com.android.settings"
300
+ if "---" in text:
301
+ return f"appId: {aid}\n---\n" + text.split("---", 1)[-1].lstrip()
302
+ return f"appId: {aid}\nname: Generated mobile flow\n---\n{text}"
303
+ return text
304
+
305
+
306
+ def ensure_stop_app(yaml_text: str) -> str:
307
+ if re.search(r"(?m)^\s*-\s*stopApp\b", yaml_text or ""):
308
+ return yaml_text
309
+ return (yaml_text or "").rstrip() + "\n- stopApp\n"
310
+
311
+
312
+ _MAESTRO_SYSTEM_YAML = """You are a Maestro mobile test engineer.
313
+ Emit ONLY valid Maestro flow YAML (appId config above ---, commands below).
314
+ Rules:
315
+ 1) Cover EVERY goal step — launch, navigate, assert, dismiss onboarding when needed.
316
+ 2) Prefer idiomatic Maestro commands (see docs.maestro.dev):
317
+ launchApp, stopApp, clearState, clearKeychain, openLink,
318
+ tapOn, doubleTapOn, longPressOn, inputText, eraseText, pressKey, hideKeyboard,
319
+ copyTextFrom, pasteText, swipe, scroll, scrollUntilVisible, extendedWaitUntil,
320
+ assertVisible, assertNotVisible, assertTrue, waitForAnimationToEnd,
321
+ takeScreenshot, setLocation, runFlow (subflows), runScript / evalScript (JS projects only).
322
+ 3) Prefer selectors from exploration results / view hierarchy when provided;
323
+ else stable visible text / accessibility ids.
324
+ 4) When exploration results include a grounded plan, follow that plan closely.
325
+ 5) Reuse: extract repeated sequences into nested flows and call with runFlow.
326
+ Use onFlowStart / onFlowComplete hooks for setup/teardown when helpful.
327
+ 6) iOS Settings: com.apple.Preferences. Android Settings: com.android.settings.
328
+ 7) Mobile web (https://): openLink + Safari/Chrome appId. Never emit Playwright/Appium.
329
+ 8) Known apps — use these appIds when the goal names them:
330
+ Wikipedia: org.wikipedia (Android) / org.wikimedia.wikipedia (iOS).
331
+ Joplin: net.cozic.joplin (Android + iOS).
332
+ Bitwarden: com.x8bit.bitwarden (Android) / com.8bit.bitwarden (iOS).
333
+ 9) After launchApp, optionally dismiss Skip/Next/Continue/Allow/Not now.
334
+ 9b) Always end happy-path flows with assertVisible (goal evidence); prefer known selectors.
335
+ 9c) Never use maestro.visible(...) / maestro.isVisible — those APIs do not exist.
336
+ Prefer assertVisible / assertNotVisible. For OR checks, use separate optional
337
+ assertVisible / extendedWaitUntil steps, or one assertVisible with a regex
338
+ like "General|Accessibility". Do not invent assertTrue JS helpers for visibility.
339
+ 10) End the flow with stopApp.
340
+ 11) No markdown prose outside a ```yaml fence.
341
+ 12) Do NOT use evalScript/runScript — YAML commands only for this project."""
342
+
343
+ _MAESTRO_SYSTEM_JS = """You are a Maestro mobile test engineer with JavaScript support enabled.
344
+ Emit a Maestro flow YAML and, when useful, companion JavaScript files.
345
+
346
+ Rules:
347
+ 1) Primary artifact: valid Maestro YAML (appId above ---, commands below).
348
+ 2) UI commands (docs.maestro.dev): launchApp, stopApp, clearState, clearKeychain, openLink,
349
+ tapOn, doubleTapOn, longPressOn, inputText, eraseText, pressKey, hideKeyboard,
350
+ copyTextFrom, pasteText, swipe, scroll, scrollUntilVisible, extendedWaitUntil,
351
+ assertVisible, assertNotVisible, assertTrue, waitForAnimationToEnd, takeScreenshot,
352
+ setLocation, runFlow (subflows).
353
+ 3) JavaScript (GraalJS / modern ES):
354
+ - Use ${expression} for dynamic values in YAML fields.
355
+ - Use evalScript for short inline logic (set output.*, compute values).
356
+ - Use runScript: scripts/<name>.js for reusable helpers.
357
+ - Prefer the global `output` object to share values across steps.
358
+ - You may use console.log for debugging; no Node.js / filesystem APIs.
359
+ - faker may be used for synthetic data when helpful.
360
+ - Optional http helpers for API setup when needed.
361
+ - NEVER call maestro.visible / maestro.isVisible (undefined). Visibility checks
362
+ belong in YAML: assertVisible / assertNotVisible / extendedWaitUntil.
363
+ assertTrue is only for real JS expressions over output.* / env values.
364
+ 4) Prefer selectors from exploration results / view hierarchy when provided;
365
+ else stable visible text / accessibility ids.
366
+ 5) When exploration results include a grounded plan, follow that plan closely.
367
+ 6) Reuse: extract repeated sequences with runFlow; use onFlowStart / onFlowComplete
368
+ for setup/teardown when helpful.
369
+ 7) iOS Settings: com.apple.Preferences. Android Settings: com.android.settings.
370
+ 8) Mobile web (https://): openLink + Safari/Chrome appId. Never emit Playwright/Appium.
371
+ 9) Known apps — use these appIds when the goal names them:
372
+ Wikipedia: org.wikipedia (Android) / org.wikimedia.wikipedia (iOS).
373
+ Joplin: net.cozic.joplin (Android + iOS).
374
+ Bitwarden: com.x8bit.bitwarden (Android) / com.8bit.bitwarden (iOS).
375
+ 10) After launchApp, optionally dismiss Skip/Next/Continue/Allow/Not now.
376
+ 10b) Always end happy-path flows with assertVisible (goal evidence); prefer known selectors.
377
+ 10c) For OR visibility (A or B), prefer assertVisible with regex "A|B" or two optional
378
+ waits plus one hard assertVisible — never assertTrue with maestro.visible.
379
+ 11) End the flow with stopApp.
380
+ 12) Output format — use fenced blocks:
381
+ ```yaml flow.yaml
382
+ ...
383
+ ```
384
+ ```javascript scripts/helpers.js
385
+ ...
386
+ ```
387
+ Only add .js files when the goal needs dynamic data, conditions, or HTTP helpers.
388
+ Keep simple smoke flows YAML-only."""
389
+
390
+ def parse_flow_bundle(text: str, *, app_id: str) -> FlowBundle:
391
+ """Parse LLM (or paste) text into YAML + optional JS scripts."""
392
+ scripts: dict[str, str] = {}
393
+ yaml_body = ""
394
+ for name, body in extract_fenced_files(text or ""):
395
+ lower = name.lower()
396
+ if lower.endswith((".js", ".mjs")) or lower.endswith("javascript"):
397
+ # Normalize to scripts/<file>.js when bare filename
398
+ path = name
399
+ if "/" not in path and "\\" not in path:
400
+ path = f"scripts/{path}"
401
+ # Strip leading // file: line if present
402
+ lines = body.splitlines()
403
+ if lines and re.match(r"^//\s*file:", lines[0], re.IGNORECASE):
404
+ body = "\n".join(lines[1:]).strip()
405
+ scripts[path] = body
406
+ elif lower.endswith((".yaml", ".yml")) or looks_like_maestro_yaml(body):
407
+ if not yaml_body:
408
+ yaml_body = body
409
+ if not yaml_body:
410
+ yaml_body = extract_yaml_fence(text or "")
411
+ yaml_out = ensure_stop_app(ensure_flow_yaml(yaml_body, app_id))
412
+ # Rewrite runScript paths to scripts/… when we emitted helpers there
413
+ yaml_out = _normalize_run_script_paths(yaml_out, scripts)
414
+ return FlowBundle(flow_yaml=yaml_out, scripts=scripts)
415
+
416
+
417
+ def _normalize_run_script_paths(flow_yaml: str, scripts: dict[str, str]) -> str:
418
+ if not scripts:
419
+ return flow_yaml
420
+ # Map basename → preferred relative path
421
+ by_base = {Path(p).name: p for p in scripts}
422
+
423
+ def repl(match: re.Match[str]) -> str:
424
+ raw = match.group(1).strip().strip("\"'")
425
+ base = Path(raw).name
426
+ if base in by_base:
427
+ return f"- runScript: {by_base[base]}"
428
+ if not raw.startswith("scripts/") and base.endswith(".js"):
429
+ return f"- runScript: scripts/{base}"
430
+ return match.group(0)
431
+
432
+ return re.sub(
433
+ r"(?m)^\s*-\s*runScript:\s*(\S+)\s*$",
434
+ repl,
435
+ flow_yaml,
436
+ )
437
+
438
+
439
+ DEFAULT_HELPERS_JS = """\
440
+ // MobiFlow Maestro helpers (GraalJS sandbox — no Node.js APIs)
441
+ // Use via: - runScript: scripts/helpers.js
442
+ // Values on `output` are available later as ${output.key}
443
+
444
+ output.runId = 'run-' + Date.now()
445
+ output.ready = true
446
+ """
447
+
448
+
449
+ _EXTEND_SYSTEM_SUFFIX = """
450
+ INCREMENTAL EXTEND MODE:
451
+ You are EXTENDING an existing Maestro flow. A previous flow YAML is provided.
452
+ - Keep all prior working commands unless a repair is clearly required.
453
+ - Add commands ONLY for the NEW gap steps (after the common prefix).
454
+ - Return a COMPLETE flow YAML (appId + --- + full command list ending in stopApp).
455
+ - Do not relaunch unnecessarily if launchApp is already present.
456
+ - Prefer appending new steps before the final stopApp.
457
+ """
458
+
459
+
460
+ async def generate_flow_bundle(
461
+ goal: str,
462
+ *,
463
+ app_id: str,
464
+ platform: str,
465
+ profile: ModelEntry,
466
+ hierarchy: str = "",
467
+ previous_yaml: str = "",
468
+ previous_scripts: dict[str, str] | None = None,
469
+ failure_log: str = "",
470
+ exploration: str = "",
471
+ allow_js: bool = True,
472
+ extend: bool = False,
473
+ progress: ProgressFn = None,
474
+ ) -> FlowBundle:
475
+ """NL (or pasted YAML) → Maestro FlowBundle (YAML + optional JS)."""
476
+ goal = (goal or "").strip()
477
+ if looks_like_maestro_yaml(goal):
478
+ return parse_flow_bundle(goal, app_id=app_id)
479
+
480
+ resolved = resolve_app_id(app_id, platform, goal)
481
+ # Deterministic shortcuts (YAML-only — no JS needed) when no explore/repair context
482
+ gl = goal.lower()
483
+ if (
484
+ not extend
485
+ and not exploration.strip()
486
+ and not previous_yaml
487
+ and "settings" in gl
488
+ and ("open" in gl or "launch" in gl)
489
+ ):
490
+ return FlowBundle(flow_yaml=_settings_flow(platform))
491
+ if (
492
+ not extend
493
+ and not exploration.strip()
494
+ and not previous_yaml
495
+ and "wikipedia" in gl
496
+ and ("open" in gl or "launch" in gl)
497
+ and "search" not in gl
498
+ ):
499
+ return FlowBundle(flow_yaml=_wikipedia_open_flow(platform, resolved))
500
+
501
+ if progress:
502
+ mode = "extend" if extend else "author"
503
+ progress(
504
+ f"{'Extending' if extend else 'Authoring'} Maestro YAML"
505
+ + (" + JS" if allow_js else "")
506
+ + (" from exploration" if exploration.strip() else "")
507
+ + " with LLM…"
508
+ )
509
+
510
+ system = _MAESTRO_SYSTEM_JS if allow_js else _MAESTRO_SYSTEM_YAML
511
+ if extend and previous_yaml.strip():
512
+ system = system + "\n" + _EXTEND_SYSTEM_SUFFIX
513
+ llm_config = profile_to_llm_config(profile)
514
+ user_parts = [
515
+ f"Platform: {platform or 'android'}",
516
+ f"App ID: {resolved}",
517
+ f"JavaScript enabled: {str(allow_js).lower()}",
518
+ f"Goal:\n{goal}",
519
+ ]
520
+ if exploration.strip():
521
+ user_parts.append(exploration.strip()[:12000])
522
+ if previous_yaml.strip():
523
+ label = "Previous flow YAML to extend" if extend else "Previous flow YAML to repair"
524
+ user_parts.append(
525
+ f"{label}:\n```yaml\n{previous_yaml.strip()}\n```"
526
+ )
527
+ if previous_scripts:
528
+ for name, body in previous_scripts.items():
529
+ user_parts.append(
530
+ f"Previous script `{name}`:\n```javascript {name}\n{body}\n```"
531
+ )
532
+ if failure_log.strip():
533
+ user_parts.append(f"Failure log:\n{failure_log.strip()[:6000]}")
534
+ if hierarchy.strip():
535
+ user_parts.append(
536
+ f"Current view hierarchy (truncated):\n{hierarchy.strip()[:8000]}"
537
+ )
538
+ if extend and previous_yaml.strip():
539
+ user_parts.append(
540
+ "Return a COMPLETE extended Maestro flow in ```yaml flow.yaml``` "
541
+ "(prior steps + new gap steps). End with stopApp."
542
+ )
543
+ elif allow_js:
544
+ user_parts.append(
545
+ "Return ```yaml flow.yaml``` and optional ```javascript scripts/<name>.js```. "
546
+ "End YAML with stopApp."
547
+ )
548
+ else:
549
+ user_parts.append(
550
+ "Return a complete Maestro flow in a ```yaml fence. End with stopApp."
551
+ )
552
+
553
+ usage_bucket: list[ChatUsage] = []
554
+ text = await asyncio.to_thread(
555
+ invoke_chat_text,
556
+ system,
557
+ "\n\n".join(user_parts),
558
+ llm_config,
559
+ max_tokens=4096,
560
+ temperature=0.2,
561
+ log_prefix="MobiFlow",
562
+ usage_out=usage_bucket,
563
+ )
564
+ bundle = parse_flow_bundle(text or "", app_id=resolved)
565
+ bundle.usage = merge_usage_list(usage_bucket)
566
+ if extend and previous_yaml.strip():
567
+ from mobiflow.incremental import merge_flow_yaml
568
+
569
+ bundle.flow_yaml = merge_flow_yaml(
570
+ previous_yaml, bundle.flow_yaml, app_id=resolved
571
+ )
572
+ if not allow_js:
573
+ # Strip JS if project disabled it
574
+ bundle.scripts = {}
575
+ # Remove runScript/evalScript lines if model ignored instructions
576
+ cleaned = re.sub(
577
+ r"(?m)^\s*-\s*(evalScript|runScript):.*(?:\n(?:\s{2,}.+)*)?",
578
+ "",
579
+ bundle.flow_yaml,
580
+ )
581
+ bundle.flow_yaml = ensure_stop_app(cleaned)
582
+ return bundle
583
+
584
+
585
+ async def generate_flow_yaml(
586
+ goal: str,
587
+ *,
588
+ app_id: str,
589
+ platform: str,
590
+ profile: ModelEntry,
591
+ hierarchy: str = "",
592
+ previous_yaml: str = "",
593
+ failure_log: str = "",
594
+ exploration: str = "",
595
+ allow_js: bool = True,
596
+ progress: ProgressFn = None,
597
+ ) -> str:
598
+ """NL → Maestro flow YAML (compat wrapper)."""
599
+ bundle = await generate_flow_bundle(
600
+ goal,
601
+ app_id=app_id,
602
+ platform=platform,
603
+ profile=profile,
604
+ hierarchy=hierarchy,
605
+ previous_yaml=previous_yaml,
606
+ failure_log=failure_log,
607
+ exploration=exploration,
608
+ allow_js=allow_js,
609
+ progress=progress,
610
+ )
611
+ return bundle.flow_yaml
612
+
613
+
614
+ def _settings_flow(platform: str) -> str:
615
+ aid = "com.apple.Preferences" if platform == "ios" else "com.android.settings"
616
+ visible = "Settings|General|Wi-Fi|Network" if platform == "ios" else "Settings|Network|Apps"
617
+ return (
618
+ f"appId: {aid}\n"
619
+ f"name: Open system Settings\n"
620
+ f"---\n"
621
+ f"- launchApp\n"
622
+ f'- assertVisible: "{visible}"\n'
623
+ f"- stopApp\n"
624
+ )
625
+
626
+
627
+ def _wikipedia_open_flow(platform: str, app_id: str) -> str:
628
+ aid = app_id or (
629
+ "org.wikimedia.wikipedia" if platform == "ios" else "org.wikipedia"
630
+ )
631
+ return (
632
+ f"appId: {aid}\n"
633
+ f"name: Open Wikipedia\n"
634
+ f"---\n"
635
+ f"- launchApp\n"
636
+ f"- tapOn:\n"
637
+ f' text: "Skip|Next|Continue|Get started|Allow|Not now"\n'
638
+ f" optional: true\n"
639
+ f'- assertVisible: "Search|Explore|Wikipedia"\n'
640
+ f"- stopApp\n"
641
+ )
642
+
643
+
644
+ async def fetch_hierarchy(device_id: str | None = None) -> str:
645
+ binary = resolve_maestro_binary()
646
+ if not binary:
647
+ return ""
648
+ args = [binary, "hierarchy"]
649
+ if device_id:
650
+ args.extend(["--device", device_id])
651
+ result = await _run_cmd(args, timeout=60.0)
652
+ return (result.get("stdout") or "")[:12000]
653
+
654
+
655
+ def _maestro_test_args(
656
+ binary: str,
657
+ flow_path: Path,
658
+ *,
659
+ device_id: str | None = None,
660
+ artifact_dir: Path | None = None,
661
+ flow_env: dict[str, str] | None = None,
662
+ include_tags: list[str] | None = None,
663
+ exclude_tags: list[str] | None = None,
664
+ maestro_config: str | Path | None = None,
665
+ platform: str | None = None,
666
+ ) -> list[str]:
667
+ from mobiflow.secrets import maestro_env_args
668
+
669
+ args = [binary, "test", str(flow_path)]
670
+ if device_id:
671
+ args.extend(["--device", device_id])
672
+ if platform and platform.lower() in {"ios", "android", "web"}:
673
+ args.extend(["--platform", platform.lower()])
674
+ if flow_env:
675
+ args.extend(maestro_env_args(flow_env))
676
+ if include_tags:
677
+ args.append("--include-tags=" + ",".join(include_tags))
678
+ if exclude_tags:
679
+ args.append("--exclude-tags=" + ",".join(exclude_tags))
680
+ cfg = str(maestro_config or "").strip()
681
+ if cfg and Path(cfg).is_file():
682
+ args.extend(["--config", cfg])
683
+ if artifact_dir is not None:
684
+ artifact_dir.mkdir(parents=True, exist_ok=True)
685
+ debug_dir = artifact_dir / "maestro-debug"
686
+ test_out = artifact_dir / "maestro-output"
687
+ junit_path = artifact_dir / "maestro-junit.xml"
688
+ debug_dir.mkdir(parents=True, exist_ok=True)
689
+ test_out.mkdir(parents=True, exist_ok=True)
690
+ args.extend(
691
+ [
692
+ "--debug-output",
693
+ str(debug_dir),
694
+ "--flatten-debug-output",
695
+ "--test-output-dir",
696
+ str(test_out),
697
+ "--format",
698
+ "JUNIT",
699
+ "--output",
700
+ str(junit_path),
701
+ ]
702
+ )
703
+ return args
704
+
705
+
706
+ def find_local_videos(root: Path, *, limit: int = 8) -> list[Path]:
707
+ """Find MP4/WebM/MOV artifacts under a Maestro run directory."""
708
+ if not root.exists():
709
+ return []
710
+ found: list[Path] = []
711
+ for path in sorted(root.rglob("*")):
712
+ if path.is_file() and path.suffix.lower() in {".mp4", ".webm", ".mov", ".m4v"}:
713
+ found.append(path)
714
+ if len(found) >= limit:
715
+ break
716
+ return found
717
+
718
+
719
+ async def _maybe_record_video(
720
+ binary: str,
721
+ flow_path: Path,
722
+ *,
723
+ cwd: Path,
724
+ device_id: str | None,
725
+ artifact_dir: Path,
726
+ flow_env: dict[str, str] | None,
727
+ timeout_s: float,
728
+ progress: ProgressFn = None,
729
+ ) -> str:
730
+ """Run `maestro record --local` and return absolute video path if produced."""
731
+ from mobiflow.secrets import maestro_env_args
732
+
733
+ videos = artifact_dir / "videos"
734
+ videos.mkdir(parents=True, exist_ok=True)
735
+ out_mp4 = videos / "execution.mp4"
736
+ args = [
737
+ binary,
738
+ "record",
739
+ str(flow_path),
740
+ "--local",
741
+ str(out_mp4),
742
+ ]
743
+ if device_id:
744
+ args.extend(["--device", device_id])
745
+ if flow_env:
746
+ args.extend(maestro_env_args(flow_env))
747
+ debug_dir = artifact_dir / "maestro-record-debug"
748
+ debug_dir.mkdir(parents=True, exist_ok=True)
749
+ args.extend(["--debug-output", str(debug_dir)])
750
+ if progress:
751
+ progress("Recording execution video (`maestro record --local`)…")
752
+ result = await _run_cmd(args, timeout=timeout_s, cwd=str(cwd))
753
+ if out_mp4.is_file() and out_mp4.stat().st_size > 0:
754
+ return str(out_mp4.resolve())
755
+ # Some CLI versions write beside cwd / debug dir
756
+ for cand in find_local_videos(artifact_dir):
757
+ return str(cand.resolve())
758
+ if not result.get("ok") and progress:
759
+ err = (result.get("stderr") or result.get("error") or "record_failed")[:200]
760
+ progress(f"Video record skipped: {err}")
761
+ return ""
762
+
763
+
764
+ async def run_flow_yaml(
765
+ flow_yaml: str,
766
+ *,
767
+ device_id: str | None = None,
768
+ timeout_s: int = 180,
769
+ scripts: dict[str, str] | None = None,
770
+ work_dir: Path | None = None,
771
+ progress: ProgressFn = None,
772
+ device_config: Any = None,
773
+ platform: str | None = None,
774
+ artifact_dir: Path | None = None,
775
+ flow_env: dict[str, str] | None = None,
776
+ record_video: bool = False,
777
+ include_tags: list[str] | None = None,
778
+ exclude_tags: list[str] | None = None,
779
+ maestro_config: str | Path | None = None,
780
+ ) -> dict[str, Any]:
781
+ """Run Maestro YAML locally or on a cloud device lab.
782
+
783
+ When ``device_config.provider`` is ``browserstack``, ``testmu``, or ``maestro``,
784
+ uploads the flow (and app) and executes via that lab instead of local ``maestro test``.
785
+
786
+ For local runs, ``artifact_dir`` enables Maestro ``--debug-output``,
787
+ ``--test-output-dir``, and JUnit ``--format/--output``. When ``record_video``
788
+ is true, also runs ``maestro record --local`` for an MP4 artifact.
789
+ """
790
+ from mobiflow.cloud.base import is_cloud_provider
791
+
792
+ if device_config is not None and is_cloud_provider(
793
+ getattr(device_config, "provider", "local")
794
+ ):
795
+ from mobiflow.cloud.runner import request_from_device_config, run_on_cloud
796
+
797
+ if progress:
798
+ progress(
799
+ f"Running on cloud provider={device_config.provider} "
800
+ f"device={device_id or device_config.device_id or '(default)'}…"
801
+ )
802
+ req = request_from_device_config(
803
+ device_config,
804
+ flow_yaml=flow_yaml,
805
+ scripts=scripts,
806
+ platform=platform or getattr(device_config, "platform", "android"),
807
+ device_id=device_id,
808
+ timeout_s=max(
809
+ timeout_s, int(getattr(device_config, "cloud_timeout_s", 1800) or 1800)
810
+ ),
811
+ )
812
+ cloud_result = await run_on_cloud(
813
+ req, progress=progress, artifact_dir=artifact_dir
814
+ )
815
+ out = cloud_result.as_run_dict()
816
+ out["flow_yaml"] = flow_yaml
817
+ out["scripts"] = scripts or {}
818
+ if artifact_dir is not None:
819
+ artifact_dir.mkdir(parents=True, exist_ok=True)
820
+ (artifact_dir / "cloud-result.json").write_text(
821
+ json.dumps(
822
+ {
823
+ "provider": out.get("provider"),
824
+ "build_id": out.get("build_id"),
825
+ "status": out.get("status"),
826
+ "dashboard_url": out.get("dashboard_url"),
827
+ "video_url": out.get("video_url"),
828
+ "media_files": out.get("media_files"),
829
+ "media_dir": out.get("media_dir"),
830
+ "ok": out.get("ok"),
831
+ },
832
+ indent=2,
833
+ )
834
+ + "\n",
835
+ encoding="utf-8",
836
+ )
837
+ out["artifact_dir"] = str(artifact_dir)
838
+ # Mirror downloaded cloud screenshots into screenshots/ for reports
839
+ media_dir = Path(out["media_dir"]) if out.get("media_dir") else (
840
+ artifact_dir / "cloud"
841
+ )
842
+ if media_dir.is_dir():
843
+ shots = artifact_dir / "screenshots"
844
+ for src in sorted(media_dir.iterdir()):
845
+ if src.is_file() and src.suffix.lower() in {
846
+ ".png",
847
+ ".jpg",
848
+ ".jpeg",
849
+ ".webp",
850
+ ".gif",
851
+ }:
852
+ shots.mkdir(parents=True, exist_ok=True)
853
+ dest = shots / src.name
854
+ if not dest.exists():
855
+ dest.write_bytes(src.read_bytes())
856
+ return out
857
+
858
+ binary = resolve_maestro_binary()
859
+ if not binary:
860
+ return {
861
+ "ok": False,
862
+ "error": "maestro_not_installed",
863
+ "stdout": "",
864
+ "stderr": "Maestro CLI not found",
865
+ "returncode": -1,
866
+ }
867
+
868
+ def _write_bundle(root: Path) -> Path:
869
+ flow_path = root / "flow.yaml"
870
+ flow_path.write_text(flow_yaml, encoding="utf-8")
871
+ for rel, body in (scripts or {}).items():
872
+ sp = root / rel
873
+ sp.parent.mkdir(parents=True, exist_ok=True)
874
+ sp.write_text(body, encoding="utf-8")
875
+ return flow_path
876
+
877
+ async def _run_local(root: Path, art: Path | None) -> dict[str, Any]:
878
+ flow_path = _write_bundle(root)
879
+ args = _maestro_test_args(
880
+ binary,
881
+ flow_path,
882
+ device_id=device_id,
883
+ artifact_dir=art,
884
+ flow_env=flow_env,
885
+ include_tags=include_tags,
886
+ exclude_tags=exclude_tags,
887
+ maestro_config=maestro_config,
888
+ platform=platform,
889
+ )
890
+ if progress:
891
+ progress(f"Running maestro test{' on ' + device_id if device_id else ''}…")
892
+ result = await _run_cmd(args, timeout=float(timeout_s), cwd=str(root))
893
+ result["flow_yaml"] = flow_yaml
894
+ result["scripts"] = scripts or {}
895
+ if art is not None:
896
+ result["artifact_dir"] = str(art)
897
+ junit = art / "maestro-junit.xml"
898
+ if junit.is_file():
899
+ result["maestro_junit"] = str(junit)
900
+ try:
901
+ (art / "flow.yaml").write_text(flow_yaml, encoding="utf-8")
902
+ except OSError:
903
+ pass
904
+ result["maestro_debug_dir"] = str(art / "maestro-debug")
905
+ result["maestro_output_dir"] = str(art / "maestro-output")
906
+ video_path = ""
907
+ if record_video:
908
+ video_path = await _maybe_record_video(
909
+ binary,
910
+ flow_path,
911
+ cwd=root,
912
+ device_id=device_id,
913
+ artifact_dir=art,
914
+ flow_env=flow_env,
915
+ timeout_s=float(timeout_s),
916
+ progress=progress,
917
+ )
918
+ if not video_path:
919
+ found = find_local_videos(art)
920
+ if found:
921
+ video_path = str(found[0].resolve())
922
+ if video_path:
923
+ result["video_url"] = video_path
924
+ # Also copy into videos/ for stable report paths
925
+ try:
926
+ vdir = art / "videos"
927
+ vdir.mkdir(parents=True, exist_ok=True)
928
+ src = Path(video_path)
929
+ dest = vdir / src.name
930
+ if src.resolve() != dest.resolve() and src.is_file():
931
+ dest.write_bytes(src.read_bytes())
932
+ result["video_url"] = str(dest.resolve())
933
+ except OSError:
934
+ pass
935
+ result["work_dir"] = str(root)
936
+ return result
937
+
938
+ if work_dir is not None:
939
+ work_dir.mkdir(parents=True, exist_ok=True)
940
+ return await _run_local(work_dir, artifact_dir)
941
+
942
+ if artifact_dir is not None:
943
+ root = Path(artifact_dir) / "bundle"
944
+ root.mkdir(parents=True, exist_ok=True)
945
+ return await _run_local(root, artifact_dir)
946
+
947
+ with tempfile.TemporaryDirectory(prefix="mobiflow-") as tmp:
948
+ return await _run_local(Path(tmp), None)
949
+
950
+
951
+ async def run_mobile_task(
952
+ goal: str,
953
+ *,
954
+ codegen_profile: ModelEntry,
955
+ discovery_profile: ModelEntry | None = None,
956
+ app_id: str = "",
957
+ platform: str = "android",
958
+ device_id: str | None = None,
959
+ heal: int = 2,
960
+ adaptive: bool = True,
961
+ explore: bool = True,
962
+ explore_steps: int = 5,
963
+ timeout_s: int = 180,
964
+ live: bool = True,
965
+ allow_js: bool = True,
966
+ auto_start_device: bool = True,
967
+ progress: ProgressFn = None,
968
+ device_config: Any = None,
969
+ artifact_dir: Path | None = None,
970
+ clear_state: bool = False,
971
+ preflight: list[str] | None = None,
972
+ app_path: str = "",
973
+ retries: int = 0,
974
+ reuse_flow_yaml: str | None = None,
975
+ reuse_scripts: dict[str, str] | None = None,
976
+ flow_env: dict[str, str] | None = None,
977
+ expect: list[str] | None = None,
978
+ prior_flow_yaml: str | None = None,
979
+ prior_scripts: dict[str, str] | None = None,
980
+ extend: bool = False,
981
+ replay_prefix: bool = False,
982
+ explore_goal: str | None = None,
983
+ codegen_goal: str | None = None,
984
+ record_video: bool = False,
985
+ include_tags: list[str] | None = None,
986
+ exclude_tags: list[str] | None = None,
987
+ maestro_config: str | Path | None = None,
988
+ ) -> dict[str, Any]:
989
+ """Full agent loop: preflight → explore → author YAML(+JS) → run → heal.
990
+
991
+ ``retries`` re-runs the same YAML before each heal. ``reuse_flow_yaml``
992
+ skips explore/codegen and executes the provided flow (optionally with heal).
993
+ ``flow_env`` is passed to Maestro as ``--env KEY=VALUE``.
994
+
995
+ Incremental / extend modes (mutually exclusive with reuse at the pipeline layer):
996
+ - ``replay_prefix``: run prior YAML without ``stopApp``, then explore ``explore_goal``
997
+ - ``extend``: codegen extends ``prior_flow_yaml`` for new steps
998
+ """
999
+ from mobiflow.cloud.base import is_cloud_provider
1000
+ from mobiflow.devices import ensure_device
1001
+ from mobiflow.explore import ExplorationResult, explore_app, plan_only_explore
1002
+ from mobiflow.maestro.lifecycle import normalize_preflight, run_preflight
1003
+
1004
+ logs: list[str] = []
1005
+ run_root = Path(artifact_dir) if artifact_dir else None
1006
+ if run_root is not None:
1007
+ run_root.mkdir(parents=True, exist_ok=True)
1008
+ discovery = discovery_profile or codegen_profile
1009
+ preflight_meta: dict[str, Any] = {}
1010
+
1011
+ def _p(msg: str) -> None:
1012
+ logs.append(msg)
1013
+ if progress:
1014
+ progress(msg)
1015
+
1016
+ cloud = bool(
1017
+ device_config is not None
1018
+ and is_cloud_provider(getattr(device_config, "provider", "local"))
1019
+ )
1020
+ provider = (
1021
+ getattr(device_config, "provider", "local") if device_config is not None else "local"
1022
+ )
1023
+
1024
+ status = await get_status()
1025
+ if cloud:
1026
+ from mobiflow.cloud import cloud_readiness
1027
+
1028
+ ready = cloud_readiness(device_config)
1029
+ _p(ready.get("message") or f"Cloud provider={provider}")
1030
+ # Cloud runs do not need local Maestro CLI / adb
1031
+ status = {
1032
+ **status,
1033
+ "ready": bool(ready.get("ready")),
1034
+ "cloud": ready,
1035
+ "message": ready.get("message") or status.get("message"),
1036
+ }
1037
+ else:
1038
+ _p(status.get("message") or "Checking Maestro…")
1039
+
1040
+ selected = (device_id or "").strip()
1041
+ if cloud:
1042
+ selected = selected or (getattr(device_config, "device_id", None) or "")
1043
+ selected = (selected or "").strip()
1044
+ if not selected:
1045
+ from mobiflow.cloud.base import devices_from_config, normalize_provider
1046
+
1047
+ selected = devices_from_config(
1048
+ None,
1049
+ platform=platform,
1050
+ provider=normalize_provider(provider),
1051
+ )[0]
1052
+ _p(f"Using default cloud device: {selected}")
1053
+ elif live:
1054
+ boot_timeout = float(timeout_s or 120)
1055
+ if device_config is not None and getattr(device_config, "boot_timeout_s", None):
1056
+ boot_timeout = float(device_config.boot_timeout_s)
1057
+ ensured = await ensure_device(
1058
+ platform_pref=platform,
1059
+ device_id=selected or None,
1060
+ auto_start=auto_start_device,
1061
+ timeout_s=max(90.0, boot_timeout),
1062
+ progress=_p,
1063
+ use_maestro_cli=bool(
1064
+ getattr(device_config, "use_maestro_cli", True)
1065
+ if device_config is not None
1066
+ else True
1067
+ ),
1068
+ device_model=(
1069
+ str(getattr(device_config, "device_model", "") or "")
1070
+ if device_config is not None
1071
+ else ""
1072
+ ),
1073
+ device_os=(
1074
+ str(getattr(device_config, "device_os", "") or "")
1075
+ if device_config is not None
1076
+ else ""
1077
+ ),
1078
+ device_locale=(
1079
+ str(getattr(device_config, "device_locale", "") or "")
1080
+ if device_config is not None
1081
+ else ""
1082
+ ),
1083
+ )
1084
+ if ensured.get("ok") and ensured.get("device"):
1085
+ selected = ensured["device"].get("id") or selected
1086
+ platform = ensured["device"].get("platform") or platform
1087
+ if ensured.get("started"):
1088
+ _p(f"Auto-started device {selected}")
1089
+ elif auto_start_device:
1090
+ _p(ensured.get("message") or "Could not ensure a device")
1091
+ else:
1092
+ # gen-only: still prefer an online id if present
1093
+ devices = list(status.get("devices") or [])
1094
+ if not selected and devices:
1095
+ plat = (platform or "").lower()
1096
+ match = next((d for d in devices if d.get("platform") == plat), None)
1097
+ selected = (match or devices[0]).get("id") or ""
1098
+
1099
+ if selected and not cloud:
1100
+ platform = infer_platform(selected, platform)
1101
+
1102
+ # Local lifecycle: install APK + Maestro clearState before explore/run
1103
+ preflight_steps = normalize_preflight(preflight)
1104
+ pkg_path = (
1105
+ app_path
1106
+ or (getattr(device_config, "app_path", "") if device_config is not None else "")
1107
+ or ""
1108
+ ).strip()
1109
+ if live and selected and not cloud and (preflight_steps or clear_state):
1110
+ preflight_meta = await run_preflight(
1111
+ app_id=resolve_app_id(app_id, platform, goal),
1112
+ platform=platform,
1113
+ device_id=selected,
1114
+ steps=preflight_steps,
1115
+ app_path=pkg_path,
1116
+ clear_state=clear_state,
1117
+ progress=_p,
1118
+ timeout_s=min(90, int(timeout_s) or 90),
1119
+ )
1120
+ if not preflight_meta.get("ok"):
1121
+ return {
1122
+ "success": False,
1123
+ "summary": preflight_meta.get("message") or "preflight failed",
1124
+ "error": preflight_meta.get("error") or "preflight_failed",
1125
+ "logs": logs,
1126
+ "device_id": selected,
1127
+ "platform": platform,
1128
+ "provider": provider,
1129
+ "preflight": preflight_meta,
1130
+ "run": {},
1131
+ }
1132
+ if preflight_meta.get("steps"):
1133
+ _p("Preflight done: " + ", ".join(preflight_meta["steps"]))
1134
+ elif live and cloud and (clear_state or "clear" in preflight_steps):
1135
+ _p("Preflight clearState is local-only — cloud install uses device.app_path")
1136
+
1137
+ scripts: dict[str, str] = {}
1138
+ exploration = ExplorationResult(
1139
+ goal=goal,
1140
+ app_id=app_id,
1141
+ platform=platform,
1142
+ mode="skipped",
1143
+ )
1144
+ exploration_prompt = ""
1145
+ codegen_usage = ChatUsage()
1146
+ max_retries = max(0, min(int(retries or 0), 10))
1147
+ from mobiflow.selectors import (
1148
+ ensure_expect_asserts,
1149
+ load_selector_memory,
1150
+ memory_to_prompt_block,
1151
+ merge_selectors,
1152
+ save_selector_memory,
1153
+ )
1154
+
1155
+ resolved_app = resolve_app_id(app_id, platform, goal)
1156
+ # run_root = .mobiflow/runs/<case-ts> → artifacts root = .mobiflow
1157
+ art_root = run_root.parent.parent if run_root is not None else None
1158
+ selector_memory: dict[str, Any] = (
1159
+ load_selector_memory(art_root, resolved_app) if art_root is not None else {}
1160
+ )
1161
+ mem_block = memory_to_prompt_block(selector_memory)
1162
+
1163
+ def _persist_selectors(success: bool) -> None:
1164
+ if art_root is None:
1165
+ return
1166
+ sels = list(exploration.selectors or [])
1167
+ if not sels and not selector_memory:
1168
+ return
1169
+ updated = merge_selectors(selector_memory, sels, success=success)
1170
+ try:
1171
+ save_selector_memory(art_root, resolved_app, updated)
1172
+ except OSError:
1173
+ pass
1174
+
1175
+ # Frozen / reused flow — skip explore + codegen
1176
+ if reuse_flow_yaml and looks_like_maestro_yaml(reuse_flow_yaml):
1177
+ bundle = parse_flow_bundle(reuse_flow_yaml, app_id=app_id)
1178
+ flow = bundle.flow_yaml
1179
+ scripts = dict(reuse_scripts or {}) or bundle.scripts
1180
+ _p("Reusing frozen Maestro YAML (skipped explore/codegen).")
1181
+ # Paste-to-run
1182
+ elif looks_like_maestro_yaml(goal):
1183
+ bundle = parse_flow_bundle(goal, app_id=app_id)
1184
+ flow = bundle.flow_yaml
1185
+ scripts = bundle.scripts
1186
+ _p("Detected Maestro YAML — running as-is.")
1187
+ else:
1188
+ from mobiflow.incremental import strip_trailing_stop_app
1189
+
1190
+ explore_text = (explore_goal or goal).strip() or goal
1191
+ codegen_text = (codegen_goal or goal).strip() or goal
1192
+ prior_yaml = (prior_flow_yaml or "").strip()
1193
+ use_extend = bool(extend and prior_yaml)
1194
+ seeded_scripts = dict(prior_scripts or {})
1195
+
1196
+ # Incremental append: replay known prefix so gap explore starts mid-flow
1197
+ if (
1198
+ replay_prefix
1199
+ and prior_yaml
1200
+ and live
1201
+ and selected
1202
+ and not cloud
1203
+ and status.get("installed")
1204
+ ):
1205
+ prefix_yaml = strip_trailing_stop_app(prior_yaml)
1206
+ _p("Replaying prior flow prefix (leaving app open for gap explore)…")
1207
+ prefix_dir = None
1208
+ if run_root is not None:
1209
+ prefix_dir = run_root / "prefix-replay"
1210
+ prefix_dir.mkdir(parents=True, exist_ok=True)
1211
+ prefix_run = await run_flow_yaml(
1212
+ prefix_yaml,
1213
+ device_id=selected,
1214
+ timeout_s=timeout_s,
1215
+ scripts=seeded_scripts or None,
1216
+ progress=_p,
1217
+ device_config=device_config,
1218
+ platform=platform,
1219
+ artifact_dir=prefix_dir,
1220
+ flow_env=flow_env,
1221
+ record_video=False,
1222
+ include_tags=include_tags,
1223
+ exclude_tags=exclude_tags,
1224
+ maestro_config=maestro_config,
1225
+ )
1226
+ if not prefix_run.get("ok"):
1227
+ _p("Prefix replay failed — falling back to full explore + extend codegen.")
1228
+ explore_text = goal
1229
+ codegen_text = goal
1230
+ use_extend = True
1231
+ else:
1232
+ _p("Prefix replay ok — exploring new steps only.")
1233
+
1234
+ # --- Explore phase (discovery LLM) before codegen ---
1235
+ want_explore = bool(explore and discovery is not None)
1236
+ if want_explore and live and selected and not cloud and status.get("installed"):
1237
+ try:
1238
+ exploration = await explore_app(
1239
+ explore_text,
1240
+ app_id=app_id,
1241
+ platform=platform,
1242
+ device_id=selected,
1243
+ profile=discovery,
1244
+ max_steps=explore_steps,
1245
+ step_timeout_s=min(90, max(30, timeout_s // 2)),
1246
+ progress=_p,
1247
+ )
1248
+ except Exception as e: # noqa: BLE001
1249
+ _p(f"Explore failed ({e}); falling back to hierarchy snapshot.")
1250
+ exploration = ExplorationResult(
1251
+ goal=explore_text,
1252
+ app_id=app_id,
1253
+ platform=platform,
1254
+ mode="skipped",
1255
+ notes=[f"explore_error: {e}"],
1256
+ )
1257
+ elif want_explore:
1258
+ # Cloud / no device: plan-only exploration from the goal text
1259
+ try:
1260
+ exploration = await plan_only_explore(
1261
+ goal=explore_text,
1262
+ app_id=app_id,
1263
+ platform=platform,
1264
+ profile=discovery,
1265
+ progress=_p,
1266
+ )
1267
+ except Exception as e: # noqa: BLE001
1268
+ _p(f"Plan-only explore failed ({e}); continuing without it.")
1269
+
1270
+ exploration_prompt = exploration.to_prompt_block()
1271
+ if mem_block:
1272
+ exploration_prompt = (
1273
+ (exploration_prompt + "\n\n" + mem_block).strip()
1274
+ if exploration_prompt
1275
+ else mem_block
1276
+ )
1277
+ if run_root is not None and exploration.mode != "skipped":
1278
+ try:
1279
+ (run_root / "exploration.json").write_text(
1280
+ json.dumps(exploration.to_dict(), indent=2) + "\n",
1281
+ encoding="utf-8",
1282
+ )
1283
+ except OSError:
1284
+ pass
1285
+
1286
+ hierarchy = exploration.final_hierarchy or ""
1287
+ if (
1288
+ not hierarchy
1289
+ and live
1290
+ and adaptive
1291
+ and selected
1292
+ and status.get("installed")
1293
+ and not cloud
1294
+ ):
1295
+ _p("Fetching view hierarchy…")
1296
+ hierarchy = await fetch_hierarchy(selected)
1297
+ elif cloud and adaptive and not exploration_prompt:
1298
+ _p("Skipping local hierarchy (cloud provider) — heal uses failure logs only.")
1299
+
1300
+ bundle = await generate_flow_bundle(
1301
+ codegen_text,
1302
+ app_id=app_id,
1303
+ platform=platform,
1304
+ profile=codegen_profile,
1305
+ hierarchy=hierarchy,
1306
+ previous_yaml=prior_yaml if (use_extend or prior_yaml) else "",
1307
+ previous_scripts=seeded_scripts or None,
1308
+ exploration=exploration_prompt,
1309
+ allow_js=allow_js,
1310
+ extend=use_extend,
1311
+ progress=_p,
1312
+ )
1313
+ flow = bundle.flow_yaml
1314
+ scripts = dict(bundle.scripts)
1315
+ codegen_usage = codegen_usage.merged(bundle.usage)
1316
+ # Keep prior companion scripts when extend did not re-emit them
1317
+ for rel, body in seeded_scripts.items():
1318
+ scripts.setdefault(rel, body)
1319
+
1320
+ if expect:
1321
+ flow = ensure_expect_asserts(flow, list(expect))
1322
+ if scripts:
1323
+ _p(f"Maestro bundle ready ({len(scripts)} JS file(s)).")
1324
+ else:
1325
+ _p("Maestro YAML ready.")
1326
+ result: dict[str, Any] = {
1327
+ "success": False,
1328
+ "flow_yaml": flow,
1329
+ "scripts": scripts,
1330
+ "device_id": selected or None,
1331
+ "platform": platform,
1332
+ "provider": provider,
1333
+ "logs": logs,
1334
+ "synthesis_only": False,
1335
+ "maestro_status": status,
1336
+ "exploration": exploration.to_dict() if exploration.mode != "skipped" else None,
1337
+ "explore_usage": exploration.usage.to_dict(),
1338
+ "codegen_usage": codegen_usage.to_dict(),
1339
+ "preflight": preflight_meta or None,
1340
+ }
1341
+
1342
+ can_run = False
1343
+ if live and selected:
1344
+ if cloud:
1345
+ can_run = bool((status.get("cloud") or {}).get("ready"))
1346
+ else:
1347
+ can_run = bool(status.get("installed"))
1348
+
1349
+ if not live or not can_run:
1350
+ result["success"] = True
1351
+ result["synthesis_only"] = True
1352
+ if not live:
1353
+ result["summary"] = "Flow generated (--gen-only; skipped device run)."
1354
+ elif cloud:
1355
+ result["summary"] = (
1356
+ "Flow generated (cloud lab not ready — skipped run). "
1357
+ + str((status.get("cloud") or {}).get("message") or "")
1358
+ ).strip()
1359
+ else:
1360
+ result["summary"] = "Flow generated (no live device / Maestro — skipped run)."
1361
+ _p(result["summary"])
1362
+ return result
1363
+
1364
+ heal_budget = max(0, int(heal or 0))
1365
+ heal_round = 0
1366
+ attempt = 0
1367
+ last_run: dict[str, Any] = {}
1368
+ attempts_meta: list[dict[str, Any]] = []
1369
+ # Order: execute → retry N (same YAML) → heal → repeat
1370
+ while True:
1371
+ passed = False
1372
+ for retry_i in range(max_retries + 1):
1373
+ attempt += 1
1374
+ label = f"heal {heal_round}/{heal_budget}"
1375
+ if max_retries:
1376
+ label += f" retry {retry_i}/{max_retries}"
1377
+ _p(f"Device run attempt {attempt} ({label})…")
1378
+ attempt_dir = None
1379
+ if run_root is not None:
1380
+ attempt_dir = run_root / "attempts" / f"{attempt:02d}"
1381
+ attempt_dir.mkdir(parents=True, exist_ok=True)
1382
+ last_run = await run_flow_yaml(
1383
+ flow,
1384
+ device_id=selected,
1385
+ timeout_s=timeout_s,
1386
+ scripts=scripts,
1387
+ progress=_p,
1388
+ device_config=device_config,
1389
+ platform=platform,
1390
+ artifact_dir=attempt_dir,
1391
+ flow_env=flow_env,
1392
+ record_video=record_video and not cloud,
1393
+ include_tags=include_tags,
1394
+ exclude_tags=exclude_tags,
1395
+ maestro_config=maestro_config,
1396
+ )
1397
+ attempts_meta.append(
1398
+ {
1399
+ "attempt": attempt,
1400
+ "heal_round": heal_round,
1401
+ "retry": retry_i,
1402
+ "ok": bool(last_run.get("ok")),
1403
+ "artifact_dir": last_run.get("artifact_dir"),
1404
+ "error": last_run.get("error"),
1405
+ "build_id": last_run.get("build_id"),
1406
+ "dashboard_url": last_run.get("dashboard_url"),
1407
+ }
1408
+ )
1409
+ result["attempts"] = attempts_meta
1410
+ result["artifact_dir"] = (
1411
+ str(run_root) if run_root else last_run.get("artifact_dir")
1412
+ )
1413
+ if last_run.get("ok"):
1414
+ passed = True
1415
+ break
1416
+ if retry_i < max_retries:
1417
+ _p(f"Flow failed — retrying same YAML ({retry_i + 1}/{max_retries})…")
1418
+
1419
+ if passed:
1420
+ result["success"] = True
1421
+ where = f"cloud:{provider}" if cloud else "device"
1422
+ result["summary"] = f"Maestro flow passed on {where}."
1423
+ result["run"] = {
1424
+ k: last_run.get(k)
1425
+ for k in (
1426
+ "returncode",
1427
+ "stdout",
1428
+ "stderr",
1429
+ "provider",
1430
+ "build_id",
1431
+ "dashboard_url",
1432
+ "status",
1433
+ "artifact_dir",
1434
+ "maestro_junit",
1435
+ "maestro_debug_dir",
1436
+ "maestro_output_dir",
1437
+ "video_url",
1438
+ "media_files",
1439
+ "media_dir",
1440
+ "media_urls",
1441
+ )
1442
+ if k in last_run
1443
+ }
1444
+ _p(result["summary"])
1445
+ if last_run.get("dashboard_url"):
1446
+ _p(f"Dashboard: {last_run['dashboard_url']}")
1447
+ if last_run.get("video_url"):
1448
+ _p(f"Video: {last_run['video_url']}")
1449
+ _persist_selectors(True)
1450
+ return result
1451
+
1452
+ if heal_round >= heal_budget:
1453
+ break
1454
+ heal_round += 1
1455
+ failure = (last_run.get("stderr") or "") + "\n" + (last_run.get("stdout") or "")
1456
+ _p("Flow failed — repairing with LLM…")
1457
+ hierarchy = ""
1458
+ if adaptive and not cloud:
1459
+ hierarchy = await fetch_hierarchy(selected)
1460
+ bundle = await generate_flow_bundle(
1461
+ goal,
1462
+ app_id=app_id,
1463
+ platform=platform,
1464
+ profile=codegen_profile,
1465
+ hierarchy=hierarchy,
1466
+ previous_yaml=flow,
1467
+ previous_scripts=scripts,
1468
+ failure_log=failure,
1469
+ exploration=exploration_prompt,
1470
+ allow_js=allow_js,
1471
+ progress=_p,
1472
+ )
1473
+ flow = bundle.flow_yaml
1474
+ scripts = bundle.scripts
1475
+ codegen_usage = codegen_usage.merged(bundle.usage)
1476
+ result["flow_yaml"] = flow
1477
+ result["scripts"] = scripts
1478
+ result["codegen_usage"] = codegen_usage.to_dict()
1479
+
1480
+ result["success"] = False
1481
+ result["summary"] = "Maestro flow failed after retries/heal attempts."
1482
+ result["error"] = last_run.get("error") or "flow_failed"
1483
+ result["run"] = {
1484
+ k: last_run.get(k)
1485
+ for k in (
1486
+ "returncode",
1487
+ "stdout",
1488
+ "stderr",
1489
+ "provider",
1490
+ "build_id",
1491
+ "dashboard_url",
1492
+ "status",
1493
+ "artifact_dir",
1494
+ "maestro_junit",
1495
+ "maestro_debug_dir",
1496
+ "maestro_output_dir",
1497
+ "video_url",
1498
+ "media_files",
1499
+ "media_dir",
1500
+ "media_urls",
1501
+ )
1502
+ if k in last_run
1503
+ }
1504
+ _p(result["summary"])
1505
+ _persist_selectors(False)
1506
+ return result