@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,345 @@
1
+ """
2
+ Replay a desktop workflow without a model.
3
+
4
+ Steps run in order on a ``Driver`` and the run stops at the first failure. When a step's control is not found and a
5
+ ``heal`` callback is given, it may propose a new target once; the step is retried with it and the proposal is
6
+ returned for review, never written into the workflow here.
7
+
8
+ A step waits up to its ``timeout`` for its control, closing any ``dismiss`` dialog that is in the way, and is tried
9
+ again up to ``retries`` times; ``expect_image`` hands what it captured to ``compare_image``.
10
+
11
+ ``{{secret:NAME}}`` is filled only for names the caller allows (``forgeqe.yaml`` ``desktop.secrets``), so a workflow
12
+ cannot ask for ForgeQE's own keys or any other variable in the environment.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Protocol
19
+
20
+ from .apps import AppSpec, resolve_app
21
+ from .workflow import MOBILE_FORMAT, MOBILE_RETRY_OPS, RETRY_OPS, WorkflowError, env_secret, fill, fill_targets, parse_chord, secret_names, validate
22
+
23
+
24
+ class ControlNotFound(LookupError):
25
+ """No control in the app's windows matches a step's target."""
26
+
27
+
28
+ class Driver(Protocol):
29
+ def launch(self, app: AppSpec) -> None: ...
30
+ def find(self, target: Mapping[str, Any], timeout: float) -> Any: ...
31
+ def click(self, control: Any, double: bool = False) -> None: ...
32
+ def type_text(self, control: Any, text: str) -> None: ...
33
+ def select(self, control: Any, value: str) -> None: ...
34
+ def press(self, chord: List[str]) -> None: ...
35
+ def text_of(self, control: Any) -> str: ...
36
+ def close(self) -> None: ...
37
+
38
+
39
+ @dataclass
40
+ class StepResult:
41
+ index: int
42
+ op: str
43
+ ok: bool
44
+ ms: int
45
+ error: str = ""
46
+ healed_target: Optional[Dict[str, Any]] = None
47
+ attempts: int = 1
48
+ # Labels of the dialogs dismissed while this step ran.
49
+ dismissed: List[str] = field(default_factory=list)
50
+ # expect_image: the files written for the step (baseline, actual, diff) and how much differed.
51
+ images: Optional[Dict[str, Any]] = None
52
+ note: str = ""
53
+
54
+
55
+ @dataclass
56
+ class ReplayResult:
57
+ ok: bool
58
+ steps: List[StepResult] = field(default_factory=list)
59
+ stopped: bool = False
60
+
61
+ @property
62
+ def healed(self) -> List[StepResult]:
63
+ return [s for s in self.steps if s.healed_target is not None]
64
+
65
+
66
+ Heal = Callable[[int, Dict[str, Any], str], Optional[Dict[str, Any]]]
67
+ # expect_image: (step index, the step, the PNG captured now) -> (ok, message, images) from the caller, which owns the
68
+ # baselines folder and the run folder.
69
+ CompareImage = Callable[[int, Mapping[str, Any], bytes], "tuple[bool, str, Dict[str, Any]]"]
70
+
71
+ POLL_SECONDS = 0.25
72
+ MAX_DISMISS_ROUNDS = 3
73
+ MAX_SCROLLS = 15
74
+
75
+
76
+ def _label(target: Mapping[str, Any]) -> str:
77
+ return str(target.get("name") or target.get("automation_id") or "")
78
+
79
+
80
+ def replay(
81
+ workflow: Mapping[str, Any],
82
+ driver: Driver,
83
+ apps: Mapping[str, AppSpec],
84
+ variables: Optional[Mapping[str, Any]] = None,
85
+ allowed_secrets: Iterable[str] = (),
86
+ secret: Callable[[str], Optional[str]] = env_secret,
87
+ heal: Optional[Heal] = None,
88
+ find_timeout: float = 10.0,
89
+ sleep: Callable[[float], None] = time.sleep,
90
+ must_exist: bool = True,
91
+ on_step: Optional[Callable[[StepResult], None]] = None,
92
+ should_stop: Callable[[], bool] = lambda: False,
93
+ compare_image: Optional[CompareImage] = None,
94
+ clock: Callable[[], float] = time.time,
95
+ ) -> ReplayResult:
96
+ wf = validate(dict(workflow))
97
+ spec = resolve_app(wf["app"], apps, must_exist=must_exist)
98
+ allowed = {str(n) for n in allowed_secrets}
99
+ values: Dict[str, Any] = {**wf["variables"], **dict(variables or {})}
100
+ wf = fill_targets(wf, values)
101
+
102
+ def allowed_secret(name: str) -> Optional[str]:
103
+ if name not in allowed:
104
+ raise WorkflowError(f"secret {name} is not listed in forgeqe.yaml desktop.secrets")
105
+ return secret(name)
106
+
107
+ # Every placeholder is checked before the app starts, so a bad workflow fails without touching the desktop.
108
+ for step in wf["steps"]:
109
+ for k in ("text", "value"):
110
+ if k in step:
111
+ for name in secret_names(step[k]):
112
+ if name not in allowed:
113
+ raise WorkflowError(f"secret {name} is not listed in forgeqe.yaml desktop.secrets")
114
+ if step["op"] == "expect_image" and compare_image is None:
115
+ raise WorkflowError("this workflow has expect_image steps, and this replay has nowhere to keep baseline images")
116
+
117
+ runner = _Runner(driver, spec, values, allowed_secret, wf.get("timeout", find_timeout), wf.get("dismiss") or [], sleep, clock, compare_image)
118
+ runner.mobile = wf["format"] == MOBILE_FORMAT
119
+ retry_ops = MOBILE_RETRY_OPS if runner.mobile else RETRY_OPS
120
+ default_retries = int(wf.get("retries", 0))
121
+ result = ReplayResult(ok=True)
122
+ try:
123
+ for i, step in enumerate(wf["steps"], 1):
124
+ if should_stop():
125
+ result.ok, result.stopped = False, True
126
+ break
127
+ started = clock()
128
+ op = step["op"]
129
+ res = StepResult(index=i, op=op, ok=True, ms=0)
130
+ runner.res = res
131
+ tries = 1 + (int(step.get("retries", default_retries)) if op in retry_ops else 0)
132
+ error: Optional[Exception] = None
133
+ for attempt in range(1, tries + 1):
134
+ res.attempts = attempt
135
+ try:
136
+ runner.run(i, step)
137
+ error = None
138
+ break
139
+ except WorkflowError as e:
140
+ # A workflow that cannot run as written does not get better by trying again.
141
+ error = e
142
+ break
143
+ except Exception as e:
144
+ error = e
145
+ if attempt < tries:
146
+ sleep(min(1.0 * attempt, 5.0))
147
+ runner.dismiss_dialogs()
148
+ if isinstance(error, ControlNotFound) and "target" in step:
149
+ proposal = heal(i, dict(step["target"]), str(error)) if heal else None
150
+ if proposal is not None:
151
+ try:
152
+ # The proposal is checked like any target, then tried once.
153
+ healed = validate({**wf, "steps": [{**step, "target": proposal}]})["steps"][0]
154
+ runner.run(i, healed)
155
+ res.healed_target = healed["target"]
156
+ error = None
157
+ except Exception as e2:
158
+ error = ControlNotFound(f"{error}; the healed target failed too: {e2}")
159
+ if error is not None:
160
+ res.ok, res.error = False, str(error) + (f" (after {res.attempts} tries)" if res.attempts > 1 else "")
161
+ res.ms = int((clock() - started) * 1000)
162
+ result.steps.append(res)
163
+ if on_step is not None:
164
+ on_step(res)
165
+ if not res.ok:
166
+ result.ok = False
167
+ break
168
+ finally:
169
+ if not result.ok or not wf["steps"] or wf["steps"][-1]["op"] != "close":
170
+ try:
171
+ driver.close()
172
+ except Exception:
173
+ pass
174
+ return result
175
+
176
+
177
+ class _Runner:
178
+ def __init__(
179
+ self,
180
+ driver: Driver,
181
+ spec: AppSpec,
182
+ values: Mapping[str, Any],
183
+ secret: Callable[[str], Optional[str]],
184
+ timeout: float,
185
+ dismiss: List[Mapping[str, Any]],
186
+ sleep: Callable[[float], None],
187
+ clock: Callable[[], float],
188
+ compare_image: Optional[CompareImage],
189
+ ) -> None:
190
+ self.driver, self.spec, self.values, self.secret = driver, spec, values, secret
191
+ self.timeout, self.dismiss, self.sleep, self.clock, self.compare_image = timeout, dismiss, sleep, clock, compare_image
192
+ self.res = StepResult(index=0, op="", ok=True, ms=0)
193
+ self.mobile = False
194
+
195
+ def dismiss_dialogs(self) -> bool:
196
+ """Click every ``dismiss`` control that is on screen now; whether any was."""
197
+ clicked = False
198
+ for target in self.dismiss:
199
+ try:
200
+ control = self.driver.find(target, 0)
201
+ except ControlNotFound:
202
+ continue
203
+ except Exception:
204
+ continue
205
+ try:
206
+ self.driver.click(control)
207
+ self.res.dismissed.append(_label(target))
208
+ clicked = True
209
+ except Exception:
210
+ pass
211
+ return clicked
212
+
213
+ def find(self, target: Mapping[str, Any], timeout: float, ready: bool = False) -> Any:
214
+ """The control, waiting up to ``timeout``; a dialog in ``dismiss`` that hides it is closed and the search goes on."""
215
+ rounds = 0
216
+ while True:
217
+ started = self.clock()
218
+ try:
219
+ control = self.driver.find(target, timeout)
220
+ wait_ready = getattr(self.driver, "wait_ready", None)
221
+ if ready and wait_ready is not None:
222
+ wait_ready(control, max(0.0, timeout - (self.clock() - started)))
223
+ return control
224
+ except ControlNotFound:
225
+ # A dialog was in the way: close it and look again, a few times at most (a dialog that keeps coming
226
+ # back is a failure to see in the report, not a loop).
227
+ if rounds < MAX_DISMISS_ROUNDS and self.dismiss and self.dismiss_dialogs():
228
+ rounds += 1
229
+ continue
230
+ raise
231
+
232
+ def run(self, index: int, step: Mapping[str, Any]) -> None:
233
+ op = step["op"]
234
+ timeout = float(step.get("timeout", self.timeout))
235
+ driver = self.driver
236
+ if op == "launch":
237
+ if "timeout" in step:
238
+ driver.launch(self.spec, timeout=timeout) # type: ignore[call-arg]
239
+ else:
240
+ driver.launch(self.spec)
241
+ elif op == "close":
242
+ driver.close()
243
+ elif op == "wait":
244
+ self.sleep(float(step["seconds"]))
245
+ elif op == "keys" and self.mobile:
246
+ driver.press_key(step["keys"]) # type: ignore[attr-defined]
247
+ elif op == "keys":
248
+ driver.press(parse_chord(step["keys"]))
249
+ elif op == "back":
250
+ driver.back() # type: ignore[attr-defined]
251
+ elif op == "hide_keyboard":
252
+ driver.hide_keyboard() # type: ignore[attr-defined]
253
+ elif op == "swipe":
254
+ control = self.find(step["target"], timeout) if "target" in step else None
255
+ driver.swipe(step["direction"], control) # type: ignore[attr-defined]
256
+ elif op == "scroll_to":
257
+ self._scroll_to(step, timeout)
258
+ elif op in ("tap", "double_tap"):
259
+ driver.click(self.find(step["target"], timeout, ready=True), double=op == "double_tap")
260
+ elif op == "long_press":
261
+ driver.long_press(self.find(step["target"], timeout, ready=True)) # type: ignore[attr-defined]
262
+ elif op == "wait_for":
263
+ self._wait_for(step, timeout)
264
+ elif op == "expect_image":
265
+ self._expect_image(index, step, timeout)
266
+ else:
267
+ control = self.find(step["target"], timeout, ready=op in ("click", "double_click", "type", "select"))
268
+ if op in ("click", "double_click"):
269
+ driver.click(control, double=op == "double_click")
270
+ elif op == "type":
271
+ driver.type_text(control, fill(step["text"], self.values, self.secret))
272
+ elif op == "select":
273
+ driver.select(control, fill(step["value"], self.values, self.secret))
274
+ elif op == "expect" and "text" in step:
275
+ want = fill(step["text"], self.values, self.secret)
276
+ got = driver.text_of(control)
277
+ if want not in got:
278
+ if secret_names(step["text"]):
279
+ raise AssertionError("the control's text does not contain the expected value (it holds a secret, so it is not shown)")
280
+ raise AssertionError(f"expected {want!r} in the control's text, found {got!r}")
281
+
282
+ def _scroll_to(self, step: Mapping[str, Any], timeout: float) -> None:
283
+ """Scroll (default: down the page) until the target is on screen, up to the timeout or MAX_SCROLLS."""
284
+ direction = step.get("direction", "down")
285
+ deadline = self.clock() + timeout
286
+ for _ in range(MAX_SCROLLS):
287
+ try:
288
+ self.driver.find(step["target"], 0)
289
+ return
290
+ except ControlNotFound:
291
+ pass
292
+ if self.clock() >= deadline:
293
+ break
294
+ self.driver.scroll(direction) # type: ignore[attr-defined]
295
+ self.sleep(POLL_SECONDS)
296
+ self.driver.find(step["target"], 0)
297
+
298
+ def _wait_for(self, step: Mapping[str, Any], timeout: float) -> None:
299
+ target = step["target"]
300
+ deadline = self.clock() + timeout
301
+ if step.get("gone"):
302
+ while True:
303
+ try:
304
+ self.driver.find(target, 0)
305
+ except ControlNotFound:
306
+ return
307
+ if self.clock() >= deadline:
308
+ raise AssertionError(f"{_label(target)!r} was still there after {timeout:g} s")
309
+ # A dialog ("Save changes?") can be what keeps it there.
310
+ self.dismiss_dialogs()
311
+ self.sleep(POLL_SECONDS)
312
+ control = self.find(target, timeout)
313
+ if "text" not in step:
314
+ return
315
+ want = fill(step["text"], self.values, self.secret)
316
+ while True:
317
+ try:
318
+ got = self.driver.text_of(control)
319
+ except Exception:
320
+ got = ""
321
+ if want in got:
322
+ return
323
+ if self.clock() >= deadline:
324
+ if secret_names(step["text"]):
325
+ raise AssertionError(f"the control did not show the expected value within {timeout:g} s (it holds a secret, so it is not shown)")
326
+ raise AssertionError(f"expected {want!r} in the control's text within {timeout:g} s, found {got!r}")
327
+ self.sleep(POLL_SECONDS)
328
+ try:
329
+ control = self.driver.find(target, 0)
330
+ except ControlNotFound:
331
+ pass
332
+
333
+ def _expect_image(self, index: int, step: Mapping[str, Any], timeout: float) -> None:
334
+ capture = getattr(self.driver, "image_png", None)
335
+ if capture is None or self.compare_image is None:
336
+ raise WorkflowError("this driver cannot capture images for expect_image")
337
+ control = self.find(step["target"], timeout) if "target" in step else None
338
+ png = capture(control)
339
+ if not png:
340
+ raise AssertionError("the control could not be captured (is the window minimised, or the screen locked?)")
341
+ ok, message, images = self.compare_image(index, step, png)
342
+ self.res.images = images
343
+ self.res.note = message if ok else ""
344
+ if not ok:
345
+ raise AssertionError(message)