@capagents/forgeqe 1.3.2 → 2.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 (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,321 @@
1
+ """
2
+ Maestro (maestro.mobile.dev): replay ForgeQE mobile workflows as Maestro flows, and run the team's own flows.
3
+
4
+ - ``to_flow`` writes a workflow as a Maestro flow. Variables are filled in first; ``{{secret:NAME}}`` becomes
5
+ ``${MAESTRO_NAME}``, which Maestro reads from the environment ForgeQE gives it, so a secret's value never lands in
6
+ the flow file or on a command line.
7
+ - ``check_flow`` checks a flow someone else wrote before it runs. Maestro evaluates ``${…}`` as JavaScript and has
8
+ commands that run scripts, call AI services or write files, so only an allowlist of UI commands passes, ``${…}`` may
9
+ only name a variable, and ``runFlow`` files must stay inside the project.
10
+ - ``run_maestro`` starts ``maestro test`` with an argument list (no shell) and reads its JUnit report and its debug
11
+ output for the result of each command.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import xml.etree.ElementTree as ET
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
22
+
23
+ import yaml
24
+
25
+ from forgeqe_desktop.workflow import WorkflowError, fill, fill_targets, secret_names, validate
26
+
27
+ _JS_EXPR = re.compile(r"\$\{([^}]*)\}")
28
+ _JS_VAR = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*$")
29
+ _REGEX_SPECIAL = re.compile(r"([\\.^$|?*+()\[\]{}])")
30
+ MAESTRO_KEYS = {"enter": "Enter", "back": "Back", "tab": "Tab", "delete": "Backspace"}
31
+ MAX_FLOW_DEPTH = 5
32
+
33
+ # Maestro commands that only act on the app's UI. Everything else (runScript, evalScript, assertTrue, the AI commands,
34
+ # takeScreenshot and recordings that write files, addMedia) is refused in flows ForgeQE did not write.
35
+ ALLOWED_COMMANDS = frozenset(
36
+ {
37
+ "launchApp", "stopApp", "killApp", "clearState", "clearKeychain", "tapOn", "doubleTapOn", "longPressOn",
38
+ "inputText", "inputRandomText", "inputRandomNumber", "inputRandomEmail", "inputRandomPersonName", "eraseText",
39
+ "pressKey", "back", "hideKeyboard", "scroll", "scrollUntilVisible", "swipe", "assertVisible", "assertNotVisible",
40
+ "waitForAnimationToEnd", "extendedWaitUntil", "copyTextFrom", "pasteText", "openLink", "setLocation", "travel",
41
+ "setAirplaneMode", "toggleAirplaneMode", "runFlow", "repeat", "retry",
42
+ }
43
+ )
44
+ _REFUSED_LINK = re.compile(r"^\s*(javascript|file|data|content|intent|vbscript):", re.I)
45
+
46
+
47
+ def regex_literal(text: str) -> str:
48
+ """``text`` as a Maestro (Java) regex that matches it literally."""
49
+ return _REGEX_SPECIAL.sub(r"\\\1", text)
50
+
51
+
52
+ def _selector(target: Mapping[str, Any], text: Optional[str] = None) -> Dict[str, Any]:
53
+ sel: Dict[str, Any] = {}
54
+ if target.get("automation_id"):
55
+ sel["id"] = regex_literal(str(target["automation_id"]))
56
+ if text is not None:
57
+ sel["text"] = f".*{regex_literal(text)}.*"
58
+ elif target.get("name"):
59
+ sel["text"] = regex_literal(str(target["name"]))
60
+ if "index" in target:
61
+ sel["index"] = int(target["index"])
62
+ if not sel:
63
+ raise WorkflowError("a Maestro step needs an automation_id or a name")
64
+ return sel
65
+
66
+
67
+ def _ms(seconds: Any) -> int:
68
+ return int(float(seconds) * 1000)
69
+
70
+
71
+ def to_flow(workflow: Mapping[str, Any], app_id: str, variables: Optional[Mapping[str, Any]] = None, allowed_secrets: Iterable[str] = ()) -> Tuple[str, List[str]]:
72
+ """``(flow text, secret names)`` for a mobile workflow; the secrets are read from MAESTRO_<NAME> when it runs."""
73
+ wf = validate(dict(workflow))
74
+ if wf["format"] != "forgeqe-mobile-v1":
75
+ raise WorkflowError("only mobile workflows (forgeqe-mobile-v1) run on Maestro")
76
+ allowed = {str(n) for n in allowed_secrets}
77
+ values: Dict[str, Any] = {**wf["variables"], **dict(variables or {})}
78
+ wf = fill_targets(wf, values)
79
+ used: List[str] = []
80
+
81
+ def secret(name: str) -> Optional[str]:
82
+ if name not in allowed:
83
+ raise WorkflowError(f"secret {name} is not listed in forgeqe.yaml mobile.secrets")
84
+ if name not in used:
85
+ used.append(name)
86
+ return "${MAESTRO_%s}" % name
87
+
88
+ def text(raw: str) -> str:
89
+ for name in secret_names(raw):
90
+ secret(name)
91
+ # A value with ${…} would be run by Maestro as JavaScript.
92
+ filled_values = fill(raw, values, lambda n: "")
93
+ if "${" in filled_values:
94
+ raise WorkflowError("text for Maestro may not contain ${…}: Maestro would run it as JavaScript")
95
+ return fill(raw, values, secret)
96
+
97
+ timeout = wf.get("timeout")
98
+ cmds: List[Any] = []
99
+ for i, step in enumerate(wf["steps"], 1):
100
+ op = step["op"]
101
+ t = step.get("timeout", timeout)
102
+ try:
103
+ if op == "launch":
104
+ cmds.append("launchApp")
105
+ # dismiss: Maestro has no handler for a dialog that may show, so each is tapped if it is there after a
106
+ # launch, where system prompts (permissions) show.
107
+ for d in wf.get("dismiss") or []:
108
+ cmds.append({"tapOn": {**_selector(d), "optional": True}})
109
+ elif op == "close":
110
+ cmds.append("stopApp")
111
+ elif op in ("tap", "double_tap", "long_press"):
112
+ cmds.append({{"tap": "tapOn", "double_tap": "doubleTapOn", "long_press": "longPressOn"}[op]: _selector(step["target"])})
113
+ elif op == "type":
114
+ cmds += [{"tapOn": _selector(step["target"])}, "eraseText", {"inputText": text(step["text"])}]
115
+ elif op == "expect":
116
+ cmds.append({"assertVisible": _selector(step["target"], text(step["text"]) if "text" in step else None)})
117
+ elif op == "wait_for":
118
+ sel = _selector(step["target"], text(step["text"]) if "text" in step else None)
119
+ cmds.append({"extendedWaitUntil": {("notVisible" if step.get("gone") else "visible"): sel, "timeout": _ms(t or 10)}})
120
+ elif op == "wait":
121
+ # Maestro has no sleep: wait for something that never appears, and carry on.
122
+ cmds.append({"extendedWaitUntil": {"visible": {"id": "forgeqe-wait-never-present"}, "timeout": _ms(step["seconds"]), "optional": True}})
123
+ elif op == "swipe":
124
+ swipe: Dict[str, Any] = {"direction": step["direction"].upper()}
125
+ if "target" in step:
126
+ swipe["from"] = _selector(step["target"])
127
+ cmds.append({"swipe": swipe})
128
+ elif op == "scroll_to":
129
+ cmds.append({"scrollUntilVisible": {"element": _selector(step["target"]), "direction": step.get("direction", "down").upper(), **({"timeout": _ms(t)} if t else {})}})
130
+ elif op == "back":
131
+ cmds.append("back")
132
+ elif op == "hide_keyboard":
133
+ cmds.append("hideKeyboard")
134
+ elif op == "keys":
135
+ if step["keys"] not in MAESTRO_KEYS:
136
+ raise WorkflowError(f"the {step['keys']} key has no Maestro equivalent; replay with the Appium driver")
137
+ cmds.append({"pressKey": MAESTRO_KEYS[step["keys"]]})
138
+ elif op == "expect_image":
139
+ raise WorkflowError("expect_image needs the Appium driver (Maestro does not compare images)")
140
+ else:
141
+ raise WorkflowError(f"op {op} has no Maestro equivalent")
142
+ except WorkflowError as e:
143
+ raise WorkflowError(f"step {i} ({op}): {e}")
144
+ header = {"appId": app_id, **({"name": wf["name"]} if wf["name"] else {})}
145
+ body = yaml.safe_dump(header, sort_keys=False, allow_unicode=True) + "---\n" + yaml.safe_dump(cmds, sort_keys=False, allow_unicode=True)
146
+ return body, used
147
+
148
+
149
+ # -- flows the team wrote ------------------------------------------------------------------------------------------
150
+
151
+
152
+ def _check_strings(value: Any, where: str) -> None:
153
+ if isinstance(value, str):
154
+ for m in _JS_EXPR.finditer(value):
155
+ if not _JS_VAR.match(m.group(1)):
156
+ raise WorkflowError(f"{where}: ${{{m.group(1)}}} is JavaScript; flows may only use ${{NAME}} variables")
157
+ elif isinstance(value, Mapping):
158
+ for k, v in value.items():
159
+ _check_strings(k, where)
160
+ _check_strings(v, where)
161
+ elif isinstance(value, list):
162
+ for v in value:
163
+ _check_strings(v, where)
164
+
165
+
166
+ def _inside(path: Path, root: Path) -> bool:
167
+ try:
168
+ Path(os.path.realpath(path)).relative_to(Path(os.path.realpath(root)))
169
+ return True
170
+ except ValueError:
171
+ return False
172
+
173
+
174
+ def _check_commands(cmds: Any, where: str, base: Path, root: Path, depth: int, seen: List[Path]) -> None:
175
+ if cmds is None:
176
+ return
177
+ if not isinstance(cmds, list):
178
+ raise WorkflowError(f"{where}: commands must be a list")
179
+ for i, cmd in enumerate(cmds, 1):
180
+ at = f"{where} command {i}"
181
+ if isinstance(cmd, str):
182
+ name, args = cmd, None
183
+ elif isinstance(cmd, Mapping) and len([k for k in cmd if k not in ("optional", "label")]) == 1:
184
+ name = next(k for k in cmd if k not in ("optional", "label"))
185
+ args = cmd[name]
186
+ else:
187
+ raise WorkflowError(f"{at}: one command per list item")
188
+ if name not in ALLOWED_COMMANDS:
189
+ raise WorkflowError(f"{at}: {name} is not allowed (only UI commands run; scripts, AI commands and file writes are refused)")
190
+ _check_strings(args, at)
191
+ if isinstance(args, Mapping) and isinstance(args.get("when"), Mapping) and "true" in args["when"]:
192
+ raise WorkflowError(f"{at}: when.true is JavaScript")
193
+ if name == "openLink":
194
+ link = args.get("link") if isinstance(args, Mapping) else args
195
+ if not isinstance(link, str) or _REFUSED_LINK.match(link):
196
+ raise WorkflowError(f"{at}: openLink must be an http(s) link or the app's own deep link")
197
+ if name in ("repeat", "retry") and isinstance(args, Mapping):
198
+ _check_commands(args.get("commands"), at, base, root, depth, seen)
199
+ if name == "retry" and args.get("file"):
200
+ _check_flow_file(base / str(args["file"]), root, depth + 1, seen)
201
+ if name == "runFlow":
202
+ if isinstance(args, str):
203
+ _check_flow_file(base / args, root, depth + 1, seen)
204
+ elif isinstance(args, Mapping):
205
+ if isinstance(args.get("when"), Mapping) and "true" in args["when"]:
206
+ raise WorkflowError(f"{at}: when.true is JavaScript")
207
+ if args.get("file"):
208
+ _check_flow_file(base / str(args["file"]), root, depth + 1, seen)
209
+ _check_commands(args.get("commands"), at, base, root, depth, seen)
210
+ else:
211
+ raise WorkflowError(f"{at}: runFlow needs a file or commands")
212
+
213
+
214
+ def _check_flow_file(path: Path, root: Path, depth: int, seen: List[Path]) -> None:
215
+ if depth > MAX_FLOW_DEPTH:
216
+ raise WorkflowError(f"{path.name}: runFlow is nested more than {MAX_FLOW_DEPTH} deep")
217
+ if path.suffix.lower() not in (".yaml", ".yml"):
218
+ raise WorkflowError(f"{path.name} is not a Maestro flow (.yaml)")
219
+ if not path.is_file():
220
+ raise WorkflowError(f"no flow at {path}")
221
+ if not _inside(path, root):
222
+ raise WorkflowError(f"{path} is outside the project folder; flows are read from it only")
223
+ real = Path(os.path.realpath(path))
224
+ if real in seen:
225
+ return
226
+ seen.append(real)
227
+ docs = [d for d in yaml.safe_load_all(real.read_text(encoding="utf-8"))]
228
+ if not docs:
229
+ raise WorkflowError(f"{path.name} is empty")
230
+ config, commands = (docs[0], docs[1] if len(docs) > 1 else None) if isinstance(docs[0], Mapping) else ({}, docs[0])
231
+ if len(docs) > 2:
232
+ raise WorkflowError(f"{path.name}: a flow is a config and one list of commands")
233
+ if not isinstance(config, Mapping):
234
+ raise WorkflowError(f"{path.name}: the first part must be the flow's config (appId: …)")
235
+ for key in config:
236
+ if key not in ("appId", "url", "name", "tags", "env", "onFlowStart", "onFlowComplete", "properties"):
237
+ raise WorkflowError(f"{path.name}: config key {key} is not allowed")
238
+ _check_strings({k: v for k, v in config.items() if k not in ("onFlowStart", "onFlowComplete")}, path.name)
239
+ for hook in ("onFlowStart", "onFlowComplete"):
240
+ _check_commands(config.get(hook), f"{path.name} {hook}", real.parent, root, depth, seen)
241
+ _check_commands(commands, path.name, real.parent, root, depth, seen)
242
+
243
+
244
+ def check_flow(path: str | os.PathLike, root: str | os.PathLike) -> List[str]:
245
+ """Check a flow (and every flow it runs) before Maestro gets it; returns the flow files checked."""
246
+ seen: List[Path] = []
247
+ _check_flow_file(Path(path), Path(root), 0, seen)
248
+ return [str(p) for p in seen]
249
+
250
+
251
+ # -- running ---------------------------------------------------------------------------------------------------------
252
+
253
+
254
+ def _junit(path: Path) -> List[Dict[str, Any]]:
255
+ if not path.is_file():
256
+ return []
257
+ try:
258
+ root = ET.parse(path).getroot()
259
+ except ET.ParseError:
260
+ return []
261
+ out: List[Dict[str, Any]] = []
262
+ for case in root.iter("testcase"):
263
+ failure = case.find("failure")
264
+ error = case.find("error")
265
+ bad = failure if failure is not None else error
266
+ out.append({"name": case.get("name") or "", "ok": bad is None, **({"error": ((bad.get("message") or "") + " " + (bad.text or "")).strip()[:2000]} if bad is not None else {})})
267
+ return out
268
+
269
+
270
+ _INTERNAL_COMMANDS = frozenset({"applyConfigurationCommand", "defineVariablesCommand"})
271
+
272
+
273
+ def command_results(debug_dir: Path) -> List[Dict[str, Any]]:
274
+ """The status of each command Maestro ran (its debug output's commands-*.json), in order; [] when not written."""
275
+ results: List[Dict[str, Any]] = []
276
+ for f in sorted(debug_dir.rglob("commands-*.json")):
277
+ try:
278
+ doc = json.loads(f.read_text(encoding="utf-8", errors="replace"))
279
+ except ValueError:
280
+ continue
281
+ for entry in doc if isinstance(doc, list) else []:
282
+ command = entry.get("command") if isinstance(entry, Mapping) else None
283
+ # Maestro's own bookkeeping (the flow's config, its env), not a command of the flow.
284
+ if isinstance(command, Mapping) and set(command) & _INTERNAL_COMMANDS:
285
+ continue
286
+ meta = entry.get("metadata") if isinstance(entry, Mapping) else None
287
+ status = str((meta or {}).get("status") or "").upper()
288
+ err = (meta or {}).get("error")
289
+ results.append({"status": status, **({"error": str(err.get("message") if isinstance(err, Mapping) else err)[:1000]} if err else {})})
290
+ return results
291
+
292
+
293
+ def run_maestro(
294
+ command: Sequence[str],
295
+ flows: Sequence[str],
296
+ out_dir: Path,
297
+ env: Mapping[str, str],
298
+ device: Optional[str] = None,
299
+ timeout: float = 3600,
300
+ run: Callable[..., Any] = subprocess.run,
301
+ ) -> Dict[str, Any]:
302
+ """Run ``maestro test`` on ``flows``; ``{ok, flows: [{name, ok, error}], commands: [...], log}``."""
303
+ out_dir.mkdir(parents=True, exist_ok=True)
304
+ junit = out_dir / "maestro-junit.xml"
305
+ debug = out_dir / "maestro-debug"
306
+ args = [*command, *(["--device", device] if device else []), "test", "--format", "junit", "--output", str(junit), "--debug-output", str(debug), *flows]
307
+ try:
308
+ proc = run(args, env=dict(env), capture_output=True, text=True, timeout=timeout, shell=False)
309
+ except FileNotFoundError:
310
+ raise WorkflowError(f"Maestro is not installed or not on PATH ({command[0]}); see https://maestro.mobile.dev")
311
+ except subprocess.TimeoutExpired:
312
+ return {"ok": False, "flows": [], "commands": [], "error": f"Maestro did not finish within {int(timeout)} s", "log": ""}
313
+ log = ((proc.stdout or "") + (proc.stderr or ""))[-8000:]
314
+ (out_dir / "maestro.log").write_text(log, encoding="utf-8")
315
+ cases = _junit(junit)
316
+ ok = proc.returncode == 0 and all(c["ok"] for c in cases)
317
+ result: Dict[str, Any] = {"ok": ok, "flows": cases, "commands": command_results(debug), "log": log[-2000:]}
318
+ if not ok:
319
+ failed = [c for c in cases if not c["ok"]]
320
+ result["error"] = (failed[0].get("error") if failed else "") or f"maestro exited with {proc.returncode}: {log.strip().splitlines()[-1] if log.strip() else ''}"
321
+ return result
@@ -0,0 +1,332 @@
1
+ """
2
+ ``python -m forgeqe_mobile serve``: one request as a JSON line on stdin, events as JSON lines on stdout, like the
3
+ desktop engine. A later ``stop`` line on stdin stops a replay between steps, or an explore between actions.
4
+
5
+ Request: ``mode`` (``replay``, ``explore`` or ``maestro``), ``out_dir``, ``mobile`` (``apps``, ``secrets``,
6
+ ``appium`` {``url``, ``capabilities``, ``username_env``, ``access_key_env``}, ``maestro`` {``command``, ``device``},
7
+ ``driver``), ``llm`` and ``profile_name`` (healing and explore), and per mode: ``workflow``, ``variables``, ``heal``,
8
+ ``update_baselines``, ``driver`` (replay); ``goal``, ``app``, ``name`` (explore); ``flows``, ``root`` (maestro: the
9
+ team's own flows, checked first).
10
+
11
+ Events: ``log``, ``step`` (one per replayed or explored step), ``call`` (one model call's time and tokens), ``done``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+ from typing import IO, Any, Callable, Dict, List, Mapping, Optional
22
+
23
+ import yaml
24
+
25
+ from forgeqe_desktop.replay import replay
26
+ from forgeqe_desktop.service import Events, _out_dir, _secret_names, _step_fields
27
+ from forgeqe_desktop.workflow import MOBILE_FORMAT, WorkflowError, load, save
28
+ from forgeqe_desktop.visual import image_checker
29
+
30
+ from .apps import load_mobile_apps
31
+ from .hyperexecute import run_hyperexecute, testmu_settings
32
+ from .maestro import check_flow, run_maestro, to_flow
33
+
34
+
35
+ def _mobile(req: Mapping[str, Any]) -> Dict[str, Any]:
36
+ m = req.get("mobile")
37
+ return dict(m) if isinstance(m, Mapping) else {}
38
+
39
+
40
+ def _model(req: Mapping[str, Any], events: Events, model_factory: Optional[Callable[..., Any]]) -> Any:
41
+ from forgeqe_desktop.models import DesktopModel
42
+
43
+ profile = req.get("llm")
44
+ if not isinstance(profile, dict):
45
+ raise WorkflowError("this mode needs an llms.json profile")
46
+ return (model_factory or DesktopModel)(profile, on_call=lambda c: events.emit({"type": "call", **c}))
47
+
48
+
49
+ def appium_driver(mobile: Mapping[str, Any]) -> Any:
50
+ from .appium_driver import AppiumDriver
51
+
52
+ a = mobile.get("appium") if isinstance(mobile.get("appium"), Mapping) else {}
53
+ user_env, key_env = a.get("username_env"), a.get("access_key_env")
54
+ return AppiumDriver(
55
+ url=str(a.get("url") or "http://127.0.0.1:4723"),
56
+ capabilities=a.get("capabilities") if isinstance(a.get("capabilities"), Mapping) else {},
57
+ username=os.environ.get(str(user_env)) if user_env else None,
58
+ access_key=os.environ.get(str(key_env)) if key_env else None,
59
+ )
60
+
61
+
62
+ def _maestro_command(mobile: Mapping[str, Any]) -> List[str]:
63
+ m = mobile.get("maestro") if isinstance(mobile.get("maestro"), Mapping) else {}
64
+ cmd = m.get("command") or ["maestro"]
65
+ if isinstance(cmd, str):
66
+ cmd = [cmd]
67
+ if not isinstance(cmd, list) or not cmd or not all(isinstance(c, str) and c for c in cmd):
68
+ raise WorkflowError("mobile.maestro.command must be the maestro program (and its arguments) as a list")
69
+ return list(cmd)
70
+
71
+
72
+ def _maestro_env(names: List[str], secrets: Mapping[str, str]) -> Dict[str, str]:
73
+ """The environment for maestro: this process's (already only system settings and listed secrets), with each secret
74
+ as MAESTRO_<NAME>, the only form a flow can read it in."""
75
+ env = dict(os.environ)
76
+ for n in names:
77
+ if secrets.get(n) is not None:
78
+ env[f"MAESTRO_{n}"] = secrets[n]
79
+ return env
80
+
81
+
82
+ def run_replay(req: Mapping[str, Any], events: Events, stop: threading.Event, driver_factory: Optional[Callable[[], Any]] = None, model_factory: Optional[Callable[..., Any]] = None, maestro_run: Optional[Callable[..., Any]] = None, cloud_run: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
83
+ out = _out_dir(req)
84
+ mobile = _mobile(req)
85
+ workflow_file = str(req.get("workflow") or "")
86
+ wf = load(workflow_file)
87
+ if wf["format"] != MOBILE_FORMAT:
88
+ raise WorkflowError(f"{workflow_file} is a desktop workflow; run it with forgeqe desktop replay")
89
+ apps = load_mobile_apps(mobile)
90
+ if wf["app"] not in apps:
91
+ raise WorkflowError(f"app {wf['app']!r} is not in mobile.apps or the environment's mobile apps (configured: {', '.join(sorted(apps)) or 'none'})")
92
+ names = _secret_names(mobile)
93
+ driver_name = str(req.get("driver") or mobile.get("driver") or "appium")
94
+ base = {"mode": "replay", "driver": driver_name, "name": wf["name"], "app": wf["app"], "workflow": workflow_file, "total": len(wf["steps"])}
95
+
96
+ if driver_name == "maestro":
97
+ spec = apps[wf["app"]]
98
+ flow, used = to_flow(wf, spec.app_id, req.get("variables") or {}, names)
99
+ flow_file = out / "flow.yaml"
100
+ flow_file.write_text(flow, encoding="utf-8")
101
+ secrets = {n: os.environ[n] for n in used if os.environ.get(n) is not None}
102
+ missing = [n for n in used if n not in secrets]
103
+ if missing:
104
+ raise WorkflowError(f"secret {missing[0]} is not set")
105
+ m = mobile.get("maestro") if isinstance(mobile.get("maestro"), Mapping) else {}
106
+ testmu = testmu_settings(mobile)
107
+ if testmu:
108
+ if used:
109
+ raise WorkflowError("this workflow types secrets, which Maestro on TestMu cannot be given safely yet; replay it on TestMu with --driver appium")
110
+ events.log(f"Running {wf['name'] or 'the workflow'} on Maestro on TestMu ({', '.join(testmu['devices'])}).")
111
+ res = (cloud_run or run_hyperexecute)([(flow_file, "flow.yaml")], ["flow.yaml"], spec.app, spec.platform, testmu, out, wf["name"] or wf["app"], dict(os.environ))
112
+ else:
113
+ events.log(f"Running {wf['name'] or 'the workflow'} on Maestro ({flow_file}).")
114
+ res = (maestro_run or run_maestro)(_maestro_command(mobile), [str(flow_file)], out, _maestro_env(used, secrets), device=m.get("device"))
115
+ steps = _maestro_steps(wf, res.get("commands") or [], res["ok"])
116
+ for s in steps:
117
+ events.emit({"type": "step", **s})
118
+ return {**base, "ok": res["ok"], "steps": steps, "heals": [], "flow": str(flow_file), **({"job": res["job"]} if res.get("job") else {}), **({"error": res["error"]} if res.get("error") else {})}
119
+
120
+ from forgeqe_desktop.heal import make_heal
121
+ from forgeqe_desktop.models import takes_images
122
+
123
+ driver = (driver_factory or (lambda: appium_driver(mobile)))()
124
+ heal = make_heal(_model(req, events, model_factory), driver, log=events.log, images=takes_images(req.get("llm") or {})) if req.get("heal") else None
125
+ shots = bool(mobile.get("screenshots", True))
126
+ (out / "steps").mkdir(exist_ok=True)
127
+
128
+ def on_step(res: Any) -> None:
129
+ shot = None
130
+ if shots and res.op != "close":
131
+ png = driver.screenshot_png()
132
+ if png:
133
+ shot = f"steps/{res.index:03d}.png"
134
+ (out / shot).write_bytes(png)
135
+ events.emit({"type": "step", **_step_fields(res), "screenshot": shot})
136
+
137
+ result = replay(
138
+ wf,
139
+ driver,
140
+ apps,
141
+ variables=req.get("variables") or {},
142
+ allowed_secrets=names,
143
+ heal=heal,
144
+ must_exist=False,
145
+ on_step=on_step,
146
+ should_stop=stop.is_set,
147
+ compare_image=image_checker(workflow_file, out, update=bool(req.get("update_baselines"))),
148
+ )
149
+ steps = []
150
+ for s in result.steps:
151
+ shot = f"steps/{s.index:03d}.png"
152
+ steps.append({**_step_fields(s), "screenshot": shot if (out / shot).is_file() else None, "target": wf["steps"][s.index - 1].get("target")})
153
+ return {
154
+ **base,
155
+ "ok": result.ok,
156
+ "stopped": result.stopped,
157
+ "steps": steps,
158
+ "heals": [{"index": s.index, "old": wf["steps"][s.index - 1].get("target"), "new": s.healed_target} for s in result.healed],
159
+ }
160
+
161
+
162
+ def _maestro_steps(wf: Mapping[str, Any], commands: List[Mapping[str, Any]], ok: bool) -> List[Dict[str, Any]]:
163
+ """Each workflow step's result from Maestro's command results (a type step is three Maestro commands)."""
164
+ per_step = [3 if s["op"] == "type" else 1 for s in wf["steps"]]
165
+ if not commands and not ok:
166
+ # No step's result is known (Maestro wrote none, or the job's results did not come back): the run's error says
167
+ # what failed, rather than a step that may have passed.
168
+ return []
169
+ out: List[Dict[str, Any]] = []
170
+ pos = 0
171
+ for i, (step, n) in enumerate(zip(wf["steps"], per_step), 1):
172
+ chunk = commands[pos : pos + n]
173
+ pos += n
174
+ if not commands:
175
+ status_ok, error = ok, ""
176
+ elif not chunk:
177
+ break # Maestro stopped before this step
178
+ else:
179
+ failed = [c for c in chunk if c.get("status") == "FAILED"]
180
+ status_ok = not failed and all(c.get("status") in ("COMPLETED", "SKIPPED", "WARNED") for c in chunk)
181
+ error = failed[0].get("error", "failed") if failed else ""
182
+ out.append({"index": i, "op": step["op"], "ok": status_ok, "ms": 0, "error": error, "healed_target": None, "target": step.get("target")})
183
+ if not status_ok:
184
+ break
185
+ return out
186
+
187
+
188
+ def run_explore(req: Mapping[str, Any], events: Events, stop: threading.Event, driver_factory: Optional[Callable[[], Any]] = None, model_factory: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
189
+ from forgeqe_desktop.models import require_vision, takes_images
190
+
191
+ from .explore import explore
192
+
193
+ out = _out_dir(req)
194
+ mobile = _mobile(req)
195
+ goal = str(req.get("goal") or "").strip()
196
+ if not goal:
197
+ raise WorkflowError("explore needs a goal")
198
+ app = str(req.get("app") or "")
199
+ apps = load_mobile_apps(mobile)
200
+ if app not in apps:
201
+ raise WorkflowError(f"app {app!r} is not in mobile.apps or the environment's mobile apps (configured: {', '.join(sorted(apps)) or 'none'})")
202
+ profile = req.get("llm") or {}
203
+ require_vision(str(req.get("profile_name") or "profile"), profile)
204
+ names = _secret_names(mobile)
205
+ secrets = {n: os.environ[n] for n in names if os.environ.get(n) is not None}
206
+ driver = (driver_factory or (lambda: appium_driver(mobile)))()
207
+ name = str(req.get("name") or goal)[:120]
208
+ started = time.time()
209
+ error = ""
210
+ workflow: Optional[Dict[str, Any]] = None
211
+ outcome: Dict[str, Any] = {}
212
+ events.log(f"Starting {app} and exploring: {goal}")
213
+ try:
214
+ workflow, outcome = explore(
215
+ _model(req, events, model_factory), driver, apps[app], goal, name, secrets, images=takes_images(profile), log=events.log,
216
+ on_step=lambda s: events.emit({"type": "step", "ok": True, "ms": 0, **s}), should_stop=stop.is_set,
217
+ )
218
+ except Exception as e:
219
+ error = f"Explore stopped with an error: {type(e).__name__}: {e}"[:2000]
220
+ events.log(error, "error")
221
+ finally:
222
+ try:
223
+ driver.close()
224
+ except Exception:
225
+ pass
226
+ recorded = len(workflow["steps"]) - 1 - int(outcome.get("checks", 0)) if workflow else 0
227
+ workflow_file = out / "workflow.yaml"
228
+ if workflow and recorded:
229
+ save(workflow, workflow_file)
230
+ done = bool(outcome.get("done"))
231
+ return {
232
+ "mode": "explore",
233
+ "ok": recorded > 0 and done and not error,
234
+ "goal": goal,
235
+ "app": app,
236
+ "name": name,
237
+ "seconds": int(time.time() - started),
238
+ "recorded": recorded,
239
+ "checks": int(outcome.get("checks", 0)),
240
+ "values": outcome.get("values") or {},
241
+ "ufo_complete": done,
242
+ "workflow": str(workflow_file) if workflow and recorded else None,
243
+ "steps": [{"index": i, "op": s["op"], "ok": True, "ms": 0, "target": s.get("target")} for i, s in enumerate(workflow["steps"], 1)] if workflow else [],
244
+ "error": error or ("" if done else f"The model did not finish the goal: {outcome.get('reason') or 'no reason given'}"),
245
+ }
246
+
247
+
248
+ def run_flows(req: Mapping[str, Any], events: Events, maestro_run: Optional[Callable[..., Any]] = None, cloud_run: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
249
+ """The team's own Maestro flows: each checked against the allowlist, then run together."""
250
+ out = _out_dir(req)
251
+ mobile = _mobile(req)
252
+ root = str(req.get("root") or "")
253
+ flows = req.get("flows")
254
+ if not root or not isinstance(flows, list) or not flows or not all(isinstance(f, str) for f in flows):
255
+ raise WorkflowError("maestro mode needs flows (a list of files) and root (the project folder)")
256
+ checked: List[str] = []
257
+ for f in flows:
258
+ checked += check_flow(f, root)
259
+ names = _secret_names(mobile)
260
+ secrets = {n: os.environ[n] for n in names if os.environ.get(n) is not None}
261
+ m = mobile.get("maestro") if isinstance(mobile.get("maestro"), Mapping) else {}
262
+ testmu = testmu_settings(mobile)
263
+ name = f"{len(flows)} Maestro flow{'' if len(flows) == 1 else 's'}"
264
+ if testmu:
265
+ spec = _app_of_flows(flows, load_mobile_apps(mobile))
266
+ events.log(f"Checked {len(checked)} flow file{'' if len(checked) == 1 else 's'}; running {len(flows)} on Maestro on TestMu.")
267
+ # A flow that runs another (runFlow) needs it in the project too: every checked file goes, in its layout.
268
+ real_root = Path(os.path.realpath(root))
269
+ rel = lambda f: Path(os.path.realpath(f)).relative_to(real_root).as_posix() # noqa: E731
270
+ res = (cloud_run or run_hyperexecute)([(Path(f), rel(f)) for f in checked], [rel(f) for f in flows], spec.app, spec.platform, testmu, out, name, dict(os.environ))
271
+ else:
272
+ events.log(f"Checked {len(checked)} flow file{'' if len(checked) == 1 else 's'}; running {len(flows)} on Maestro.")
273
+ res = (maestro_run or run_maestro)(_maestro_command(mobile), [str(Path(f).resolve()) for f in flows], out, _maestro_env(names, secrets), device=m.get("device"))
274
+ return {"mode": "maestro", "ok": res["ok"], "flows": res.get("flows") or [], "checked": checked, "name": name, **({"job": res["job"]} if res.get("job") else {}), **({"error": res["error"]} if res.get("error") else {})}
275
+
276
+
277
+ def _app_of_flows(flows: List[str], apps: Mapping[str, Any]) -> Any:
278
+ """The configured mobile app the flows are for, by their appId: TestMu installs it from its `app`."""
279
+ ids = set()
280
+ for f in flows:
281
+ docs = list(yaml.safe_load_all(Path(f).read_text(encoding="utf-8")))
282
+ if docs and isinstance(docs[0], Mapping) and isinstance(docs[0].get("appId"), str):
283
+ ids.add(docs[0]["appId"])
284
+ if len(ids) != 1:
285
+ raise WorkflowError("to run on TestMu, the flows must all be for one app (one appId)")
286
+ app_id = ids.pop()
287
+ for spec in apps.values():
288
+ if spec.app_id == app_id:
289
+ return spec
290
+ raise WorkflowError(f"no mobile app with app_id {app_id} is configured; add it (with its app build or lt:// id) to env.yaml or mobile.apps")
291
+
292
+
293
+ def _watch_stop(stream: IO[str], stop: threading.Event) -> None:
294
+ for line in stream:
295
+ if line.strip() == "stop":
296
+ stop.set()
297
+ return
298
+
299
+
300
+ def serve(
301
+ stdin: IO[str] = sys.stdin,
302
+ stdout: IO[str] = sys.stdout,
303
+ driver_factory: Optional[Callable[[], Any]] = None,
304
+ model_factory: Optional[Callable[..., Any]] = None,
305
+ maestro_run: Optional[Callable[..., Any]] = None,
306
+ cloud_run: Optional[Callable[..., Any]] = None,
307
+ ) -> int:
308
+ events = Events(stdout)
309
+ stop = threading.Event()
310
+ out: Optional[Path] = None
311
+ try:
312
+ req = json.loads(stdin.readline() or "{}")
313
+ if not isinstance(req, dict):
314
+ raise WorkflowError("the request must be a JSON object")
315
+ threading.Thread(target=_watch_stop, args=(stdin, stop), daemon=True).start()
316
+ out = _out_dir(req)
317
+ mode = req.get("mode")
318
+ if mode == "replay":
319
+ result = run_replay(req, events, stop, driver_factory, model_factory, maestro_run, cloud_run)
320
+ elif mode == "explore":
321
+ result = run_explore(req, events, stop, driver_factory, model_factory)
322
+ elif mode == "maestro":
323
+ result = run_flows(req, events, maestro_run, cloud_run)
324
+ else:
325
+ raise WorkflowError(f"mode must be replay, explore or maestro, not {mode!r}")
326
+ except Exception as e:
327
+ result = {"ok": False, "error": f"{type(e).__name__}: {e}" if not isinstance(e, (WorkflowError, ValueError)) else str(e)}
328
+ result.setdefault("format", MOBILE_FORMAT)
329
+ if out is not None:
330
+ (out / "run.json").write_text(json.dumps(result, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
331
+ events.emit({"type": "done", "result": result})
332
+ return 0 if result.get("ok") else 1