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/__init__.py +6 -0
- flow2skill/__main__.py +3 -0
- flow2skill/cli.py +212 -0
- flow2skill/exporter.py +359 -0
- flow2skill/model.py +398 -0
- flow2skill/parser.py +407 -0
- flow2skill/recorder.py +298 -0
- flow2skill/replay.py +153 -0
- flow2skill/server.py +335 -0
- flow2skill/ui/demo_form.html +36 -0
- flow2skill/ui/index.html +274 -0
- flow2skill-0.1.0.dist-info/METADATA +233 -0
- flow2skill-0.1.0.dist-info/RECORD +17 -0
- flow2skill-0.1.0.dist-info/WHEEL +5 -0
- flow2skill-0.1.0.dist-info/entry_points.txt +2 -0
- flow2skill-0.1.0.dist-info/licenses/LICENSE +21 -0
- flow2skill-0.1.0.dist-info/top_level.txt +1 -0
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>
|