@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,600 @@
1
+ """Orchestrate case → generate → run → heal → reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from rich.console import Console
13
+
14
+ from mobiflow.cases import load_case, resolve_run_options
15
+ from mobiflow.config import MobiflowConfig
16
+ from mobiflow.incremental import (
17
+ classify_guidance,
18
+ format_gap_task,
19
+ load_guidance,
20
+ save_guidance,
21
+ )
22
+ from mobiflow.maestro import run_mobile_task
23
+ from mobiflow.reporting import (
24
+ ReportCase,
25
+ collect_screenshots,
26
+ index_artifacts,
27
+ write_run_reports,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+ console = Console()
32
+
33
+
34
+ def _write_scripts(flow_dir: Path, scripts: dict[str, str]) -> list[Path]:
35
+ written: list[Path] = []
36
+ for rel, body in (scripts or {}).items():
37
+ # Scripts are relative to flow dir (e.g. scripts/helpers.js)
38
+ path = flow_dir / rel
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ path.write_text(body, encoding="utf-8")
41
+ written.append(path)
42
+ return written
43
+
44
+
45
+ def resolve_flow_path(case: Any, cfg: MobiflowConfig) -> Path | None:
46
+ """Return existing frozen YAML from case.flow or flows/<case>.yaml."""
47
+ if case.flow:
48
+ p = Path(case.flow).expanduser()
49
+ if not p.is_absolute():
50
+ p = cfg.repo_path() / p
51
+ return p.resolve() if p.is_file() else None
52
+ candidate = cfg.flow_dir_path() / f"{case.name}.yaml"
53
+ return candidate if candidate.is_file() else None
54
+
55
+
56
+ def resolve_reuse_flow_path(
57
+ case: Any,
58
+ cfg: MobiflowConfig,
59
+ *,
60
+ reuse_flow: bool | None = None,
61
+ ) -> Path | None:
62
+ """Return a frozen YAML path from case.flow or flows/<case>.yaml when enabled."""
63
+ if case.flow:
64
+ return resolve_flow_path(case, cfg)
65
+ want = cfg.run.reuse_flow if reuse_flow is None else reuse_flow
66
+ if not want:
67
+ return None
68
+ return resolve_flow_path(case, cfg)
69
+
70
+
71
+ def _load_companion_scripts(flow_path: Path, cfg: MobiflowConfig) -> dict[str, str]:
72
+ scripts: dict[str, str] = {}
73
+ # Prefer scripts next to the flow, then stack.scripts_dir
74
+ for root in (flow_path.parent, cfg.scripts_dir_path(), cfg.flow_dir_path()):
75
+ scripts_dir = root / "scripts" if (root / "scripts").is_dir() else root
76
+ if not scripts_dir.is_dir():
77
+ continue
78
+ for js in scripts_dir.rglob("*.js"):
79
+ try:
80
+ rel = js.relative_to(flow_path.parent)
81
+ except ValueError:
82
+ rel = Path("scripts") / js.name
83
+ key = str(rel).replace("\\", "/")
84
+ if key not in scripts:
85
+ scripts[key] = js.read_text(encoding="utf-8")
86
+ return scripts
87
+
88
+
89
+ def _resolve_incremental_plan(
90
+ case: Any,
91
+ cfg: MobiflowConfig,
92
+ *,
93
+ incremental: bool,
94
+ extend_explore: bool,
95
+ ) -> dict[str, Any]:
96
+ """Classify case growth and decide reuse / gap-explore / seeded extend."""
97
+ plan: dict[str, Any] = {
98
+ "mode": "full",
99
+ "reuse_yaml": None,
100
+ "reuse_scripts": {},
101
+ "prior_yaml": None,
102
+ "prior_scripts": {},
103
+ "extend": False,
104
+ "replay_prefix": False,
105
+ "explore_goal": None,
106
+ "codegen_goal": None,
107
+ }
108
+ if not incremental and not extend_explore:
109
+ return plan
110
+
111
+ prior_path = resolve_flow_path(case, cfg)
112
+ if prior_path is None:
113
+ console.print(
114
+ " [yellow]incremental/extend: no prior flows/<case>.yaml — full run[/yellow]"
115
+ )
116
+ return plan
117
+
118
+ prior_yaml = prior_path.read_text(encoding="utf-8")
119
+ prior_scripts = _load_companion_scripts(prior_path, cfg)
120
+ current = case.guidance_steps()
121
+ prior_g = load_guidance(cfg.repo_path(), case.name)
122
+
123
+ if extend_explore and not incremental:
124
+ console.print(
125
+ f" [cyan]extend-explore[/cyan] seeding codegen from {prior_path.name}"
126
+ )
127
+ plan.update(
128
+ {
129
+ "mode": "extend",
130
+ "prior_yaml": prior_yaml,
131
+ "prior_scripts": prior_scripts,
132
+ "extend": True,
133
+ }
134
+ )
135
+ return plan
136
+
137
+ # --incremental
138
+ if not prior_g:
139
+ # Flow exists but no stamped guidance — treat as dirty (seeded full)
140
+ diff_mode = "dirty"
141
+ common = 0
142
+ console.print(
143
+ " [cyan]incremental[/cyan] no saved guidance stamp — "
144
+ "seeded full explore (dirty)"
145
+ )
146
+ else:
147
+ diff = classify_guidance(prior_g, current)
148
+ diff_mode = diff.mode
149
+ common = diff.common_prefix
150
+ console.print(
151
+ f" [cyan]incremental[/cyan] guidance {diff_mode} "
152
+ f"(common prefix {common}/{len(current) or len(prior_g)})"
153
+ )
154
+
155
+ if diff_mode == "unchanged":
156
+ plan.update(
157
+ {
158
+ "mode": "unchanged",
159
+ "reuse_yaml": prior_yaml,
160
+ "reuse_scripts": prior_scripts,
161
+ }
162
+ )
163
+ return plan
164
+
165
+ if diff_mode == "append" and current:
166
+ new_steps = current[common:]
167
+ gap = format_gap_task(
168
+ title=case.task.split("\n", 1)[0][:120],
169
+ new_steps=new_steps,
170
+ start_index=common + 1,
171
+ app_id=case.app_id or cfg.device.app_id or "",
172
+ )
173
+ console.print(f" → gap explore for {len(new_steps)} new step(s)")
174
+ plan.update(
175
+ {
176
+ "mode": "append",
177
+ "prior_yaml": prior_yaml,
178
+ "prior_scripts": prior_scripts,
179
+ "extend": True,
180
+ "replay_prefix": True,
181
+ "explore_goal": gap,
182
+ "codegen_goal": gap,
183
+ }
184
+ )
185
+ return plan
186
+
187
+ # dirty | fresh-with-prior
188
+ plan.update(
189
+ {
190
+ "mode": "dirty",
191
+ "prior_yaml": prior_yaml,
192
+ "prior_scripts": prior_scripts,
193
+ "extend": True,
194
+ }
195
+ )
196
+ return plan
197
+
198
+
199
+ def run_pipeline(
200
+ case_file: Path | str,
201
+ cfg: MobiflowConfig,
202
+ *,
203
+ gen_only: bool = False,
204
+ device_id: str | None = None,
205
+ no_heal: bool = False,
206
+ reuse_flow: bool | None = None,
207
+ incremental: bool | None = None,
208
+ extend_explore: bool | None = None,
209
+ ) -> dict[str, Any]:
210
+ case = load_case(case_file)
211
+ flow_dir = cfg.flow_dir_path()
212
+ flow_dir.mkdir(parents=True, exist_ok=True)
213
+
214
+ app_id = case.app_id or cfg.device.app_id
215
+ platform = case.platform or cfg.device.platform
216
+ selected_device = device_id or case.device_id or cfg.device.device_id
217
+ allow_js = cfg.stack.js_enabled()
218
+
219
+ codegen = cfg.codegen_profile()
220
+ discovery = cfg.discovery_profile()
221
+
222
+ opts = resolve_run_options(
223
+ case,
224
+ cfg,
225
+ gen_only=gen_only,
226
+ no_heal=no_heal,
227
+ reuse_flow=reuse_flow,
228
+ incremental=incremental,
229
+ extend_explore=extend_explore,
230
+ )
231
+ gen_only = opts.gen_only
232
+ want_reuse = opts.reuse_flow
233
+ want_incr = opts.incremental
234
+ want_extend = opts.extend_explore
235
+
236
+ from mobiflow.casedata import format_data_prompt_block
237
+ from mobiflow.secrets import merge_flow_env, redact_text
238
+
239
+ data_path_resolved = None
240
+ data_flat: dict[str, str] = {}
241
+ data_block = ""
242
+ if case.data_path:
243
+ try:
244
+ data_path_resolved, _raw, data_flat = case.load_data(repo=cfg.repo_path())
245
+ data_block = format_data_prompt_block(
246
+ data_flat,
247
+ path=str(data_path_resolved) if data_path_resolved else case.data_path,
248
+ )
249
+ except (OSError, ValueError, FileNotFoundError) as exc:
250
+ raise ValueError(f"Case data: {exc}") from exc
251
+
252
+ task_text = case.explore_task(data_block=data_block)
253
+
254
+ def progress(msg: str) -> None:
255
+ console.print(f" [dim]→[/dim] {msg}")
256
+
257
+ console.print(f"[bold]Case[/bold] {case.name}")
258
+ console.print(f" task: {task_text[:200]}")
259
+ for w in case.parse_warnings:
260
+ console.print(f" [yellow]case warning:[/yellow] {w}")
261
+ if data_path_resolved is not None:
262
+ console.print(
263
+ f" [cyan]data[/cyan] {data_path_resolved} ({len(data_flat)} key(s))"
264
+ )
265
+ provider = cfg.device.provider or "local"
266
+ console.print(
267
+ f" provider={provider} platform={platform} appId={app_id or '(infer)'} "
268
+ f"device={selected_device or '(auto)'}"
269
+ )
270
+ if cfg.device.is_cloud():
271
+ console.print(
272
+ f" cloud app_path={cfg.device.app_path or '-'} "
273
+ f"app_url={cfg.device.app_url or '-'}"
274
+ )
275
+ console.print(
276
+ f" LLM codegen={cfg.llm.codegen} discovery={cfg.llm.discovery} "
277
+ f"lang={cfg.stack.language}"
278
+ )
279
+ mode_bits = []
280
+ if want_reuse:
281
+ mode_bits.append("reuseFlow")
282
+ if want_incr:
283
+ mode_bits.append("incremental")
284
+ if want_extend:
285
+ mode_bits.append("extendExplore")
286
+ if opts.gen_only:
287
+ mode_bits.append("genOnly")
288
+ console.print(
289
+ f" run: heal={opts.heal} retries={opts.retries} "
290
+ f"explore={str(opts.explore).lower()} "
291
+ f"modes={','.join(mode_bits) or 'full'} "
292
+ f"[dim]({', '.join(f'{k}={v}' for k, v in sorted(opts.sources.items()))})[/dim]"
293
+ )
294
+
295
+ reuse_yaml = None
296
+ reuse_scripts: dict[str, str] = {}
297
+ prior_yaml = None
298
+ prior_scripts: dict[str, str] = {}
299
+ extend = False
300
+ replay_prefix = False
301
+ explore_goal = None
302
+ codegen_goal = None
303
+ incr_mode = "full"
304
+
305
+ if want_reuse and not gen_only:
306
+ reuse_path = resolve_reuse_flow_path(case, cfg, reuse_flow=True)
307
+ if reuse_path is not None:
308
+ reuse_yaml = reuse_path.read_text(encoding="utf-8")
309
+ reuse_scripts = _load_companion_scripts(reuse_path, cfg)
310
+ console.print(f" [cyan]reuse-flow[/cyan] {reuse_path}")
311
+ else:
312
+ console.print(
313
+ f" [yellow]reuse-flow requested but no YAML at "
314
+ f"{cfg.flow_dir_path() / (case.name + '.yaml')} — generating[/yellow]"
315
+ )
316
+ elif want_incr or want_extend:
317
+ plan = _resolve_incremental_plan(
318
+ case,
319
+ cfg,
320
+ incremental=bool(want_incr),
321
+ extend_explore=bool(want_extend),
322
+ )
323
+ incr_mode = str(plan["mode"])
324
+ reuse_yaml = plan.get("reuse_yaml")
325
+ reuse_scripts = dict(plan.get("reuse_scripts") or {})
326
+ prior_yaml = plan.get("prior_yaml")
327
+ prior_scripts = dict(plan.get("prior_scripts") or {})
328
+ extend = bool(plan.get("extend"))
329
+ # gen-only: skip device prefix replay
330
+ replay_prefix = bool(plan.get("replay_prefix")) and not gen_only
331
+ explore_goal = plan.get("explore_goal")
332
+ codegen_goal = plan.get("codegen_goal")
333
+
334
+ # Merge order: config env < data file < case env (case wins)
335
+ flow_env = merge_flow_env(cfg.run.env, data_flat, case.env)
336
+ if flow_env:
337
+ console.print(f" env keys: {', '.join(sorted(flow_env))}")
338
+
339
+ import asyncio
340
+
341
+ run_timeout = (
342
+ opts.timeout_s
343
+ if opts.timeout_s is not None
344
+ else max(cfg.run.timeout_s, cfg.device.boot_timeout_s)
345
+ )
346
+ if cfg.device.is_cloud():
347
+ run_timeout = max(run_timeout, int(cfg.device.cloud_timeout_s or 1800))
348
+
349
+ stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
350
+ started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
351
+ run_artifact_dir: Path | None = None
352
+ if cfg.run.save_artifacts and not gen_only:
353
+ run_artifact_dir = cfg.artifacts_dir() / "runs" / f"{case.name}-{stamp}"
354
+ run_artifact_dir.mkdir(parents=True, exist_ok=True)
355
+
356
+ t0 = time.monotonic()
357
+ result = asyncio.run(
358
+ run_mobile_task(
359
+ task_text,
360
+ codegen_profile=codegen,
361
+ discovery_profile=discovery,
362
+ app_id=app_id,
363
+ platform=platform,
364
+ device_id=selected_device,
365
+ heal=opts.heal,
366
+ adaptive=opts.adaptive and not gen_only,
367
+ explore=opts.explore and not gen_only and reuse_yaml is None,
368
+ explore_steps=opts.explore_steps,
369
+ timeout_s=run_timeout,
370
+ live=not gen_only,
371
+ allow_js=allow_js,
372
+ auto_start_device=cfg.device.auto_start and not gen_only and not cfg.device.is_cloud(),
373
+ progress=progress,
374
+ device_config=cfg.device,
375
+ artifact_dir=run_artifact_dir,
376
+ clear_state=bool(case.clear_state),
377
+ preflight=list(cfg.run.preflight or []),
378
+ app_path=cfg.device.app_path or "",
379
+ retries=0 if gen_only else opts.retries,
380
+ reuse_flow_yaml=reuse_yaml,
381
+ reuse_scripts=reuse_scripts or None,
382
+ flow_env=flow_env or None,
383
+ expect=list(case.expect or []),
384
+ prior_flow_yaml=prior_yaml,
385
+ prior_scripts=prior_scripts or None,
386
+ extend=extend,
387
+ replay_prefix=replay_prefix,
388
+ explore_goal=explore_goal,
389
+ codegen_goal=codegen_goal,
390
+ record_video=bool(cfg.run.video) and not cfg.device.is_cloud(),
391
+ include_tags=list(cfg.run.include_tags or []),
392
+ exclude_tags=list(cfg.run.exclude_tags or []),
393
+ maestro_config=(cfg.run.maestro_config or "").strip() or None,
394
+ )
395
+ )
396
+ duration_s = time.monotonic() - t0
397
+
398
+ flow_yaml = result.get("flow_yaml") or ""
399
+ scripts = result.get("scripts") or {}
400
+ out_flow = flow_dir / f"{case.name}.yaml"
401
+ if flow_yaml:
402
+ out_flow.write_text(flow_yaml, encoding="utf-8")
403
+ console.print(f"[green]Wrote flow[/green] → {out_flow}")
404
+ for sp in _write_scripts(flow_dir, scripts):
405
+ console.print(f"[green]Wrote script[/green] → {sp}")
406
+
407
+ # Stamp guidance after a successful generation / reuse so tomorrow's
408
+ # --incremental can classify append vs dirty.
409
+ if result.get("success") and case.guidance_steps():
410
+ try:
411
+ gpath = save_guidance(
412
+ cfg.repo_path(),
413
+ case.name,
414
+ case.guidance_steps(),
415
+ flow_path=str(out_flow) if flow_yaml else "",
416
+ mode=incr_mode,
417
+ )
418
+ console.print(f"[dim]Guidance stamp → {gpath}[/dim]")
419
+ except OSError as exc:
420
+ logger.warning("Could not save guidance stamp: %s", exc)
421
+
422
+ run_meta = result.get("run") or {}
423
+ reports_written: dict[str, str] = {}
424
+ screenshot_rels: list[str] = []
425
+
426
+ if cfg.run.save_artifacts:
427
+ art = cfg.artifacts_dir()
428
+ art.mkdir(parents=True, exist_ok=True)
429
+ (art / "runs").mkdir(exist_ok=True)
430
+
431
+ # Prefer the durable per-run dir created above
432
+ if run_artifact_dir is None:
433
+ run_artifact_dir = art / "runs" / f"{case.name}-{stamp}"
434
+ run_artifact_dir.mkdir(parents=True, exist_ok=True)
435
+
436
+ # Collect screenshots from Maestro debug/output dirs (all attempts)
437
+ shot_paths: list[Path] = []
438
+ for candidate in (
439
+ run_artifact_dir,
440
+ Path(run_meta.get("maestro_debug_dir") or ""),
441
+ Path(run_meta.get("maestro_output_dir") or ""),
442
+ Path(run_meta.get("artifact_dir") or ""),
443
+ ):
444
+ if candidate and str(candidate) not in {"", "."}:
445
+ shot_paths.extend(collect_screenshots(candidate))
446
+ # Dedupe while preserving order
447
+ seen: set[str] = set()
448
+ unique_shots: list[Path] = []
449
+ for p in shot_paths:
450
+ key = str(p.resolve())
451
+ if key in seen:
452
+ continue
453
+ seen.add(key)
454
+ unique_shots.append(p)
455
+
456
+ shots_dir = run_artifact_dir / "screenshots"
457
+ if unique_shots:
458
+ shots_dir.mkdir(parents=True, exist_ok=True)
459
+ for i, src in enumerate(unique_shots[:40], start=1):
460
+ dest = shots_dir / f"{i:02d}-{src.name}"
461
+ try:
462
+ if not dest.exists():
463
+ dest.write_bytes(src.read_bytes())
464
+ screenshot_rels.append(str(Path("screenshots") / dest.name))
465
+ except OSError:
466
+ continue
467
+
468
+ # Reports live under report_dir/<case>-<stamp>/ and also copied into run dir
469
+ case_report_dir = cfg.report_dir_path() / f"{case.name}-{stamp}"
470
+ report_case = ReportCase(
471
+ name=case.name,
472
+ success=bool(result.get("success")),
473
+ summary=str(result.get("summary") or ""),
474
+ error=str(result.get("error") or ""),
475
+ task=task_text,
476
+ platform=str(result.get("platform") or platform or ""),
477
+ provider=str(result.get("provider") or provider),
478
+ device_id=str(result.get("device_id") or selected_device or ""),
479
+ duration_s=duration_s,
480
+ flow_path=str(out_flow) if flow_yaml else "",
481
+ dashboard_url=str(run_meta.get("dashboard_url") or ""),
482
+ build_id=str(run_meta.get("build_id") or ""),
483
+ stdout=redact_text(str(run_meta.get("stdout") or ""), flow_env),
484
+ stderr=redact_text(str(run_meta.get("stderr") or ""), flow_env),
485
+ logs=[redact_text(str(x), flow_env) for x in (result.get("logs") or [])],
486
+ synthesis_only=bool(result.get("synthesis_only")),
487
+ screenshot_paths=screenshot_rels,
488
+ artifact_dir=str(run_artifact_dir),
489
+ started_at=started_at,
490
+ video_url=str(run_meta.get("video_url") or ""),
491
+ explore_usage=dict(result.get("explore_usage") or {}),
492
+ codegen_usage=dict(result.get("codegen_usage") or {}),
493
+ )
494
+ maestro_junit = None
495
+ if run_meta.get("maestro_junit"):
496
+ maestro_junit = Path(str(run_meta["maestro_junit"]))
497
+ # Prefer last attempt junit inside run dir
498
+ if maestro_junit is None:
499
+ found = list(run_artifact_dir.rglob("maestro-junit.xml"))
500
+ if found:
501
+ maestro_junit = found[-1]
502
+
503
+ if cfg.run.reports:
504
+ # Write into run dir/report so screenshot relative paths resolve in HTML
505
+ embedded_report_dir = run_artifact_dir / "report"
506
+ # Fix screenshot paths relative to report dir
507
+ report_case.screenshot_paths = [
508
+ f"../screenshots/{Path(p).name}" for p in screenshot_rels
509
+ ]
510
+ reports_written = write_run_reports(
511
+ report_case,
512
+ embedded_report_dir,
513
+ formats=cfg.run.reports,
514
+ maestro_junit=maestro_junit,
515
+ )
516
+ # Also mirror under configured report_dir for CI convenience
517
+ mirrored = write_run_reports(
518
+ report_case,
519
+ case_report_dir,
520
+ formats=cfg.run.reports,
521
+ maestro_junit=maestro_junit,
522
+ )
523
+ # Copy screenshots next to mirrored HTML so it still renders
524
+ if screenshot_rels:
525
+ mirror_shots = case_report_dir / "screenshots"
526
+ mirror_shots.mkdir(parents=True, exist_ok=True)
527
+ src_shots = run_artifact_dir / "screenshots"
528
+ if src_shots.is_dir():
529
+ for img in src_shots.iterdir():
530
+ if img.is_file():
531
+ (mirror_shots / img.name).write_bytes(img.read_bytes())
532
+ reports_written = {**mirrored, **{f"run_{k}": v for k, v in reports_written.items()}}
533
+ if reports_written.get("html"):
534
+ console.print(f"[green]HTML report[/green] → {reports_written['html']}")
535
+ if reports_written.get("junit"):
536
+ console.print(f"[green]JUnit[/green] → {reports_written['junit']}")
537
+
538
+ inventory = index_artifacts(run_artifact_dir)
539
+ payload = {
540
+ "case": case.name,
541
+ "task": task_text,
542
+ "data_path": str(data_path_resolved) if data_path_resolved else "",
543
+ "data_keys": sorted(data_flat.keys()),
544
+ "success": result.get("success"),
545
+ "summary": result.get("summary"),
546
+ "flow_path": str(out_flow),
547
+ "scripts": list(scripts.keys()),
548
+ "language": cfg.stack.language,
549
+ "device_id": result.get("device_id"),
550
+ "platform": result.get("platform"),
551
+ "provider": result.get("provider") or provider,
552
+ "synthesis_only": result.get("synthesis_only"),
553
+ "logs": result.get("logs"),
554
+ "error": result.get("error"),
555
+ "run": result.get("run"),
556
+ "attempts": result.get("attempts"),
557
+ "exploration": result.get("exploration"),
558
+ "explore_usage": result.get("explore_usage") or {},
559
+ "codegen_usage": result.get("codegen_usage") or {},
560
+ "incremental_mode": incr_mode,
561
+ "duration_s": duration_s,
562
+ "started_at": started_at,
563
+ "artifact_dir": str(run_artifact_dir),
564
+ "screenshots": screenshot_rels,
565
+ "artifacts": inventory,
566
+ "reports": reports_written,
567
+ }
568
+ run_json = run_artifact_dir / "run.json"
569
+ run_json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
570
+ # Compat: also keep flat latest pointer under .mobiflow/runs/
571
+ latest = art / "runs" / f"{case.name}.latest.json"
572
+ latest.write_text(run_json.read_text(encoding="utf-8"), encoding="utf-8")
573
+ stamp_json = art / "runs" / f"{case.name}-{stamp}.json"
574
+ stamp_json.write_text(run_json.read_text(encoding="utf-8"), encoding="utf-8")
575
+ if flow_yaml:
576
+ (art / "flows").mkdir(exist_ok=True)
577
+ (art / "flows" / f"{case.name}.yaml").write_text(flow_yaml, encoding="utf-8")
578
+ for rel, body in scripts.items():
579
+ sp = art / "flows" / rel
580
+ sp.parent.mkdir(parents=True, exist_ok=True)
581
+ sp.write_text(body, encoding="utf-8")
582
+ console.print(f"[dim]Artifacts → {run_artifact_dir}[/dim]")
583
+ result["reports"] = reports_written
584
+ result["artifact_dir"] = str(run_artifact_dir)
585
+ result["screenshots"] = screenshot_rels
586
+
587
+ result["duration_s"] = duration_s
588
+ result["started_at"] = started_at
589
+ result["flow_path"] = str(out_flow) if flow_yaml else str(result.get("flow_path") or "")
590
+ result["case"] = case.name
591
+ result["incremental_mode"] = incr_mode
592
+
593
+ ok = bool(result.get("success"))
594
+ if ok:
595
+ console.print(f"[bold green]OK[/bold green] {result.get('summary')}")
596
+ else:
597
+ console.print(
598
+ f"[bold red]FAIL[/bold red] {result.get('summary') or result.get('error')}"
599
+ )
600
+ return result