openadapt-flow 0.1.0__py3-none-any.whl

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 (37) hide show
  1. openadapt_flow/__init__.py +17 -0
  2. openadapt_flow/__main__.py +349 -0
  3. openadapt_flow/backend.py +33 -0
  4. openadapt_flow/backends/__init__.py +24 -0
  5. openadapt_flow/backends/playwright_backend.py +152 -0
  6. openadapt_flow/bench.py +162 -0
  7. openadapt_flow/compiler/__init__.py +6 -0
  8. openadapt_flow/compiler/codegen.py +88 -0
  9. openadapt_flow/compiler/compile.py +604 -0
  10. openadapt_flow/demo_driver.py +78 -0
  11. openadapt_flow/emit/__init__.py +13 -0
  12. openadapt_flow/emit/l1_artifact.py +151 -0
  13. openadapt_flow/emit/mcp_tool.py +174 -0
  14. openadapt_flow/emit/skill.py +145 -0
  15. openadapt_flow/ir.py +196 -0
  16. openadapt_flow/mockmed/__init__.py +9 -0
  17. openadapt_flow/mockmed/server.py +52 -0
  18. openadapt_flow/mockmed/static/app.js +209 -0
  19. openadapt_flow/mockmed/static/index.html +18 -0
  20. openadapt_flow/mockmed/static/styles.css +213 -0
  21. openadapt_flow/recorder.py +169 -0
  22. openadapt_flow/report.py +247 -0
  23. openadapt_flow/runtime/__init__.py +47 -0
  24. openadapt_flow/runtime/grounder.py +190 -0
  25. openadapt_flow/runtime/heal.py +262 -0
  26. openadapt_flow/runtime/replayer.py +419 -0
  27. openadapt_flow/runtime/resolver.py +297 -0
  28. openadapt_flow/vision/__init__.py +25 -0
  29. openadapt_flow/vision/hashing.py +55 -0
  30. openadapt_flow/vision/match.py +157 -0
  31. openadapt_flow/vision/ocr.py +147 -0
  32. openadapt_flow/vision/settle.py +61 -0
  33. openadapt_flow-0.1.0.dist-info/METADATA +138 -0
  34. openadapt_flow-0.1.0.dist-info/RECORD +37 -0
  35. openadapt_flow-0.1.0.dist-info/WHEEL +4 -0
  36. openadapt_flow-0.1.0.dist-info/entry_points.txt +2 -0
  37. openadapt_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,17 @@
