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/replay.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .exporter import describe_action
9
+ from .model import Action, FlowValidationError, Selector, Workflow
10
+
11
+
12
+ def resolve_value(value: Any) -> Any:
13
+ if isinstance(value, str) and "${" in value:
14
+
15
+ def replace(match: re.Match[str]) -> str:
16
+ variable = match.group(1)
17
+ resolved = os.getenv(variable)
18
+ if resolved is None:
19
+ raise FlowValidationError(f"Required environment variable is missing: {variable}")
20
+ return resolved
21
+
22
+ return re.sub(r"\$\{(F2S_[A-Z0-9_]+)\}", replace, value)
23
+ return value
24
+
25
+
26
+ def locate(page: Any, selector: Selector) -> Any:
27
+ if selector.engine == "page":
28
+ target = page
29
+ elif selector.engine == "role":
30
+ kwargs = {}
31
+ if selector.name is not None:
32
+ kwargs["name"] = resolve_value(selector.name)
33
+ if selector.exact is not None:
34
+ kwargs["exact"] = selector.exact
35
+ target = page.get_by_role(resolve_value(selector.role or ""), **kwargs)
36
+ elif selector.engine == "label":
37
+ target = page.get_by_label(resolve_value(selector.value or ""), exact=selector.exact)
38
+ elif selector.engine == "placeholder":
39
+ target = page.get_by_placeholder(resolve_value(selector.value or ""), exact=selector.exact)
40
+ elif selector.engine == "text":
41
+ target = page.get_by_text(resolve_value(selector.value or ""), exact=selector.exact)
42
+ elif selector.engine == "test_id":
43
+ target = page.get_by_test_id(resolve_value(selector.value or ""))
44
+ elif selector.engine == "title":
45
+ target = page.get_by_title(resolve_value(selector.value or ""), exact=selector.exact)
46
+ elif selector.engine == "alt_text":
47
+ target = page.get_by_alt_text(resolve_value(selector.value or ""), exact=selector.exact)
48
+ elif selector.engine == "css":
49
+ target = page.locator(resolve_value(selector.value or ""))
50
+ else:
51
+ raise FlowValidationError(f"Unsupported selector engine: {selector.engine}")
52
+ for modifier in selector.modifiers:
53
+ if modifier == "first":
54
+ target = target.first
55
+ elif modifier.startswith("nth:"):
56
+ target = target.nth(int(modifier.split(":", 1)[1]))
57
+ else:
58
+ raise FlowValidationError(f"Unsupported selector modifier: {modifier}")
59
+ return target
60
+
61
+
62
+ def execute_action(page: Any, action: Action) -> None:
63
+ from playwright.sync_api import expect
64
+
65
+ target = locate(page, action.selector)
66
+ if action.kind == "goto":
67
+ page.goto(resolve_value(action.value))
68
+ elif action.kind in {"click", "check", "uncheck", "hover"}:
69
+ getattr(target, action.kind)()
70
+ elif action.kind in {"fill", "press", "select_option"}:
71
+ getattr(target, action.kind)(resolve_value(action.value))
72
+ elif action.kind == "assert_visible":
73
+ expect(target).to_be_visible()
74
+ elif action.kind == "assert_text":
75
+ expect(target).to_contain_text(resolve_value(action.expected))
76
+ elif action.kind == "assert_exact_text":
77
+ expect(target).to_have_text(resolve_value(action.expected))
78
+ elif action.kind == "assert_url":
79
+ expect(page).to_have_url(resolve_value(action.expected))
80
+ elif action.kind == "assert_value":
81
+ expect(target).to_have_value(resolve_value(action.expected))
82
+ else:
83
+ raise FlowValidationError(f"Unsupported action: {action.kind}")
84
+
85
+
86
+ def plan(workflow: Workflow) -> str:
87
+ lines = [f"Flow: {workflow.name}", f"Intent: {workflow.intent}", ""]
88
+ for index, action in enumerate(workflow.actions, start=1):
89
+ gate = (
90
+ " [APPROVAL GATE]"
91
+ if action.risk == "approval"
92
+ else " [REVIEW]"
93
+ if action.risk == "review"
94
+ else ""
95
+ )
96
+ lines.append(f"{index:02d}. {describe_action(action)}{gate}")
97
+ lines.extend(
98
+ [
99
+ "",
100
+ f"Assertions: {sum(a.kind.startswith('assert_') for a in workflow.actions)}",
101
+ f"Protected variables: {len(workflow.variables)}",
102
+ f"Fingerprint: {workflow.fingerprint()}",
103
+ ]
104
+ )
105
+ return "\n".join(lines)
106
+
107
+
108
+ def replay(
109
+ workflow: Workflow,
110
+ *,
111
+ live: bool = False,
112
+ headed: bool = False,
113
+ allow_side_effects: bool = False,
114
+ channel: str | None = None,
115
+ evidence_dir: str | Path | None = None,
116
+ ) -> str:
117
+ workflow.validate()
118
+ if not live:
119
+ return plan(workflow)
120
+ if not any(action.kind.startswith("assert_") for action in workflow.actions):
121
+ raise FlowValidationError("Replay blocked: no executable assertion was captured")
122
+ risky = [a for a in workflow.actions if a.risk != "safe"]
123
+ if risky and not allow_side_effects:
124
+ labels = "; ".join(describe_action(action) for action in risky)
125
+ raise FlowValidationError(
126
+ "Replay blocked: reviewed or approval-gated browser actions are present. "
127
+ f"Review these steps first: {labels}"
128
+ )
129
+
130
+ evidence_root = Path(evidence_dir or ".flow2skill-evidence").resolve()
131
+ evidence_root.mkdir(parents=True, exist_ok=True)
132
+ from playwright.sync_api import sync_playwright
133
+
134
+ with sync_playwright() as playwright:
135
+ launch_options: dict[str, Any] = {"headless": not headed}
136
+ if channel:
137
+ launch_options["channel"] = channel
138
+ browser = playwright.chromium.launch(**launch_options)
139
+ context = browser.new_context()
140
+ page = context.new_page()
141
+ try:
142
+ for action in workflow.actions:
143
+ execute_action(page, action)
144
+ screenshot = evidence_root / f"{workflow.slug}-passed.png"
145
+ page.screenshot(path=str(screenshot), full_page=True)
146
+ return f"PASS {workflow.name}\nEvidence: {screenshot}"
147
+ except Exception:
148
+ screenshot = evidence_root / f"{workflow.slug}-failed.png"
149
+ page.screenshot(path=str(screenshot), full_page=True)
150
+ raise
151
+ finally:
152
+ context.close()
153
+ browser.close()
flow2skill/server.py ADDED
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import threading
6
+ import webbrowser
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from urllib.parse import parse_qs, urlsplit
11
+
12
+ from .model import FlowValidationError, Workflow, slugify
13
+ from .recorder import RecorderManager, compile_source
14
+
15
+ MAX_BODY = 1_000_000
16
+
17
+
18
+ def sample_recording(url: str) -> str:
19
+ return f"""from playwright.sync_api import Page, expect
20
+
21
+
22
+ def test_agent_release_gate(page: Page) -> None:
23
+ page.goto({url!r})
24
+ page.get_by_label("API token").fill("synthetic-demo-value-7Q9X")
25
+ page.get_by_role("button", name="Validate workflow").click()
26
+ expect(page.get_by_text("Ready for deterministic replay", exact=True)).to_be_visible()
27
+ """
28
+
29
+
30
+ def valid_local_authority(authority: str, expected_port: int) -> bool:
31
+ try:
32
+ parsed = urlsplit(f"http://{authority}")
33
+ request_port = parsed.port or 80
34
+ except ValueError:
35
+ return False
36
+ return (
37
+ parsed.hostname in {"127.0.0.1", "localhost", "::1"}
38
+ and parsed.username is None
39
+ and parsed.password is None
40
+ and parsed.path in {"", "/"}
41
+ and request_port == expected_port
42
+ )
43
+
44
+
45
+ def serve(
46
+ *,
47
+ host: str = "127.0.0.1",
48
+ port: int = 8765,
49
+ workspace_root: str | Path,
50
+ open_browser: bool = True,
51
+ ) -> None:
52
+ if host not in {"127.0.0.1", "localhost", "::1"}:
53
+ raise FlowValidationError("Flow2Skill Studio is local-only and may bind only to loopback")
54
+ token = secrets.token_hex(24)
55
+ root = Path(workspace_root).resolve()
56
+ root.mkdir(parents=True, exist_ok=True)
57
+ manager = RecorderManager(root)
58
+ index_template = files("flow2skill").joinpath("ui/index.html").read_text(encoding="utf-8")
59
+ demo_fixture = files("flow2skill").joinpath("ui/demo_form.html").read_text(encoding="utf-8")
60
+ index = index_template.replace("__FLOW2SKILL_TOKEN__", token)
61
+
62
+ class Handler(BaseHTTPRequestHandler):
63
+ server_version = "Flow2SkillStudio/0.1"
64
+
65
+ def log_message(self, fmt: str, *args: object) -> None:
66
+ print(f"[studio] {self.client_address[0]} {fmt % args}")
67
+
68
+ def _headers(self, status: int, content_type: str, length: int) -> None:
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", content_type)
71
+ self.send_header("Content-Length", str(length))
72
+ self.send_header("Cache-Control", "no-store")
73
+ self.send_header("X-Content-Type-Options", "nosniff")
74
+ self.send_header("X-Frame-Options", "DENY")
75
+ self.send_header("Referrer-Policy", "no-referrer")
76
+ self.send_header(
77
+ "Content-Security-Policy",
78
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; "
79
+ "script-src 'self' 'unsafe-inline'; img-src 'self' data:; "
80
+ "connect-src 'self'; frame-ancestors 'none'",
81
+ )
82
+ self.end_headers()
83
+
84
+ def _send(self, payload: bytes, content_type: str, status: int = 200) -> None:
85
+ self._headers(status, content_type, len(payload))
86
+ self.wfile.write(payload)
87
+
88
+ def _json(self, payload: object, status: int = 200) -> None:
89
+ self._send(
90
+ json.dumps(payload, ensure_ascii=False).encode("utf-8"),
91
+ "application/json; charset=utf-8",
92
+ status,
93
+ )
94
+
95
+ def _authorized(self) -> bool:
96
+ return secrets.compare_digest(self.headers.get("X-Flow2Skill-Token", ""), token)
97
+
98
+ def _valid_authority(self, authority: str) -> bool:
99
+ return valid_local_authority(authority, server.server_port)
100
+
101
+ def _valid_host(self) -> bool:
102
+ return self._valid_authority(self.headers.get("Host", ""))
103
+
104
+ def _same_origin(self) -> bool:
105
+ origin = self.headers.get("Origin")
106
+ if not origin:
107
+ return True
108
+ parsed = urlsplit(origin)
109
+ return parsed.scheme == "http" and self._valid_authority(parsed.netloc)
110
+
111
+ def _require_auth(self) -> bool:
112
+ if self._valid_host() and self._authorized() and self._same_origin():
113
+ return True
114
+ self._json({"error": "Unauthorized local request"}, 403)
115
+ return False
116
+
117
+ def _read_json(self) -> dict[str, object]:
118
+ try:
119
+ size = int(self.headers.get("Content-Length", "0"))
120
+ except ValueError as exc:
121
+ raise FlowValidationError("Invalid request size") from exc
122
+ if size <= 0 or size > MAX_BODY:
123
+ raise FlowValidationError("Request body is empty or too large")
124
+ raw = self.rfile.read(size)
125
+ try:
126
+ payload = json.loads(raw)
127
+ except json.JSONDecodeError as exc:
128
+ raise FlowValidationError("Request body must be valid JSON") from exc
129
+ if not isinstance(payload, dict):
130
+ raise FlowValidationError("Request body must be a JSON object")
131
+ return payload
132
+
133
+ def _workspace(self, name: str) -> Path:
134
+ workspace = (root / slugify(name)).resolve()
135
+ if root not in workspace.parents:
136
+ raise FlowValidationError("Invalid workspace")
137
+ return workspace
138
+
139
+ def _payload_text(self, payload: dict[str, object], key: str, *, default: str = "") -> str:
140
+ value = payload.get(key, default)
141
+ if not isinstance(value, str):
142
+ raise FlowValidationError(f"Request field `{key}` must be a string")
143
+ return value
144
+
145
+ def _payload_bool(
146
+ self, payload: dict[str, object], key: str, *, default: bool = True
147
+ ) -> bool:
148
+ value = payload.get(key, default)
149
+ if not isinstance(value, bool):
150
+ raise FlowValidationError(f"Request field `{key}` must be a boolean")
151
+ return value
152
+
153
+ def do_GET(self) -> None: # noqa: N802
154
+ parsed = urlsplit(self.path)
155
+ if not self._valid_host():
156
+ self._json({"error": "Invalid local Host header"}, 403)
157
+ return
158
+ if parsed.path == "/":
159
+ self._send(index.encode("utf-8"), "text/html; charset=utf-8")
160
+ return
161
+ if parsed.path == "/demo":
162
+ self._send(demo_fixture.encode("utf-8"), "text/html; charset=utf-8")
163
+ return
164
+ if parsed.path == "/favicon.ico":
165
+ self._send(b"", "image/x-icon", 204)
166
+ return
167
+ if not self._require_auth():
168
+ return
169
+ try:
170
+ if parsed.path == "/api/health":
171
+ self._json(
172
+ {
173
+ "ok": True,
174
+ "local_only": True,
175
+ "workspace_root": str(root),
176
+ }
177
+ )
178
+ return
179
+ if parsed.path == "/api/workspaces":
180
+ items = []
181
+ for child in sorted(
182
+ root.iterdir(), key=lambda path: path.stat().st_mtime, reverse=True
183
+ ):
184
+ manifest = child / "flow.json"
185
+ if not child.is_dir() or not manifest.is_file():
186
+ continue
187
+ try:
188
+ workflow = Workflow.read(manifest)
189
+ items.append(
190
+ {
191
+ "slug": child.name,
192
+ "name": workflow.name,
193
+ "actions": len(workflow.actions),
194
+ "assertions": sum(
195
+ action.kind.startswith("assert_")
196
+ for action in workflow.actions
197
+ ),
198
+ "warnings": len(workflow.warnings),
199
+ "path": str(child),
200
+ }
201
+ )
202
+ except Exception:
203
+ continue
204
+ self._json({"workspaces": items})
205
+ return
206
+ if parsed.path.startswith("/api/record/"):
207
+ job_id = parsed.path.rsplit("/", 1)[-1]
208
+ self._json(manager.get(job_id).status())
209
+ return
210
+ if parsed.path == "/api/artifact":
211
+ params = parse_qs(parsed.query)
212
+ workspace = self._workspace((params.get("workspace") or [""])[0])
213
+ filename = (params.get("file") or [""])[0]
214
+ allowed = {
215
+ "SKILL.md",
216
+ "README.md",
217
+ "flow.json",
218
+ "flow.yaml",
219
+ }
220
+ if filename not in allowed and not (
221
+ filename.startswith("test_") and filename.endswith(".py")
222
+ ):
223
+ raise FlowValidationError("Artifact is not previewable")
224
+ artifact = (workspace / filename).resolve()
225
+ if workspace not in artifact.parents or not artifact.is_file():
226
+ raise FlowValidationError("Artifact was not found")
227
+ self._json(
228
+ {
229
+ "workspace": workspace.name,
230
+ "file": filename,
231
+ "content": artifact.read_text(encoding="utf-8"),
232
+ }
233
+ )
234
+ return
235
+ self._json({"error": "Not found"}, 404)
236
+ except (FlowValidationError, OSError, ValueError) as exc:
237
+ self._json({"error": str(exc)}, 400)
238
+
239
+ def do_POST(self) -> None: # noqa: N802
240
+ if not self._require_auth():
241
+ return
242
+ try:
243
+ payload = self._read_json()
244
+ if self.path == "/api/compile":
245
+ name = self._payload_text(payload, "name").strip()
246
+ code = self._payload_text(payload, "code")
247
+ if not name:
248
+ raise FlowValidationError("Workflow name is required")
249
+ workspace = self._workspace(name)
250
+ result = compile_source(
251
+ code,
252
+ name=name,
253
+ output_dir=workspace,
254
+ intent=self._payload_text(payload, "intent"),
255
+ success_criteria=self._payload_text(payload, "success_criteria"),
256
+ success_text=self._payload_text(payload, "success_text").strip() or None,
257
+ redact_all_inputs=self._payload_bool(payload, "redact_all_inputs"),
258
+ )
259
+ workflow = result["workflow"]
260
+ self._json(
261
+ {
262
+ "state": "complete",
263
+ "workspace": workspace.name,
264
+ "path": str(workspace),
265
+ "workflow": workflow.to_dict(),
266
+ "files": {key: value.name for key, value in result["paths"].items()},
267
+ }
268
+ )
269
+ return
270
+ if self.path == "/api/demo":
271
+ name = "Agent release gate"
272
+ workspace = self._workspace(name)
273
+ result = compile_source(
274
+ sample_recording(f"http://127.0.0.1:{server.server_port}/demo"),
275
+ name=name,
276
+ output_dir=workspace,
277
+ intent="Validate a protected agent configuration and prove it is ready.",
278
+ success_criteria="The exact deterministic ready state is visible.",
279
+ )
280
+ self._json(
281
+ {
282
+ "state": "complete",
283
+ "workspace": workspace.name,
284
+ "path": str(workspace),
285
+ "workflow": result["workflow"].to_dict(),
286
+ "files": {key: value.name for key, value in result["paths"].items()},
287
+ }
288
+ )
289
+ return
290
+ if self.path == "/api/record/start":
291
+ name = self._payload_text(payload, "name").strip()
292
+ if not name:
293
+ raise FlowValidationError("Workflow name is required")
294
+ job = manager.start(
295
+ name=name,
296
+ url=self._payload_text(payload, "url").strip(),
297
+ intent=self._payload_text(payload, "intent").strip()
298
+ or "Replay the demonstrated browser workflow reliably.",
299
+ success_criteria=self._payload_text(payload, "success_criteria").strip()
300
+ or "The recorded assertions pass.",
301
+ success_text=self._payload_text(payload, "success_text").strip() or None,
302
+ redact_all_inputs=self._payload_bool(payload, "redact_all_inputs"),
303
+ channel=self._payload_text(payload, "channel", default="chrome")
304
+ or "chrome",
305
+ )
306
+ self._json(
307
+ {
308
+ "job_id": job.job_id,
309
+ "state": "recording",
310
+ "message": "Complete the flow in the recorder, then close its window.",
311
+ },
312
+ 202,
313
+ )
314
+ return
315
+ if self.path.startswith("/api/record/") and self.path.endswith("/cancel"):
316
+ job_id = self.path.split("/")[-2]
317
+ self._json(manager.cancel(job_id))
318
+ return
319
+ self._json({"error": "Not found"}, 404)
320
+ except (FlowValidationError, OSError, ValueError) as exc:
321
+ self._json({"error": str(exc)}, 400)
322
+
323
+ server = ThreadingHTTPServer((host, port), Handler)
324
+ url = f"http://{host}:{server.server_port}/"
325
+ print(f"Flow2Skill Studio: {url}")
326
+ print(f"Workspaces: {root}")
327
+ if open_browser:
328
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
329
+ try:
330
+ server.serve_forever()
331
+ except KeyboardInterrupt:
332
+ pass
333
+ finally:
334
+ manager.terminate_all()
335
+ server.server_close()
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Flow2Skill Agent Release Gate</title>
7
+ <style>
8
+ :root{color-scheme:dark;--bg:#090b0a;--panel:#121513;--line:#2c332e;--acid:#d8ff52;--ink:#f4f1e8;--muted:#99a29c;--cyan:#62e6ff}
9
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 80% 10%,#18200f 0,transparent 34%),linear-gradient(135deg,#080a09,#0d100e);color:var(--ink);font:16px/1.5 Inter,Segoe UI,sans-serif}.shell{width:min(1000px,92vw);border:1px solid var(--line);border-radius:24px;background:rgba(18,21,19,.96);box-shadow:0 30px 100px #0008;overflow:hidden}.bar{display:flex;justify-content:space-between;padding:16px 22px;border-bottom:1px solid var(--line);font:700 12px ui-monospace,monospace;letter-spacing:.12em}.badge{color:var(--acid)}main{padding:54px}.eyebrow{color:var(--acid);font:700 12px ui-monospace,monospace;letter-spacing:.13em}h1{font-size:clamp(42px,7vw,76px);line-height:.95;margin:18px 0;letter-spacing:-.055em}p{color:var(--muted);max-width:690px}.gate{display:grid;grid-template-columns:1fr auto;gap:12px;margin-top:38px}label{grid-column:1/-1;color:#cdd3cf;font:700 12px ui-monospace,monospace;letter-spacing:.1em;text-transform:uppercase}input{min-width:0;border:1px solid #3c443f;border-radius:12px;background:#090b0a;color:var(--ink);padding:17px;font-size:17px;outline:none}input:focus{border-color:var(--acid);box-shadow:0 0 0 3px #d8ff5222}button{border:0;border-radius:12px;background:var(--acid);color:#0a0c0b;padding:0 27px;font-weight:850;font-size:16px;cursor:pointer}#result{margin:30px 0 0;padding:22px;border:1px solid #53672f;border-radius:14px;background:#d8ff520d;color:var(--ink);font:700 21px ui-monospace,monospace}.proof{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:34px}.proof div{padding:18px;border:1px solid var(--line);border-radius:12px;color:var(--muted);font-size:13px}.proof strong{display:block;color:var(--cyan);font:700 15px ui-monospace,monospace;margin-bottom:4px}@media(max-width:640px){main{padding:32px}.gate{grid-template-columns:1fr}.proof{grid-template-columns:1fr}button{height:54px}}
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <section class="shell">
14
+ <header class="bar"><span>AGENT RELEASE GATE</span><span class="badge">LOCAL FIXTURE · NO EXTERNAL CALLS</span></header>
15
+ <main>
16
+ <div class="eyebrow">DETERMINISTIC APPROVAL PROOF</div>
17
+ <h1>Prove the workflow,<br>not the promise.</h1>
18
+ <p>This packaged fixture validates Flow2Skill without an account, network request, persistent write, or production credential.</p>
19
+ <div class="gate">
20
+ <label for="api-token">API token</label>
21
+ <input id="api-token" type="password" placeholder="Synthetic demo token" autocomplete="off">
22
+ <button type="button" id="validate-button">Validate workflow</button>
23
+ </div>
24
+ <h2 id="result" role="status" hidden>Ready for deterministic replay</h2>
25
+ <div class="proof">
26
+ <div><strong>EXTERNAL CALLS: 0</strong>Everything runs on loopback.</div>
27
+ <div><strong>PERSISTENT WRITES: 0</strong>The fixture stores nothing.</div>
28
+ <div><strong>FIXTURE MODE: DETERMINISTIC</strong>One exact mechanical gate.</div>
29
+ </div>
30
+ </main>
31
+ </section>
32
+ <script>
33
+ document.getElementById('validate-button').addEventListener('click',()=>{const token=document.getElementById('api-token').value;const result=document.getElementById('result');if(token){result.hidden=false}});
34
+ </script>
35
+ </body>
36
+ </html>