flow2skill 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.
flow2skill/parser.py ADDED
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .model import (
8
+ ANY_PLACEHOLDER_SCAN_RE,
9
+ PLACEHOLDER_SCAN_RE,
10
+ Action,
11
+ FlowValidationError,
12
+ Selector,
13
+ Workflow,
14
+ classify_risk,
15
+ redact_url,
16
+ sanitize_fill_value,
17
+ )
18
+
19
+ _MISSING = object()
20
+
21
+ SELECTOR_METHODS = {
22
+ "get_by_role": "role",
23
+ "get_by_label": "label",
24
+ "get_by_placeholder": "placeholder",
25
+ "get_by_text": "text",
26
+ "get_by_test_id": "test_id",
27
+ "get_by_title": "title",
28
+ "get_by_alt_text": "alt_text",
29
+ "locator": "css",
30
+ }
31
+ ACTION_METHODS = {
32
+ "goto",
33
+ "click",
34
+ "fill",
35
+ "press",
36
+ "select_option",
37
+ "check",
38
+ "uncheck",
39
+ "hover",
40
+ }
41
+ ASSERT_METHODS = {
42
+ "to_be_visible": "assert_visible",
43
+ "to_contain_text": "assert_text",
44
+ "to_have_text": "assert_exact_text",
45
+ "to_have_url": "assert_url",
46
+ "to_have_value": "assert_value",
47
+ }
48
+
49
+
50
+ def _literal(node: ast.AST | None, default: Any = None) -> Any:
51
+ if node is None:
52
+ return default
53
+ try:
54
+ return ast.literal_eval(node)
55
+ except (ValueError, TypeError):
56
+ return default
57
+
58
+
59
+ def _kw_node(call: ast.Call, name: str) -> ast.AST | None:
60
+ for item in call.keywords:
61
+ if item.arg == name:
62
+ return item.value
63
+ return None
64
+
65
+
66
+ def _valid_call_shape(call: ast.Call, *, positional: int, keywords: set[str] | None = None) -> bool:
67
+ allowed = keywords or set()
68
+ names = [item.arg for item in call.keywords]
69
+ return (
70
+ len(call.args) == positional
71
+ and all(name is not None and name in allowed for name in names)
72
+ and len(names) == len(set(names))
73
+ )
74
+
75
+
76
+ def _root_is_page(node: ast.AST) -> bool:
77
+ while isinstance(node, (ast.Call, ast.Attribute)):
78
+ node = node.func if isinstance(node, ast.Call) else node.value
79
+ return isinstance(node, ast.Name) and node.id == "page"
80
+
81
+
82
+ def _selector(node: ast.AST) -> Selector | None:
83
+ if isinstance(node, ast.Name) and node.id == "page":
84
+ return Selector("page")
85
+ if isinstance(node, ast.Attribute) and node.attr == "first":
86
+ base = _selector(node.value)
87
+ if base:
88
+ return Selector(**{**base.__dict__, "modifiers": (*base.modifiers, "first")})
89
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
90
+ return None
91
+
92
+ method = node.func.attr
93
+ if method == "nth":
94
+ if not _valid_call_shape(node, positional=1):
95
+ return None
96
+ base = _selector(node.func.value)
97
+ index = _literal(node.args[0], _MISSING) if node.args else _MISSING
98
+ if base and isinstance(index, int) and index >= 0:
99
+ return Selector(**{**base.__dict__, "modifiers": (*base.modifiers, f"nth:{index}")})
100
+ return None
101
+
102
+ engine = SELECTOR_METHODS.get(method)
103
+ if not (engine and isinstance(node.func.value, ast.Name) and node.func.value.id == "page"):
104
+ return None
105
+ allowed_keywords = (
106
+ {"name", "exact"} if engine == "role" else ({"exact"} if engine != "css" else set())
107
+ )
108
+ if not _valid_call_shape(node, positional=1, keywords=allowed_keywords):
109
+ return None
110
+ exact_node = _kw_node(node, "exact")
111
+ exact = _literal(exact_node, _MISSING) if exact_node is not None else None
112
+ if exact is _MISSING or (exact is not None and not isinstance(exact, bool)):
113
+ return None
114
+
115
+ if engine == "role":
116
+ role = _literal(node.args[0], _MISSING) if node.args else _MISSING
117
+ if not isinstance(role, str) or not role:
118
+ return None
119
+ name_node = _kw_node(node, "name")
120
+ name = _literal(name_node, _MISSING) if name_node is not None else None
121
+ if name is _MISSING or (name is not None and not isinstance(name, str)):
122
+ return None
123
+ return Selector(engine="role", role=role, name=name, exact=exact)
124
+
125
+ value = _literal(node.args[0], _MISSING) if node.args else _MISSING
126
+ if not isinstance(value, str) or not value:
127
+ return None
128
+ return Selector(engine=engine, value=value, exact=exact)
129
+
130
+
131
+ def _call_arg(call: ast.Call, index: int = 0, default: Any = None) -> Any:
132
+ return _literal(call.args[index], default) if len(call.args) > index else default
133
+
134
+
135
+ def _portable_calls(tree: ast.Module) -> list[ast.Call]:
136
+ functions = [
137
+ node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
138
+ ]
139
+ if len(functions) != 1 or isinstance(functions[0], ast.AsyncFunctionDef):
140
+ raise FlowValidationError("Recording must contain exactly one synchronous test function")
141
+ test_function = functions[0]
142
+ if not test_function.name.startswith("test"):
143
+ raise FlowValidationError("Recorded function name must start with `test`")
144
+ args = test_function.args
145
+ if (
146
+ test_function.decorator_list
147
+ or len(args.args) != 1
148
+ or args.args[0].arg != "page"
149
+ or args.posonlyargs
150
+ or args.kwonlyargs
151
+ or args.vararg
152
+ or args.kwarg
153
+ or args.defaults
154
+ or args.kw_defaults
155
+ ):
156
+ raise FlowValidationError(
157
+ "Recorded test must be an undecorated function with exactly one `page` argument"
158
+ )
159
+
160
+ permitted_module_nodes = (ast.Import, ast.ImportFrom, ast.FunctionDef)
161
+ for node in tree.body:
162
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
163
+ continue
164
+ if not isinstance(node, permitted_module_nodes):
165
+ raise FlowValidationError(
166
+ f"Unsupported module-level statement at line {getattr(node, 'lineno', '?')}"
167
+ )
168
+
169
+ calls: list[ast.Call] = []
170
+ for statement in test_function.body:
171
+ if isinstance(statement, ast.Pass):
172
+ continue
173
+ if not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call)):
174
+ raise FlowValidationError(
175
+ "Control flow, assignments, context managers, and nested statements are not "
176
+ f"portable (line {getattr(statement, 'lineno', '?')})"
177
+ )
178
+ calls.append(statement.value)
179
+ return calls
180
+
181
+
182
+ def parse_codegen(
183
+ source: str,
184
+ *,
185
+ name: str,
186
+ intent: str = "Replay the demonstrated browser workflow reliably.",
187
+ success_criteria: str = "The recorded assertions pass.",
188
+ success_text: str | None = None,
189
+ redact_all_inputs: bool = True,
190
+ ) -> Workflow:
191
+ try:
192
+ tree = ast.parse(source)
193
+ except SyntaxError as exc:
194
+ raise FlowValidationError(
195
+ f"Recording is not valid Python at line {exc.lineno}: {exc.msg}"
196
+ ) from exc
197
+
198
+ actions: list[Action] = []
199
+ variables: list[str] = []
200
+ start_url = ""
201
+ value_index = 1
202
+ protected_literals: dict[str, str] = {}
203
+
204
+ def reject_captured_placeholder(value: str, line: int | None, context: str) -> None:
205
+ if ANY_PLACEHOLDER_SCAN_RE.search(value):
206
+ raise FlowValidationError(
207
+ f"Line {line}: captured placeholder syntax is not allowed in {context}"
208
+ )
209
+
210
+ def protect_text(value: str) -> str:
211
+ protected = value
212
+ for literal, variable in sorted(
213
+ protected_literals.items(), key=lambda item: len(item[0]), reverse=True
214
+ ):
215
+ if literal and literal in protected:
216
+ if len(literal) < 3 and protected != literal:
217
+ raise FlowValidationError(
218
+ "A protected value is too short to substitute safely inside selector text"
219
+ )
220
+ protected = protected.replace(literal, f"${{{variable}}}")
221
+ return protected
222
+
223
+ def protect_selector(selector: Selector, line: int | None) -> Selector:
224
+ fields = {
225
+ "value": selector.value,
226
+ "role": selector.role,
227
+ "name": selector.name,
228
+ }
229
+ for value in fields.values():
230
+ if value is not None:
231
+ reject_captured_placeholder(value, line, "selector")
232
+ return Selector(
233
+ engine=selector.engine,
234
+ value=protect_text(selector.value) if selector.value is not None else None,
235
+ role=protect_text(selector.role) if selector.role is not None else None,
236
+ name=protect_text(selector.name) if selector.name is not None else None,
237
+ exact=selector.exact,
238
+ modifiers=selector.modifiers,
239
+ )
240
+
241
+ def add_placeholders(value: Any) -> None:
242
+ if isinstance(value, str):
243
+ for variable in PLACEHOLDER_SCAN_RE.findall(value):
244
+ if variable not in variables:
245
+ variables.append(variable)
246
+
247
+ def fail(method: str, line: int | None, reason: str) -> None:
248
+ raise FlowValidationError(
249
+ f"Line {line}: {reason}; `{method}(...)` cannot be replayed safely"
250
+ )
251
+
252
+ for call in _portable_calls(tree):
253
+ if not isinstance(call.func, ast.Attribute):
254
+ fail("call", getattr(call, "lineno", None), "non-Playwright call detected")
255
+ method = call.func.attr
256
+ line = getattr(call, "lineno", None)
257
+
258
+ expect_call = call.func.value
259
+ is_expect = (
260
+ isinstance(expect_call, ast.Call)
261
+ and isinstance(expect_call.func, ast.Name)
262
+ and expect_call.func.id == "expect"
263
+ and bool(expect_call.args)
264
+ )
265
+ if is_expect:
266
+ if method not in ASSERT_METHODS:
267
+ fail(method, line, "unsupported Playwright assertion")
268
+ if not _valid_call_shape(expect_call, positional=1):
269
+ fail(method, line, "unsupported expect(...) arguments")
270
+ expected_arguments = 0 if method == "to_be_visible" else 1
271
+ if not _valid_call_shape(call, positional=expected_arguments):
272
+ fail(method, line, "unsupported assertion arguments")
273
+ selector = _selector(expect_call.args[0])
274
+ if not selector:
275
+ fail(method, line, "assertion target is dynamic or not portable")
276
+ selector = protect_selector(selector, line)
277
+ kind = ASSERT_METHODS[method]
278
+ expected = _call_arg(call, default=_MISSING)
279
+ if kind == "assert_visible":
280
+ expected = True
281
+ elif expected is _MISSING:
282
+ fail(method, line, "assertion value is dynamic or missing")
283
+ if kind == "assert_url":
284
+ if not isinstance(expected, str):
285
+ fail(method, line, "URL assertion must use a literal string")
286
+ reject_captured_placeholder(expected, line, "URL assertion")
287
+ expected = redact_url(protect_text(expected))
288
+ add_placeholders(expected)
289
+ elif kind in {"assert_text", "assert_exact_text", "assert_value"}:
290
+ if not isinstance(expected, str):
291
+ fail(method, line, "text/value assertion must use a literal string")
292
+ reject_captured_placeholder(expected, line, "assertion value")
293
+ raw_expected = expected
294
+ expected, variable = sanitize_fill_value(
295
+ selector,
296
+ expected,
297
+ value_index,
298
+ redact_all_inputs=redact_all_inputs,
299
+ )
300
+ value_index += 1
301
+ if variable and variable not in variables:
302
+ variables.append(variable)
303
+ if variable:
304
+ protected_literals[raw_expected] = variable
305
+ actions.append(
306
+ Action(kind=kind, selector=selector, expected=expected, source_line=line)
307
+ )
308
+ continue
309
+
310
+ if method not in ACTION_METHODS:
311
+ fail(method, line, "unsupported Playwright call")
312
+ expected_arguments = 1 if method in {"goto", "fill", "press", "select_option"} else 0
313
+ if not _valid_call_shape(call, positional=expected_arguments):
314
+ fail(method, line, "unsupported action arguments")
315
+ selector = _selector(call.func.value)
316
+ if selector is None:
317
+ fail(method, line, "action target is dynamic or not portable")
318
+ selector = protect_selector(selector, line)
319
+
320
+ if method == "goto":
321
+ raw_url = _call_arg(call, default=_MISSING)
322
+ if not isinstance(raw_url, str) or not raw_url:
323
+ fail(method, line, "navigation URL is dynamic or missing")
324
+ reject_captured_placeholder(raw_url, line, "navigation URL")
325
+ safe_url = redact_url(protect_text(raw_url))
326
+ if not start_url:
327
+ start_url = safe_url
328
+ add_placeholders(safe_url)
329
+ actions.append(
330
+ Action(
331
+ kind="goto",
332
+ selector=Selector("page"),
333
+ value=safe_url,
334
+ risk=classify_risk("goto", Selector("page"), safe_url),
335
+ source_line=line,
336
+ )
337
+ )
338
+ continue
339
+
340
+ value = _call_arg(call, default=_MISSING)
341
+ if method in {"fill", "press", "select_option"} and value is _MISSING:
342
+ fail(method, line, "action value is dynamic or missing")
343
+ if value is _MISSING:
344
+ value = None
345
+ if isinstance(value, str):
346
+ reject_captured_placeholder(value, line, "action value")
347
+ if method in {"fill", "select_option"}:
348
+ if not isinstance(value, str):
349
+ fail(method, line, "only literal string values are portable")
350
+ raw_value = value
351
+ value, variable = sanitize_fill_value(
352
+ selector,
353
+ value,
354
+ value_index,
355
+ redact_all_inputs=redact_all_inputs,
356
+ )
357
+ value_index += 1
358
+ if variable and variable not in variables:
359
+ variables.append(variable)
360
+ if variable:
361
+ protected_literals[raw_value] = variable
362
+ risk = classify_risk(method, selector, value)
363
+ actions.append(
364
+ Action(
365
+ kind=method,
366
+ selector=selector,
367
+ value=value,
368
+ risk=risk,
369
+ source_line=line,
370
+ )
371
+ )
372
+
373
+ if success_text:
374
+ success_selector = protect_selector(Selector(engine="text", value=success_text), None)
375
+ actions.append(
376
+ Action(
377
+ kind="assert_visible",
378
+ selector=success_selector,
379
+ expected=True,
380
+ note="Success criterion supplied during compilation",
381
+ )
382
+ )
383
+ if not actions:
384
+ raise FlowValidationError("No supported Playwright actions were found in the recording")
385
+ if not start_url:
386
+ raise FlowValidationError("A literal page.goto() URL is required for portable replay")
387
+ if not any(action.kind.startswith("assert_") for action in actions):
388
+ raise FlowValidationError(
389
+ "No executable assertion was captured; record an expect(...) assertion or supply "
390
+ "--success-text"
391
+ )
392
+
393
+ workflow = Workflow(
394
+ name=name,
395
+ intent=intent.strip() or "Replay the demonstrated browser workflow reliably.",
396
+ start_url=start_url,
397
+ actions=actions,
398
+ success_criteria=success_criteria.strip() or "The recorded assertions pass.",
399
+ variables=variables,
400
+ )
401
+ workflow.validate()
402
+ return workflow
403
+
404
+
405
+ def parse_codegen_file(path: str | Path, **kwargs: Any) -> Workflow:
406
+ source = Path(path).read_text(encoding="utf-8")
407
+ return parse_codegen(source, **kwargs)
flow2skill/recorder.py ADDED
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import secrets
6
+ import shutil
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .exporter import write_bundle
15
+ from .model import FlowValidationError, slugify
16
+ from .parser import parse_codegen
17
+
18
+ DEFAULT_CODEGEN_VERSION = "1.61.0"
19
+ STALE_CAPTURE_SECONDS = 60 * 60
20
+ SAFE_OPTION_RE = re.compile(r"^[A-Za-z0-9._-]+$")
21
+
22
+
23
+ def codegen_command(
24
+ *,
25
+ url: str,
26
+ raw_output: Path,
27
+ channel: str = "chrome",
28
+ test_id_attribute: str = "data-testid",
29
+ ) -> list[str]:
30
+ if not re.match(r"^(https?|file)://", url, re.IGNORECASE):
31
+ raise FlowValidationError("Recorder URL must use http://, https://, or file://")
32
+ if not SAFE_OPTION_RE.fullmatch(channel):
33
+ raise FlowValidationError("Browser channel contains unsupported characters")
34
+ if not SAFE_OPTION_RE.fullmatch(test_id_attribute):
35
+ raise FlowValidationError("Test-id attribute contains unsupported characters")
36
+ version = os.getenv("FLOW2SKILL_CODEGEN_VERSION", DEFAULT_CODEGEN_VERSION)
37
+ if not re.fullmatch(r"\d+(?:\.\d+){1,3}", version):
38
+ raise FlowValidationError("FLOW2SKILL_CODEGEN_VERSION must be numeric")
39
+ args = [
40
+ "npx",
41
+ "--yes",
42
+ f"playwright@{version}",
43
+ "codegen",
44
+ "--target=python-pytest",
45
+ f"--output={raw_output}",
46
+ f"--channel={channel}",
47
+ f"--test-id-attribute={test_id_attribute}",
48
+ "--block-service-workers",
49
+ url,
50
+ ]
51
+ if os.name == "nt":
52
+ node = shutil.which("node.exe")
53
+ npx_cmd = shutil.which("npx.cmd") or shutil.which("npx")
54
+ if not node or not npx_cmd:
55
+ raise FlowValidationError("Node.js and npx are required for recording")
56
+ npx_cli = Path(npx_cmd).resolve().parent / "node_modules" / "npm" / "bin" / "npx-cli.js"
57
+ if not npx_cli.is_file():
58
+ raise FlowValidationError(f"Could not locate the npx CLI beside {npx_cmd}")
59
+ return [node, str(npx_cli), *args[1:]]
60
+ npx = shutil.which("npx")
61
+ if not npx:
62
+ raise FlowValidationError("npx is required for recording but was not found on PATH")
63
+ args[0] = npx
64
+ return args
65
+
66
+
67
+ def compile_source(
68
+ source: str,
69
+ *,
70
+ name: str,
71
+ output_dir: str | Path,
72
+ intent: str = "Replay the demonstrated browser workflow reliably.",
73
+ success_criteria: str = "The recorded assertions pass.",
74
+ success_text: str | None = None,
75
+ redact_all_inputs: bool = True,
76
+ ) -> dict[str, Any]:
77
+ workflow = parse_codegen(
78
+ source,
79
+ name=name,
80
+ intent=intent,
81
+ success_criteria=success_criteria,
82
+ success_text=success_text,
83
+ redact_all_inputs=redact_all_inputs,
84
+ )
85
+ paths = write_bundle(workflow, output_dir)
86
+ return {
87
+ "workflow": workflow,
88
+ "paths": paths,
89
+ "output_dir": Path(output_dir).resolve(),
90
+ }
91
+
92
+
93
+ @dataclass
94
+ class RecordingJob:
95
+ job_id: str
96
+ name: str
97
+ intent: str
98
+ success_criteria: str
99
+ success_text: str | None
100
+ output_dir: Path
101
+ raw_path: Path
102
+ log_path: Path
103
+ process: subprocess.Popen[Any]
104
+ redact_all_inputs: bool = True
105
+ state: str = "recording"
106
+ error: str | None = None
107
+ result: dict[str, Any] | None = None
108
+ _finalized: bool = field(default=False, repr=False)
109
+
110
+ def status(self) -> dict[str, Any]:
111
+ exit_code = self.process.poll()
112
+ if exit_code is None:
113
+ return {
114
+ "job_id": self.job_id,
115
+ "state": "recording",
116
+ "message": "Recorder is open. Complete the flow, then close the Playwright window.",
117
+ }
118
+ if not self._finalized:
119
+ self._finalize(exit_code)
120
+ response: dict[str, Any] = {"job_id": self.job_id, "state": self.state}
121
+ if self.error:
122
+ response["error"] = self.error
123
+ if self.result:
124
+ workflow = self.result["workflow"]
125
+ response.update(
126
+ {
127
+ "workspace": str(self.output_dir),
128
+ "workflow": workflow.to_dict(),
129
+ "files": {key: str(value) for key, value in self.result["paths"].items()},
130
+ }
131
+ )
132
+ return response
133
+
134
+ def _finalize(self, exit_code: int) -> None:
135
+ self._finalized = True
136
+ try:
137
+ if exit_code != 0:
138
+ raise FlowValidationError(
139
+ f"Playwright recorder exited with code {exit_code}; ephemeral logs were deleted"
140
+ )
141
+ if not self.raw_path.is_file() or self.raw_path.stat().st_size == 0:
142
+ raise FlowValidationError("Recorder closed without producing a workflow")
143
+ source = self.raw_path.read_text(encoding="utf-8")
144
+ self.result = compile_source(
145
+ source,
146
+ name=self.name,
147
+ output_dir=self.output_dir,
148
+ intent=self.intent,
149
+ success_criteria=self.success_criteria,
150
+ success_text=self.success_text,
151
+ redact_all_inputs=self.redact_all_inputs,
152
+ )
153
+ self.state = "complete"
154
+ except Exception as exc: # surfaced to the local Studio/API
155
+ self.state = "failed"
156
+ self.error = str(exc)
157
+ finally:
158
+ # Codegen can briefly contain literal inputs. Never keep the raw capture.
159
+ self.raw_path.unlink(missing_ok=True)
160
+ self.log_path.unlink(missing_ok=True)
161
+
162
+ def cancel(self) -> dict[str, Any]:
163
+ if self.process.poll() is None:
164
+ if os.name == "nt":
165
+ subprocess.run(
166
+ ["taskkill.exe", "/PID", str(self.process.pid), "/T", "/F"],
167
+ stdout=subprocess.DEVNULL,
168
+ stderr=subprocess.DEVNULL,
169
+ check=False,
170
+ )
171
+ else:
172
+ self.process.terminate()
173
+ try:
174
+ self.process.wait(timeout=5)
175
+ except subprocess.TimeoutExpired:
176
+ self.process.kill()
177
+ self._finalized = True
178
+ self.state = "cancelled"
179
+ self.raw_path.unlink(missing_ok=True)
180
+ self.log_path.unlink(missing_ok=True)
181
+ return {"job_id": self.job_id, "state": self.state}
182
+
183
+
184
+ class RecorderManager:
185
+ def __init__(self, workspace_root: str | Path):
186
+ self.workspace_root = Path(workspace_root).resolve()
187
+ self.workspace_root.mkdir(parents=True, exist_ok=True)
188
+ self._jobs: dict[str, RecordingJob] = {}
189
+ self._lock = threading.Lock()
190
+ self._cleanup_stale_captures()
191
+
192
+ def _cleanup_stale_captures(self) -> None:
193
+ cutoff = time.time() - STALE_CAPTURE_SECONDS
194
+ for pattern in (".*.raw.py", ".*.recorder.log"):
195
+ for path in self.workspace_root.rglob(pattern):
196
+ try:
197
+ if path.is_file() and path.stat().st_mtime < cutoff:
198
+ path.unlink(missing_ok=True)
199
+ except OSError:
200
+ continue
201
+
202
+ def start(
203
+ self,
204
+ *,
205
+ name: str,
206
+ url: str,
207
+ intent: str,
208
+ success_criteria: str,
209
+ success_text: str | None,
210
+ redact_all_inputs: bool = True,
211
+ channel: str = "chrome",
212
+ ) -> RecordingJob:
213
+ if not url.lower().startswith(("http://", "https://", "file://")):
214
+ raise FlowValidationError("Recorder URL must use http://, https://, or file://")
215
+ workspace = self.workspace_root / slugify(name)
216
+ workspace.mkdir(parents=True, exist_ok=True)
217
+ job_id = secrets.token_hex(8)
218
+ raw_path = workspace / f".{job_id}.raw.py"
219
+ log_path = workspace / f".{job_id}.recorder.log"
220
+ command = codegen_command(url=url, raw_output=raw_path, channel=channel)
221
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
222
+ log_handle = log_path.open("w", encoding="utf-8")
223
+ try:
224
+ process = subprocess.Popen(
225
+ command,
226
+ cwd=workspace,
227
+ stdout=log_handle,
228
+ stderr=subprocess.STDOUT,
229
+ text=True,
230
+ creationflags=creationflags,
231
+ )
232
+ except Exception:
233
+ log_handle.close()
234
+ raw_path.unlink(missing_ok=True)
235
+ log_path.unlink(missing_ok=True)
236
+ raise
237
+ finally:
238
+ log_handle.close()
239
+ job = RecordingJob(
240
+ job_id=job_id,
241
+ name=name,
242
+ intent=intent,
243
+ success_criteria=success_criteria,
244
+ success_text=success_text,
245
+ output_dir=workspace,
246
+ raw_path=raw_path,
247
+ log_path=log_path,
248
+ process=process,
249
+ redact_all_inputs=redact_all_inputs,
250
+ )
251
+ with self._lock:
252
+ self._jobs[job_id] = job
253
+ return job
254
+
255
+ def get(self, job_id: str) -> RecordingJob:
256
+ with self._lock:
257
+ job = self._jobs.get(job_id)
258
+ if not job:
259
+ raise FlowValidationError("Unknown recorder job")
260
+ return job
261
+
262
+ def cancel(self, job_id: str) -> dict[str, Any]:
263
+ return self.get(job_id).cancel()
264
+
265
+ def terminate_all(self) -> None:
266
+ with self._lock:
267
+ jobs = list(self._jobs.values())
268
+ for job in jobs:
269
+ job.cancel()
270
+
271
+
272
+ def record_blocking(
273
+ *,
274
+ name: str,
275
+ url: str,
276
+ output_root: str | Path,
277
+ intent: str,
278
+ success_criteria: str,
279
+ success_text: str | None = None,
280
+ redact_all_inputs: bool = True,
281
+ channel: str = "chrome",
282
+ ) -> dict[str, Any]:
283
+ manager = RecorderManager(output_root)
284
+ job = manager.start(
285
+ name=name,
286
+ url=url,
287
+ intent=intent,
288
+ success_criteria=success_criteria,
289
+ success_text=success_text,
290
+ redact_all_inputs=redact_all_inputs,
291
+ channel=channel,
292
+ )
293
+ print("Recorder opened. Complete the browser flow, then close the Playwright window.")
294
+ job.process.wait()
295
+ status = job.status()
296
+ if status["state"] != "complete":
297
+ raise FlowValidationError(status.get("error", "Recording failed"))
298
+ return job.result or {}