1
+ """openadapt-flow: record once, compile, replay deterministically, heal on drift."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from openadapt_flow.ir import ( # noqa: F401
6
+ ActionKind,
7
+ Anchor,
8
+ HealEvent,
9
+ Landmark,
10
+ Postcondition,
11
+ PostconditionKind,
12
+ Resolution,
13
+ RunReport,
14
+ Step,
15
+ StepResult,
16
+ Workflow,
17
+ )
@@ -0,0 +1,349 @@
1
+ """openadapt-flow CLI.
2
+
3
+ Subcommands (thin wrappers over the module APIs; sibling modules are
4
+ imported lazily inside each handler so ``--help`` always works):
5
+
6
+ - ``demo-record`` — serve MockMed locally and record the canonical demo.
7
+ - ``compile`` — compile a recording directory into a workflow bundle.
8
+ - ``replay`` — replay a bundle; serves the bundled MockMed demo app when no
9
+ ``--url`` is given (with optional ``--drift`` to demonstrate healing).
10
+ - ``bench`` — replay a bundle N times against MockMed and aggregate.
11
+ - ``emit-skill`` — emit an Agent Skills folder for a bundle.
12
+ - ``emit-mcp`` — emit a standalone MCP ``server.py`` for a bundle.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Sequence
21
+
22
+ _VIEWPORT = {"width": 1280, "height": 800}
23
+
24
+
25
+ def _parse_params(pairs: Sequence[str] | None) -> dict[str, str]:
26
+ """Parse repeated ``--param k=v`` flags into a dict."""
27
+ params: dict[str, str] = {}
28
+ for pair in pairs or []:
29
+ if "=" not in pair:
30
+ raise SystemExit(f"--param expects k=v, got {pair!r}")
31
+ key, value = pair.split("=", 1)
32
+ params[key] = value
33
+ return params
34
+
35
+
36
+ def _with_drift(url: str, drift: str | None) -> str:
37
+ """Append a ``?drift=...`` query to a MockMed base URL."""
38
+ if not drift:
39
+ return url
40
+ return f"{url.rstrip('/')}/?drift={drift}"
41
+
42
+
43
+ def _cmd_demo_record(args: argparse.Namespace) -> int:
44
+ from openadapt_flow.demo_driver import record_triage_demo
45
+ from openadapt_flow.mockmed.server import serve
46
+
47
+ url, stop = serve(port=0)
48
+ try:
49
+ url = _with_drift(url, args.drift)
50
+ out = record_triage_demo(
51
+ url,
52
+ Path(args.out),
53
+ note_text=args.note_text,
54
+ param_name=args.param_name,
55
+ headed=args.headed,
56
+ )
57
+ print(f"Recording written to {out}")
58
+ finally:
59
+ stop()
60
+ return 0
61
+
62
+
63
+ def _cmd_compile(args: argparse.Namespace) -> int:
64
+ from openadapt_flow.compiler import compile_recording
65
+
66
+ workflow = compile_recording(
67
+ Path(args.recording), Path(args.out), name=args.name
68
+ )
69
+ print(
70
+ f"Compiled {len(workflow.steps)} steps into {args.out} "
71
+ f"(workflow: {workflow.name!r})"
72
+ )
73
+ return 0
74
+
75
+
76
+ def _default_run_dir() -> Path:
77
+ """Timestamped default run directory under ``runs/``."""
78
+ from datetime import datetime, timezone
79
+
80
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
81
+ return Path("runs") / f"replay-{stamp}"
82
+
83
+
84
+ def _cmd_replay(args: argparse.Namespace) -> int:
85
+ from playwright.sync_api import sync_playwright
86
+
87
+ from openadapt_flow.backends.playwright_backend import PlaywrightBackend
88
+ from openadapt_flow.ir import Workflow
89
+ from openadapt_flow.report import render_run_report
90
+ from openadapt_flow.runtime import Replayer
91
+
92
+ bundle = Path(args.bundle)
93
+ run_dir = Path(args.run_dir) if args.run_dir else _default_run_dir()
94
+ workflow = Workflow.load(bundle)
95
+ params = _parse_params(args.param)
96
+
97
+ if args.url and args.drift:
98
+ raise SystemExit(
99
+ "--drift only applies to the bundled MockMed demo app; "
100
+ "omit --url to use it (drift your own app for real)."
101
+ )
102
+
103
+ stop = None
104
+ url = args.url
105
+ if url is None:
106
+ from openadapt_flow.mockmed.server import serve
107
+
108
+ url, stop = serve(port=0)
109
+ url = _with_drift(url, args.drift)
110
+ drift_note = f" (drift: {args.drift})" if args.drift else ""
111
+ print(f"No --url given; replaying against bundled MockMed{drift_note}")
112
+
113
+ try:
114
+ with sync_playwright() as p:
115
+ browser = p.chromium.launch(headless=not args.headed)
116
+ page = browser.new_page(viewport=_VIEWPORT)
117
+ page.goto(url)
118
+ try:
119
+ backend = PlaywrightBackend(page)
120
+ report = Replayer(backend).run(
121
+ workflow,
122
+ params=params,
123
+ bundle_dir=bundle,
124
+ run_dir=run_dir,
125
+ save_healed_to=(
126
+ Path(args.save_healed_to)
127
+ if args.save_healed_to
128
+ else None
129
+ ),
130
+ )
131
+ finally:
132
+ browser.close()
133
+ finally:
134
+ if stop is not None:
135
+ stop()
136
+
137
+ report_md = render_run_report(run_dir)
138
+ outcome = "success" if report.success else "FAILED"
139
+ print(f"Replay {outcome}: {report_md}")
140
+ return 0 if report.success else 1
141
+
142
+
143
+ def _cmd_bench(args: argparse.Namespace) -> int:
144
+ from contextlib import contextmanager
145
+
146
+ from openadapt_flow.bench import run_bench
147
+ from openadapt_flow.mockmed.server import serve
148
+ from openadapt_flow.report import render_bench_report
149
+
150
+ url, stop = serve(port=0)
151
+ target_url = _with_drift(url, args.drift)
152
+
153
+ @contextmanager
154
+ def backend_factory():
155
+ from playwright.sync_api import sync_playwright
156
+
157
+ from openadapt_flow.backends.playwright_backend import (
158
+ PlaywrightBackend,
159
+ )
160
+
161
+ with sync_playwright() as p:
162
+ browser = p.chromium.launch(headless=not args.headed)
163
+ page = browser.new_page(viewport=_VIEWPORT)
164
+ page.goto(target_url)
165
+ try:
166
+ yield PlaywrightBackend(page)
167
+ finally:
168
+ browser.close()
169
+
170
+ run_root = Path(args.run_root)
171
+ try:
172
+ result = run_bench(
173
+ Path(args.bundle),
174
+ backend_factory,
175
+ args.n,
176
+ params=_parse_params(args.param),
177
+ run_root=run_root,
178
+ )
179
+ finally:
180
+ stop()
181
+
182
+ report_md = render_bench_report(
183
+ run_root / "bench.json", run_root / "BENCH.md"
184
+ )
185
+ print(
186
+ f"Bench: {result['success_count']}/{result['n']} succeeded "
187
+ f"(p50 {result['total_ms_p50']:.0f} ms) — {report_md}"
188
+ )
189
+ return 0 if result["success_count"] == result["n"] else 1
190
+
191
+
192
+ def _cmd_emit_skill(args: argparse.Namespace) -> int:
193
+ from openadapt_flow.emit.skill import emit_skill
194
+
195
+ skill_dir = emit_skill(Path(args.bundle), Path(args.out))
196
+ print(f"Skill written to {skill_dir}")
197
+ return 0
198
+
199
+
200
+ def _cmd_emit_mcp(args: argparse.Namespace) -> int:
201
+ from openadapt_flow.emit.mcp_tool import emit_mcp_server
202
+
203
+ server_path = emit_mcp_server(Path(args.bundle), Path(args.out))
204
+ print(f"MCP server written to {server_path}")
205
+ return 0
206
+
207
+
208
+ def build_parser() -> argparse.ArgumentParser:
209
+ """Build the top-level argument parser."""
210
+ parser = argparse.ArgumentParser(
211
+ prog="openadapt-flow",
212
+ description=(
213
+ "Record a workflow once, compile it into a deterministic "
214
+ "vision-anchored script, replay it locally, heal it on drift."
215
+ ),
216
+ )
217
+ sub = parser.add_subparsers(dest="command", required=True)
218
+
219
+ p = sub.add_parser(
220
+ "demo-record",
221
+ help="Serve MockMed and record the canonical triage demo",
222
+ )
223
+ p.add_argument("--out", required=True, help="Recording output directory")
224
+ p.add_argument(
225
+ "--note-text",
226
+ default="Follow-up in 2 weeks; BP recheck.",
227
+ help="Note text typed during the demo (recorded as a parameter)",
228
+ )
229
+ p.add_argument(
230
+ "--param-name", default="note", help="Parameter name for the note"
231
+ )
232
+ p.add_argument(
233
+ "--drift", default=None, help="Comma-separated MockMed drift modes"
234
+ )
235
+ p.add_argument(
236
+ "--headed", action="store_true", help="Run the browser headed"
237
+ )
238
+ p.set_defaults(func=_cmd_demo_record)
239
+
240
+ p = sub.add_parser(
241
+ "compile", help="Compile a recording into a workflow bundle"
242
+ )
243
+ p.add_argument("recording", help="Recording directory")
244
+ p.add_argument("--out", required=True, help="Output bundle directory")
245
+ p.add_argument("--name", required=True, help="Workflow name")
246
+ p.set_defaults(func=_cmd_compile)
247
+
248
+ p = sub.add_parser(
249
+ "replay",
250
+ help=(
251
+ "Replay a bundle (serves the bundled MockMed demo app when "
252
+ "no --url is given)"
253
+ ),
254
+ )
255
+ p.add_argument("bundle", help="Workflow bundle directory")
256
+ p.add_argument(
257
+ "--url",
258
+ default=None,
259
+ help=(
260
+ "URL of the target app (default: serve the bundled MockMed "
261
+ "demo app)"
262
+ ),
263
+ )
264
+ p.add_argument(
265
+ "--drift",
266
+ default=None,
267
+ help=(
268
+ "Comma-separated MockMed drift modes (theme,move,rename,modal) "
269
+ "to demonstrate self-healing; only valid without --url"
270
+ ),
271
+ )
272
+ p.add_argument(
273
+ "--run-dir",
274
+ default=None,
275
+ help=(
276
+ "Run output directory "
277
+ "(default: runs/replay-<UTC timestamp> under the current "
278
+ "directory)"
279
+ ),
280
+ )
281
+ p.add_argument(
282
+ "--param",
283
+ action="append",
284
+ metavar="K=V",
285
+ help="Parameter substitution (repeatable)",
286
+ )
287
+ p.add_argument(
288
+ "--save-healed-to",
289
+ default=None,
290
+ help="Write the healed bundle to this directory",
291
+ )
292
+ p.add_argument(
293
+ "--headed", action="store_true", help="Run the browser headed"
294
+ )
295
+ p.set_defaults(func=_cmd_replay)
296
+
297
+ p = sub.add_parser(
298
+ "bench", help="Replay a bundle N times against MockMed and aggregate"
299
+ )
300
+ p.add_argument("bundle", help="Workflow bundle directory")
301
+ p.add_argument("--n", type=int, default=3, help="Number of iterations")
302
+ p.add_argument(
303
+ "--drift",
304
+ default=None,
305
+ help="Comma-separated drift modes forwarded to the MockMed URL",
306
+ )
307
+ p.add_argument(
308
+ "--run-root", required=True, help="Directory for per-iteration runs"
309
+ )
310
+ p.add_argument(
311
+ "--param",
312
+ action="append",
313
+ metavar="K=V",
314
+ help="Parameter substitution (repeatable)",
315
+ )
316
+ p.add_argument(
317
+ "--headed", action="store_true", help="Run the browser headed"
318
+ )
319
+ p.set_defaults(func=_cmd_bench)
320
+
321
+ p = sub.add_parser(
322
+ "emit-skill", help="Emit an Agent Skills folder for a bundle"
323
+ )
324
+ p.add_argument("bundle", help="Workflow bundle directory")
325
+ p.add_argument(
326
+ "--out", required=True, help="Parent directory for the skill folder"
327
+ )
328
+ p.set_defaults(func=_cmd_emit_skill)
329
+
330
+ p = sub.add_parser(
331
+ "emit-mcp", help="Emit a standalone MCP server.py for a bundle"
332
+ )
333
+ p.add_argument("bundle", help="Workflow bundle directory")
334
+ p.add_argument(
335
+ "--out", required=True, help="Path for the generated server.py"
336
+ )
337
+ p.set_defaults(func=_cmd_emit_mcp)
338
+
339
+ return parser
340
+
341
+
342
+ def main(argv: Sequence[str] | None = None) -> int:
343
+ """CLI entry point. Returns a process exit code."""
344
+ args = build_parser().parse_args(argv)
345
+ return args.func(args)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ """Backend protocol: the only interface the runtime uses to touch a GUI.
2
+
3
+ The runtime is vision-only by construction — it sees PNG bytes and emits
4
+ clicks/keys at pixel coordinates. Anything that can screenshot and inject
5
+ input can be a backend: a Playwright page (reference/test backend), a native
6
+ OS layer (pyautogui/Quartz), or an RDP session.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class Backend(Protocol):
16
+ @property
17
+ def viewport(self) -> tuple[int, int]:
18
+ """(width, height) of the screen surface in pixels."""
19
+ ...
20
+
21
+ def screenshot(self) -> bytes:
22
+ """Return the current frame as PNG bytes."""
23
+ ...
24
+
25
+ def click(self, x: int, y: int, *, double: bool = False) -> None: ...
26
+
27
+ def type_text(self, text: str) -> None:
28
+ """Type text into the currently focused element."""
29
+ ...
30
+
31
+ def press(self, key: str) -> None:
32
+ """Press a key or chord, e.g. 'Enter', 'Tab', 'Meta+a'."""
33
+ ...
@@ -0,0 +1,24 @@
1
+ """Backend implementations of the `openadapt_flow.backend.Backend` protocol.
2
+
3
+ `PlaywrightBackend` is re-exported lazily so that importing this package does
4
+ not require playwright unless the backend is actually used.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING: # pragma: no cover
12
+ from openadapt_flow.backends.playwright_backend import PlaywrightBackend
13
+
14
+ __all__ = ["PlaywrightBackend"]
15
+
16
+
17
+ def __getattr__(name: str) -> object:
18
+ if name == "PlaywrightBackend":
19
+ from openadapt_flow.backends.playwright_backend import (
20
+ PlaywrightBackend,
21
+ )
22
+
23
+ return PlaywrightBackend
24
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,152 @@
1
+ """Playwright-driven reference backend (sync API, chromium, headless-capable).
2
+
3
+ Implements the `openadapt_flow.backend.Backend` protocol against a Playwright
4
+ `Page`: full-viewport PNG screenshots, mouse clicks at pixel coordinates,
5
+ keyboard typing, and key/chord presses. Viewport is fixed at 1280x800 with
6
+ deviceScaleFactor=1 so CSS pixels equal screenshot pixels.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Callable
12
+
13
+ if TYPE_CHECKING: # pragma: no cover
14
+ from playwright.sync_api import Page
15
+
16
+ VIEWPORT: tuple[int, int] = (1280, 800)
17
+
18
+ _MODIFIER_ALIASES = {
19
+ "meta": "Meta",
20
+ "cmd": "Meta",
21
+ "command": "Meta",
22
+ "ctrl": "Control",
23
+ "control": "Control",
24
+ "alt": "Alt",
25
+ "option": "Alt",
26
+ "shift": "Shift",
27
+ }
28
+
29
+ _NAMED_KEYS = {
30
+ "enter": "Enter",
31
+ "return": "Enter",
32
+ "tab": "Tab",
33
+ "escape": "Escape",
34
+ "esc": "Escape",
35
+ "backspace": "Backspace",
36
+ "delete": "Delete",
37
+ "space": "Space",
38
+ "home": "Home",
39
+ "end": "End",
40
+ "pageup": "PageUp",
41
+ "pagedown": "PageDown",
42
+ "arrowup": "ArrowUp",
43
+ "arrowdown": "ArrowDown",
44
+ "arrowleft": "ArrowLeft",
45
+ "arrowright": "ArrowRight",
46
+ }
47
+
48
+
49
+ def _normalize_chord(key: str) -> str:
50
+ """Normalize a key or chord like ``'Meta+a'`` to Playwright's format.
51
+
52
+ Modifier aliases (``ctrl``, ``cmd``, ...) are canonicalized; common named
53
+ keys are case-corrected; single characters pass through unchanged.
54
+
55
+ Args:
56
+ key: Key name or ``+``-joined chord (e.g. ``'Enter'``, ``'Meta+a'``).
57
+
58
+ Returns:
59
+ The Playwright-compatible key/chord string.
60
+ """
61
+ parts = [p for p in key.split("+") if p]
62
+ normalized: list[str] = []
63
+ for part in parts:
64
+ lower = part.lower()
65
+ if lower in _MODIFIER_ALIASES:
66
+ normalized.append(_MODIFIER_ALIASES[lower])
67
+ elif lower in _NAMED_KEYS:
68
+ normalized.append(_NAMED_KEYS[lower])
69
+ else:
70
+ normalized.append(part)
71
+ return "+".join(normalized)
72
+
73
+
74
+ class PlaywrightBackend:
75
+ """`Backend` implementation over a Playwright sync-API `Page`.
76
+
77
+ Attributes:
78
+ page: The underlying Playwright page (public so record-time helpers
79
+ such as the demo driver may use locators; replay never does).
80
+ """
81
+
82
+ def __init__(self, page: "Page") -> None:
83
+ """Wrap an existing Playwright page.
84
+
85
+ Args:
86
+ page: A page created with viewport 1280x800, deviceScaleFactor=1.
87
+ """
88
+ self.page = page
89
+
90
+ @property
91
+ def viewport(self) -> tuple[int, int]:
92
+ """(width, height) of the page viewport in pixels."""
93
+ size = self.page.viewport_size
94
+ if size is None: # pragma: no cover - viewport always set by launch()
95
+ return VIEWPORT
96
+ return (size["width"], size["height"])
97
+
98
+ def screenshot(self) -> bytes:
99
+ """Return the current full-viewport frame as PNG bytes."""
100
+ return self.page.screenshot(type="png", full_page=False)
101
+
102
+ def click(self, x: int, y: int, *, double: bool = False) -> None:
103
+ """Click (or double-click) at pixel coordinates via the mouse."""
104
+ if double:
105
+ self.page.mouse.dblclick(x, y)
106
+ else:
107
+ self.page.mouse.click(x, y)
108
+
109
+ def type_text(self, text: str) -> None:
110
+ """Type text into the currently focused element."""
111
+ self.page.keyboard.type(text)
112
+
113
+ def press(self, key: str) -> None:
114
+ """Press a key or chord, e.g. ``'Enter'`` or ``'Meta+a'``."""
115
+ self.page.keyboard.press(_normalize_chord(key))
116
+
117
+ @classmethod
118
+ def launch(
119
+ cls, url: str, headless: bool = True
120
+ ) -> tuple["PlaywrightBackend", Callable[[], None]]:
121
+ """Start Playwright + chromium, open ``url``, and return a backend.
122
+
123
+ Args:
124
+ url: URL to navigate the new page to.
125
+ headless: Whether to launch chromium headless.
126
+
127
+ Returns:
128
+ ``(backend, close)`` where ``close()`` shuts down the browser and
129
+ the Playwright driver.
130
+ """
131
+ from playwright.sync_api import sync_playwright
132
+
133
+ pw = sync_playwright().start()
134
+ try:
135
+ browser = pw.chromium.launch(headless=headless)
136
+ except Exception:
137
+ pw.stop()
138
+ raise
139
+ page = browser.new_page(
140
+ viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]},
141
+ device_scale_factor=1,
142
+ )
143
+ page.goto(url)
144
+ backend = cls(page)
145
+
146
+ def close() -> None:
147
+ try:
148
+ browser.close()
149
+ finally:
150
+ pw.stop()
151
+
152
+ return backend, close