aetherius 0.2.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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""HTTP client wrapper: httpx.Client with tenacity-based retries and pluggable auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from tenacity import (
|
|
9
|
+
RetryError,
|
|
10
|
+
retry,
|
|
11
|
+
retry_if_exception_type,
|
|
12
|
+
stop_after_attempt,
|
|
13
|
+
wait_exponential,
|
|
14
|
+
wait_fixed,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ...core.blueprint.models import RetriesOptions
|
|
18
|
+
from ...core.errors import NetworkError, RetryExhaustedError, StatusAssertionError, TimeoutError
|
|
19
|
+
from .auth import AuthStrategy, NoAuth
|
|
20
|
+
|
|
21
|
+
_RETRYABLE = (httpx.TransportError, httpx.TimeoutException)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_retry(retries: RetriesOptions) -> Any:
|
|
25
|
+
"""Return a tenacity retry decorator configured from Blueprint options."""
|
|
26
|
+
if retries.max == 0:
|
|
27
|
+
return None
|
|
28
|
+
stop = stop_after_attempt(retries.max + 1)
|
|
29
|
+
wait: Any
|
|
30
|
+
if retries.backoff == "exponential":
|
|
31
|
+
wait = wait_exponential(multiplier=1, min=1, max=30)
|
|
32
|
+
elif retries.backoff == "linear":
|
|
33
|
+
wait = wait_fixed(1)
|
|
34
|
+
else:
|
|
35
|
+
wait = wait_fixed(0)
|
|
36
|
+
return retry(
|
|
37
|
+
retry=retry_if_exception_type(_RETRYABLE),
|
|
38
|
+
stop=stop,
|
|
39
|
+
wait=wait,
|
|
40
|
+
reraise=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VectorClient:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
timeout_ms: int = 30_000,
|
|
49
|
+
retries: RetriesOptions | None = None,
|
|
50
|
+
auth: AuthStrategy | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._timeout = timeout_ms / 1000
|
|
53
|
+
self._retries = retries or RetriesOptions()
|
|
54
|
+
self._auth: AuthStrategy = auth or NoAuth()
|
|
55
|
+
self._client = httpx.Client(timeout=self._timeout, follow_redirects=True)
|
|
56
|
+
self._auth.prepare(self._client)
|
|
57
|
+
self._retry_decorator = _build_retry(self._retries)
|
|
58
|
+
|
|
59
|
+
def request(
|
|
60
|
+
self,
|
|
61
|
+
method: str,
|
|
62
|
+
url: str,
|
|
63
|
+
*,
|
|
64
|
+
headers: dict[str, str] | None = None,
|
|
65
|
+
json: Any | None = None,
|
|
66
|
+
form: dict[str, str] | None = None,
|
|
67
|
+
params: dict[str, str] | None = None,
|
|
68
|
+
expected_status: int | None = None,
|
|
69
|
+
) -> httpx.Response:
|
|
70
|
+
"""Send an HTTP request with optional auth, retries, and status assertion.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ActionError: if both json and form are provided.
|
|
74
|
+
TimeoutError: on httpx timeout.
|
|
75
|
+
RetryExhaustedError: after all retry attempts fail.
|
|
76
|
+
NetworkError: on other transport failures.
|
|
77
|
+
StatusAssertionError: if expected_status is set and response status doesn't match.
|
|
78
|
+
"""
|
|
79
|
+
from ...core.errors import ActionError
|
|
80
|
+
|
|
81
|
+
if json is not None and form is not None:
|
|
82
|
+
raise ActionError("Cannot set both 'json' and 'form' on the same http.request step.")
|
|
83
|
+
|
|
84
|
+
req = httpx.Request(
|
|
85
|
+
method=method.upper(),
|
|
86
|
+
url=url,
|
|
87
|
+
headers=headers or {},
|
|
88
|
+
params=params,
|
|
89
|
+
json=json,
|
|
90
|
+
data=form,
|
|
91
|
+
)
|
|
92
|
+
req = self._auth.apply(req)
|
|
93
|
+
|
|
94
|
+
def _send() -> httpx.Response:
|
|
95
|
+
return self._client.send(req)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if self._retry_decorator is not None:
|
|
99
|
+
response: httpx.Response = self._retry_decorator(_send)()
|
|
100
|
+
else:
|
|
101
|
+
response = _send()
|
|
102
|
+
except httpx.TimeoutException as exc:
|
|
103
|
+
raise TimeoutError(f"Request timed out: {method} {url}") from exc
|
|
104
|
+
except RetryError as exc:
|
|
105
|
+
raise RetryExhaustedError(
|
|
106
|
+
f"All {self._retries.max} retry attempts failed: {method} {url}",
|
|
107
|
+
last_error=exc,
|
|
108
|
+
) from exc
|
|
109
|
+
except httpx.TransportError as exc:
|
|
110
|
+
raise NetworkError(f"Transport error: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
if expected_status is not None and response.status_code != expected_status:
|
|
113
|
+
raise StatusAssertionError(
|
|
114
|
+
expected=expected_status,
|
|
115
|
+
actual=response.status_code,
|
|
116
|
+
url=url,
|
|
117
|
+
body_preview=response.text,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
self._client.close()
|
|
124
|
+
|
|
125
|
+
def __enter__(self) -> "VectorClient":
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def __exit__(self, *_: object) -> None:
|
|
129
|
+
self.close()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Vector driver: executes http.request and utility actions for Act I (no browser)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .._shared import SharedActionsMixin
|
|
8
|
+
from ...core.blueprint.models import StepModel
|
|
9
|
+
from ...core.errors import ActionError
|
|
10
|
+
from ...core.events.bus import EventBus
|
|
11
|
+
from ...core.extraction.html_extractor import HtmlExtractSpec, extract_html
|
|
12
|
+
from ...core.extraction.json_extractor import ExtractSpec, extract_json
|
|
13
|
+
from ...core.runtime.context import RunContext
|
|
14
|
+
from .client import VectorClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VectorDriver(SharedActionsMixin):
|
|
18
|
+
act = "vector"
|
|
19
|
+
|
|
20
|
+
def setup(self, ctx: RunContext) -> None:
|
|
21
|
+
timeout_ms = ctx.blueprint.options.timeout_ms or 30_000
|
|
22
|
+
self._client = VectorClient(
|
|
23
|
+
timeout_ms=timeout_ms,
|
|
24
|
+
retries=ctx.blueprint.options.retries,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def teardown(self, ctx: RunContext) -> None:
|
|
28
|
+
self._client.close()
|
|
29
|
+
|
|
30
|
+
def run_step(
|
|
31
|
+
self,
|
|
32
|
+
step: StepModel,
|
|
33
|
+
ctx: RunContext,
|
|
34
|
+
bus: EventBus,
|
|
35
|
+
renderer: Callable[[Any], Any],
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
match step.action:
|
|
38
|
+
case "http.request":
|
|
39
|
+
return self._http_request(step, ctx, bus, renderer)
|
|
40
|
+
case "set":
|
|
41
|
+
return self._set(step, renderer)
|
|
42
|
+
case "assert":
|
|
43
|
+
return self._assert(step, renderer)
|
|
44
|
+
case "emit":
|
|
45
|
+
return self._emit(step, ctx, bus, renderer)
|
|
46
|
+
case "wait":
|
|
47
|
+
return self._wait(step, renderer)
|
|
48
|
+
case _:
|
|
49
|
+
raise ActionError(f"VectorDriver: unsupported action {step.action!r}")
|
|
50
|
+
|
|
51
|
+
# ── Action handlers ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def _http_request(
|
|
54
|
+
self,
|
|
55
|
+
step: StepModel,
|
|
56
|
+
ctx: RunContext,
|
|
57
|
+
bus: EventBus,
|
|
58
|
+
renderer: Callable[[Any], Any],
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
p = step.extra_fields
|
|
61
|
+
method: str = renderer(p.get("method", "GET"))
|
|
62
|
+
url: str = renderer(p.get("url", ""))
|
|
63
|
+
headers: dict[str, str] = renderer(p.get("headers") or {})
|
|
64
|
+
form: dict[str, str] | None = renderer(p["form"]) if "form" in p else None
|
|
65
|
+
json_body: Any = renderer(p["json"]) if "json" in p else None
|
|
66
|
+
params: dict[str, str] | None = renderer(p.get("params"))
|
|
67
|
+
expect: dict[str, Any] = renderer(p.get("expect") or {})
|
|
68
|
+
expected_status: int | None = expect.get("status")
|
|
69
|
+
|
|
70
|
+
response = self._client.request(
|
|
71
|
+
method,
|
|
72
|
+
url,
|
|
73
|
+
headers=headers,
|
|
74
|
+
json=json_body,
|
|
75
|
+
form=form,
|
|
76
|
+
params=params,
|
|
77
|
+
expected_status=expected_status,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
outputs: dict[str, Any] = {
|
|
81
|
+
"status_code": response.status_code,
|
|
82
|
+
"headers": dict(response.headers),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
extract_specs: dict[str, Any] = p.get("extract") or {}
|
|
86
|
+
if extract_specs:
|
|
87
|
+
content_type = response.headers.get("content-type", "")
|
|
88
|
+
extracted = self._dispatch_extract(
|
|
89
|
+
response.content, extract_specs, content_type, renderer
|
|
90
|
+
)
|
|
91
|
+
outputs.update(extracted)
|
|
92
|
+
|
|
93
|
+
return outputs
|
|
94
|
+
|
|
95
|
+
def _dispatch_extract(
|
|
96
|
+
self,
|
|
97
|
+
body: bytes,
|
|
98
|
+
raw_specs: dict[str, Any],
|
|
99
|
+
content_type: str,
|
|
100
|
+
renderer: Callable[[Any], Any],
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
json_specs: dict[str, ExtractSpec] = {}
|
|
103
|
+
html_specs: dict[str, HtmlExtractSpec] = {}
|
|
104
|
+
|
|
105
|
+
for name, raw in raw_specs.items():
|
|
106
|
+
from_val: str = raw.get("from", "json")
|
|
107
|
+
if from_val == "json":
|
|
108
|
+
json_specs[name] = ExtractSpec(
|
|
109
|
+
from_=from_val,
|
|
110
|
+
path=raw.get("path", "$"),
|
|
111
|
+
where=raw.get("where"),
|
|
112
|
+
fields={k: v for k, v in (raw.get("fields") or {}).items()},
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
html_specs[name] = HtmlExtractSpec(
|
|
116
|
+
from_=from_val,
|
|
117
|
+
selector=raw.get("selector", ""),
|
|
118
|
+
selector_type=raw.get("selector_type", "css"),
|
|
119
|
+
attr=raw.get("attr"),
|
|
120
|
+
multiple=raw.get("multiple", True),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
result: dict[str, Any] = {}
|
|
124
|
+
if json_specs:
|
|
125
|
+
result.update(extract_json(body, json_specs))
|
|
126
|
+
if html_specs:
|
|
127
|
+
result.update(extract_html(body, html_specs))
|
|
128
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Headless Blueprint construction, reusable by the Console, the daemon and the SDKs.
|
|
2
|
+
|
|
3
|
+
The Console is only its presentation layer: everything here is Textual-free. A ``BlueprintDraft`` is
|
|
4
|
+
the editing state; ``validate_draft`` reports issues live; ``build_blueprint``/``save_blueprint``
|
|
5
|
+
finalise it; ``act_infos``/``actions_for_act`` project the action registry into UI-ready records;
|
|
6
|
+
``list_templates``/``template_draft`` seed a draft from a starter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .catalog import ActInfo, ActionInfo, act_infos, actions_for_act, get_act_info
|
|
12
|
+
from .factory import (
|
|
13
|
+
BlueprintDraft,
|
|
14
|
+
StepDraft,
|
|
15
|
+
assemble_blueprint,
|
|
16
|
+
build_blueprint,
|
|
17
|
+
save_blueprint,
|
|
18
|
+
slugify_name,
|
|
19
|
+
)
|
|
20
|
+
from .templates import TemplateInfo, list_templates, template_draft
|
|
21
|
+
from .validation import ValidationIssue, validate_draft
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ActInfo",
|
|
25
|
+
"ActionInfo",
|
|
26
|
+
"act_infos",
|
|
27
|
+
"actions_for_act",
|
|
28
|
+
"get_act_info",
|
|
29
|
+
"BlueprintDraft",
|
|
30
|
+
"StepDraft",
|
|
31
|
+
"assemble_blueprint",
|
|
32
|
+
"build_blueprint",
|
|
33
|
+
"save_blueprint",
|
|
34
|
+
"slugify_name",
|
|
35
|
+
"TemplateInfo",
|
|
36
|
+
"list_templates",
|
|
37
|
+
"template_draft",
|
|
38
|
+
"ValidationIssue",
|
|
39
|
+
"validate_draft",
|
|
40
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""UI-friendly metadata for Acts and actions, projected from the core action registry.
|
|
2
|
+
|
|
3
|
+
This is the projection the README calls the builder catalogue: it joins the capability table
|
|
4
|
+
(``core/actions/base.py``), the field specs (``core/actions/registry.action_specs``) and the
|
|
5
|
+
runnable-Act set (``IMPLEMENTED_ACTS``) into flat, Textual-free records the Console and the daemon
|
|
6
|
+
can render. No action metadata is defined here — only combined and ordered — so the registry stays
|
|
7
|
+
the single source of truth.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ..core.actions.base import ACT_CAPABILITIES, PENDING_ACTIONS, Capability
|
|
15
|
+
from ..core.actions.registry import action_specs
|
|
16
|
+
from ..core.actions.spec import ActionSpec
|
|
17
|
+
from ..core.errors import BuilderError
|
|
18
|
+
from ..core.runtime.engine import IMPLEMENTED_ACTS
|
|
19
|
+
|
|
20
|
+
# Canonical display order, escalating Act by Act.
|
|
21
|
+
_ACT_ORDER: tuple[str, ...] = ("vector", "continuum", "oracle", "phantom")
|
|
22
|
+
|
|
23
|
+
_ACT_TITLES: dict[str, str] = {
|
|
24
|
+
"vector": "Vector — HTTP / API",
|
|
25
|
+
"continuum": "Continuum — scripted browser",
|
|
26
|
+
"oracle": "Oracle — vision-guided browser",
|
|
27
|
+
"phantom": "Phantom — autonomous agent",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# One-line explanation shown next to each Act in the picker and the catalogue.
|
|
31
|
+
_ACT_SUMMARIES: dict[str, str] = {
|
|
32
|
+
"vector": "HTTP/API requests. Fastest, no browser. The 'axios' case.",
|
|
33
|
+
"continuum": "Scripted browser (Playwright): login, JS, session, DOM. Stable selectors.",
|
|
34
|
+
"oracle": "Vision-guided browser with discretion. Locates fragile/obfuscated UI on screen.",
|
|
35
|
+
"phantom": "Autonomous agent: perceive, reason, act in a loop. Unscripted goals.",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ActInfo:
|
|
41
|
+
"""An Act as the builder presents it: title, explanation, and whether it runs today."""
|
|
42
|
+
|
|
43
|
+
act: str
|
|
44
|
+
title: str
|
|
45
|
+
summary: str
|
|
46
|
+
implemented: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ActionInfo:
|
|
51
|
+
"""An action available under an Act, with its spec and whether that Act runs it yet."""
|
|
52
|
+
|
|
53
|
+
spec: ActionSpec
|
|
54
|
+
runnable: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def act_infos() -> list[ActInfo]:
|
|
58
|
+
"""Every Act in canonical order, with its runnable status from IMPLEMENTED_ACTS."""
|
|
59
|
+
return [
|
|
60
|
+
ActInfo(
|
|
61
|
+
act=act,
|
|
62
|
+
title=_ACT_TITLES[act],
|
|
63
|
+
summary=_ACT_SUMMARIES[act],
|
|
64
|
+
implemented=act in IMPLEMENTED_ACTS,
|
|
65
|
+
)
|
|
66
|
+
for act in _ACT_ORDER
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_act_info(act: str) -> ActInfo:
|
|
71
|
+
"""Return the :class:`ActInfo` for *act* or raise :class:`BuilderError`."""
|
|
72
|
+
if act not in _ACT_TITLES:
|
|
73
|
+
raise BuilderError(f"Unknown act {act!r} (known: {list(_ACT_ORDER)}).")
|
|
74
|
+
return next(info for info in act_infos() if info.act == act)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def actions_for_act(act: str) -> list[ActionInfo]:
|
|
78
|
+
"""The actions an *act* supports, sorted by name, each flagged runnable or pending.
|
|
79
|
+
|
|
80
|
+
An action is runnable when its Act is implemented and the action is not in that Act's
|
|
81
|
+
PENDING_ACTIONS set (declared in the capability table but not dispatched by a driver yet).
|
|
82
|
+
"""
|
|
83
|
+
caps = ACT_CAPABILITIES.get(act)
|
|
84
|
+
if caps is None:
|
|
85
|
+
raise BuilderError(f"Unknown act {act!r} (known: {list(_ACT_ORDER)}).")
|
|
86
|
+
specs = action_specs()
|
|
87
|
+
pending: frozenset[Capability] = PENDING_ACTIONS.get(act, frozenset())
|
|
88
|
+
implemented = act in IMPLEMENTED_ACTS
|
|
89
|
+
infos = [
|
|
90
|
+
ActionInfo(spec=specs[cap.value], runnable=implemented and cap not in pending)
|
|
91
|
+
for cap in caps
|
|
92
|
+
]
|
|
93
|
+
return sorted(infos, key=lambda info: info.spec.name)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Assemble a valid Blueprint from structured choices, validating against the schema as it is built.
|
|
2
|
+
|
|
3
|
+
The headless heart of the Blueprint Studio, reusable by the Console, the daemon and the SDKs. A
|
|
4
|
+
:class:`BlueprintDraft` is the mutable editing state: raw data (dicts/lists), legitimately partial
|
|
5
|
+
while a user is still filling it in. :func:`validate_draft` reports issues without raising (the live
|
|
6
|
+
preview calls it on every change); :func:`build_blueprint` / :func:`save_blueprint` finalise it and
|
|
7
|
+
raise typed errors. ``assemble_blueprint`` and ``slugify_name`` live here — the recorder, a producer
|
|
8
|
+
of steps, re-exports them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Mapping
|
|
18
|
+
|
|
19
|
+
from ..core.actions.registry import get_spec
|
|
20
|
+
from ..core.actions.spec import ParamKind
|
|
21
|
+
from ..core.blueprint.loader import load_blueprint, validate_blueprint_data
|
|
22
|
+
from ..core.blueprint.models import Blueprint
|
|
23
|
+
from ..core.blueprint.validator import validate_for_act
|
|
24
|
+
from ..core.errors import BuilderError
|
|
25
|
+
from .validation import ValidationIssue, validate_draft
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"slugify_name",
|
|
29
|
+
"assemble_blueprint",
|
|
30
|
+
"StepDraft",
|
|
31
|
+
"BlueprintDraft",
|
|
32
|
+
"ValidationIssue",
|
|
33
|
+
"validate_draft",
|
|
34
|
+
"build_blueprint",
|
|
35
|
+
"save_blueprint",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def slugify_name(name: str) -> str:
|
|
40
|
+
"""Filesystem-safe stem derived from a Blueprint name (keeps dots, e.g. quotes.login)."""
|
|
41
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.")
|
|
42
|
+
return slug or "blueprint"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def assemble_blueprint(
|
|
46
|
+
name: str,
|
|
47
|
+
steps: list[dict[str, Any]],
|
|
48
|
+
secrets: list[str],
|
|
49
|
+
*,
|
|
50
|
+
act: str = "continuum",
|
|
51
|
+
inputs: dict[str, Any] | None = None,
|
|
52
|
+
outputs: dict[str, Any] | None = None,
|
|
53
|
+
description: str | None = None,
|
|
54
|
+
vars: dict[str, Any] | None = None,
|
|
55
|
+
options: dict[str, Any] | None = None,
|
|
56
|
+
vision: dict[str, Any] | None = None,
|
|
57
|
+
goal: str | None = None,
|
|
58
|
+
constraints: list[str] | None = None,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Assemble a minimal, ordered Blueprint dict for *act* (schema key order, empties omitted)."""
|
|
61
|
+
blueprint: dict[str, Any] = {"aetherius": "1.0", "name": name}
|
|
62
|
+
if description:
|
|
63
|
+
blueprint["description"] = description
|
|
64
|
+
blueprint["act"] = act
|
|
65
|
+
if inputs:
|
|
66
|
+
blueprint["inputs"] = inputs
|
|
67
|
+
if secrets:
|
|
68
|
+
blueprint["secrets"] = secrets
|
|
69
|
+
if vars:
|
|
70
|
+
blueprint["vars"] = vars
|
|
71
|
+
if options:
|
|
72
|
+
blueprint["options"] = options
|
|
73
|
+
if vision:
|
|
74
|
+
blueprint["vision"] = vision
|
|
75
|
+
if goal:
|
|
76
|
+
blueprint["goal"] = goal
|
|
77
|
+
if constraints:
|
|
78
|
+
blueprint["constraints"] = constraints
|
|
79
|
+
# A goal-only Blueprint (Phantom) legitimately has no steps; otherwise steps are always present.
|
|
80
|
+
if steps or not goal:
|
|
81
|
+
blueprint["steps"] = steps
|
|
82
|
+
if outputs:
|
|
83
|
+
blueprint["outputs"] = outputs
|
|
84
|
+
return blueprint
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Empty value used to pre-seed a required parameter, per kind, so a freshly added step shows what it
|
|
88
|
+
# still needs (and validate_draft flags it until filled). Empty object/array stay valid JSON.
|
|
89
|
+
def _empty_value(kind: ParamKind) -> Any:
|
|
90
|
+
if kind == "boolean":
|
|
91
|
+
return False
|
|
92
|
+
if kind == "object":
|
|
93
|
+
return {}
|
|
94
|
+
if kind == "array":
|
|
95
|
+
return []
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class StepDraft:
|
|
101
|
+
"""One editable step: an action, an optional id, and its raw parameters."""
|
|
102
|
+
|
|
103
|
+
action: str
|
|
104
|
+
id: str | None = None
|
|
105
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
def to_data(self) -> dict[str, Any]:
|
|
108
|
+
"""Serialise to the step dict shape (id first when set, then action, then params)."""
|
|
109
|
+
data: dict[str, Any] = {}
|
|
110
|
+
if self.id:
|
|
111
|
+
data["id"] = self.id
|
|
112
|
+
data["action"] = self.action
|
|
113
|
+
data.update(self.params)
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_data(cls, data: Mapping[str, Any]) -> "StepDraft":
|
|
118
|
+
params = {k: v for k, v in data.items() if k not in ("id", "action")}
|
|
119
|
+
return cls(action=str(data.get("action", "")), id=data.get("id"), params=params)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class BlueprintDraft:
|
|
124
|
+
"""Mutable editing state of a Blueprint. Lossless: carries every envelope field so an existing
|
|
125
|
+
file round-trips unchanged through ``from_data``/``to_data`` (e.g. refining recorder output)."""
|
|
126
|
+
|
|
127
|
+
name: str = ""
|
|
128
|
+
act: str = "vector"
|
|
129
|
+
description: str | None = None
|
|
130
|
+
inputs: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
131
|
+
secrets: list[str] = field(default_factory=list)
|
|
132
|
+
vars: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
134
|
+
steps: list[StepDraft] = field(default_factory=list)
|
|
135
|
+
outputs: dict[str, Any] = field(default_factory=dict)
|
|
136
|
+
vision: dict[str, Any] | None = None
|
|
137
|
+
goal: str | None = None
|
|
138
|
+
constraints: list[str] = field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
def add_step(self, action: str, *, index: int | None = None) -> StepDraft:
|
|
141
|
+
"""Append (or insert at *index*) a step, pre-seeding its required parameters."""
|
|
142
|
+
params: dict[str, Any] = {}
|
|
143
|
+
try:
|
|
144
|
+
spec = get_spec(action)
|
|
145
|
+
except Exception:
|
|
146
|
+
spec = None
|
|
147
|
+
if spec is not None:
|
|
148
|
+
for param in spec.required_params():
|
|
149
|
+
params[param.name] = _empty_value(param.kind)
|
|
150
|
+
step = StepDraft(action=action, params=params)
|
|
151
|
+
if index is None:
|
|
152
|
+
self.steps.append(step)
|
|
153
|
+
else:
|
|
154
|
+
self.steps.insert(max(0, min(index, len(self.steps))), step)
|
|
155
|
+
return step
|
|
156
|
+
|
|
157
|
+
def remove_step(self, index: int) -> None:
|
|
158
|
+
if 0 <= index < len(self.steps):
|
|
159
|
+
del self.steps[index]
|
|
160
|
+
|
|
161
|
+
def move_step(self, index: int, delta: int) -> None:
|
|
162
|
+
"""Move the step at *index* by *delta* positions, clamped to the list bounds."""
|
|
163
|
+
if not (0 <= index < len(self.steps)):
|
|
164
|
+
return
|
|
165
|
+
target = max(0, min(index + delta, len(self.steps) - 1))
|
|
166
|
+
if target != index:
|
|
167
|
+
self.steps.insert(target, self.steps.pop(index))
|
|
168
|
+
|
|
169
|
+
def to_data(self) -> dict[str, Any]:
|
|
170
|
+
"""Assemble the ordered Blueprint dict this draft represents (empties omitted)."""
|
|
171
|
+
return assemble_blueprint(
|
|
172
|
+
self.name,
|
|
173
|
+
[step.to_data() for step in self.steps],
|
|
174
|
+
list(self.secrets),
|
|
175
|
+
act=self.act,
|
|
176
|
+
inputs=dict(self.inputs) or None,
|
|
177
|
+
outputs=dict(self.outputs) or None,
|
|
178
|
+
description=self.description,
|
|
179
|
+
vars=dict(self.vars) or None,
|
|
180
|
+
options=dict(self.options) or None,
|
|
181
|
+
vision=self.vision,
|
|
182
|
+
goal=self.goal,
|
|
183
|
+
constraints=list(self.constraints) or None,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_data(cls, data: Mapping[str, Any]) -> "BlueprintDraft":
|
|
188
|
+
"""Build a draft from a Blueprint dict (as loaded from a file), preserving every field."""
|
|
189
|
+
return cls(
|
|
190
|
+
name=str(data.get("name", "")),
|
|
191
|
+
act=str(data.get("act", "vector")),
|
|
192
|
+
description=data.get("description"),
|
|
193
|
+
inputs={k: dict(v) for k, v in (data.get("inputs") or {}).items()},
|
|
194
|
+
secrets=list(data.get("secrets") or []),
|
|
195
|
+
vars=dict(data.get("vars") or {}),
|
|
196
|
+
options=dict(data.get("options") or {}),
|
|
197
|
+
steps=[StepDraft.from_data(s) for s in (data.get("steps") or [])],
|
|
198
|
+
outputs=dict(data.get("outputs") or {}),
|
|
199
|
+
vision=data.get("vision"),
|
|
200
|
+
goal=data.get("goal"),
|
|
201
|
+
constraints=list(data.get("constraints") or []),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def build_blueprint(draft: BlueprintDraft) -> Blueprint:
|
|
206
|
+
"""Finalise *draft* into a validated Blueprint model, raising typed errors on any problem."""
|
|
207
|
+
blueprint = validate_blueprint_data(draft.to_data(), source="<studio>")
|
|
208
|
+
validate_for_act(blueprint)
|
|
209
|
+
return blueprint
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def save_blueprint(
|
|
213
|
+
draft: BlueprintDraft,
|
|
214
|
+
*,
|
|
215
|
+
path: Path | str | None = None,
|
|
216
|
+
out_dir: Path | str | None = None,
|
|
217
|
+
) -> Path:
|
|
218
|
+
"""Validate and write *draft* to disk, returning the file path.
|
|
219
|
+
|
|
220
|
+
With *path* (edit mode) the file is overwritten in place. Otherwise the file is created under
|
|
221
|
+
*out_dir* (or ``./blueprints``) as ``{slug}.blueprint.json``; a name collision with a different
|
|
222
|
+
existing file raises :class:`BuilderError` rather than clobbering it. Validation runs before any
|
|
223
|
+
write, and the written file is re-read through the canonical loader as a final guarantee.
|
|
224
|
+
"""
|
|
225
|
+
build_blueprint(draft) # raise before touching disk
|
|
226
|
+
|
|
227
|
+
if path is not None:
|
|
228
|
+
target = Path(path)
|
|
229
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
else:
|
|
231
|
+
target_dir = Path(out_dir) if out_dir is not None else Path.cwd() / "blueprints"
|
|
232
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
target = target_dir / f"{slugify_name(draft.name)}.blueprint.json"
|
|
234
|
+
if target.exists():
|
|
235
|
+
raise BuilderError(
|
|
236
|
+
f"A Blueprint already exists at {target}. Rename this one, or open that file to edit."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
target.write_text(
|
|
240
|
+
json.dumps(draft.to_data(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
241
|
+
)
|
|
242
|
+
validate_for_act(load_blueprint(target))
|
|
243
|
+
return target
|