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,125 @@
|
|
|
1
|
+
"""JSON extraction via JSONPath, with optional per-item field mapping and a safe where predicate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import types
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jsonpath_ng.ext import parse as jpath_parse
|
|
12
|
+
|
|
13
|
+
from ..errors import ExtractionError
|
|
14
|
+
|
|
15
|
+
# AST node types allowed in a `where` expression.
|
|
16
|
+
# Anything outside this set (calls, imports, comprehensions, …) is rejected.
|
|
17
|
+
_ALLOWED_NODES = (
|
|
18
|
+
ast.Expression,
|
|
19
|
+
ast.Compare,
|
|
20
|
+
ast.Name,
|
|
21
|
+
ast.Attribute,
|
|
22
|
+
ast.Constant,
|
|
23
|
+
ast.BoolOp,
|
|
24
|
+
ast.And,
|
|
25
|
+
ast.Or,
|
|
26
|
+
ast.UnaryOp,
|
|
27
|
+
ast.Not,
|
|
28
|
+
ast.Eq,
|
|
29
|
+
ast.NotEq,
|
|
30
|
+
ast.Lt,
|
|
31
|
+
ast.LtE,
|
|
32
|
+
ast.Gt,
|
|
33
|
+
ast.GtE,
|
|
34
|
+
ast.In,
|
|
35
|
+
ast.NotIn,
|
|
36
|
+
ast.Is,
|
|
37
|
+
ast.IsNot,
|
|
38
|
+
ast.Load,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ExtractSpec:
|
|
44
|
+
from_: str # "json" | "html"
|
|
45
|
+
path: str # JSONPath expression, e.g. "$[*]"
|
|
46
|
+
where: str | None = None # per-item predicate, e.g. "item.eventCategory != 'Vacances'"
|
|
47
|
+
fields: dict[str, str] = field(default_factory=dict) # name -> JSONPath relative to item
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _eval_predicate(expr: str, item: Any) -> bool:
|
|
51
|
+
"""Evaluate a simple comparison expression against *item* with an AST safety check.
|
|
52
|
+
|
|
53
|
+
*item* is wrapped in SimpleNamespace so `item.attr` dot-access works on JSON dicts.
|
|
54
|
+
Raises ExtractionError if the expression contains disallowed AST nodes.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
tree = ast.parse(expr, mode="eval")
|
|
58
|
+
except SyntaxError as exc:
|
|
59
|
+
raise ExtractionError(f"Invalid where expression {expr!r}: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
for node in ast.walk(tree):
|
|
62
|
+
if not isinstance(node, _ALLOWED_NODES):
|
|
63
|
+
raise ExtractionError(
|
|
64
|
+
f"Disallowed construct {type(node).__name__!r} in where expression {expr!r}. "
|
|
65
|
+
"Only comparisons and boolean logic are permitted."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ns: Any
|
|
69
|
+
if isinstance(item, dict):
|
|
70
|
+
ns = types.SimpleNamespace(**item)
|
|
71
|
+
else:
|
|
72
|
+
ns = item
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
return bool(eval(compile(tree, "<where>", "eval"), {"__builtins__": {}}, {"item": ns}))
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise ExtractionError(f"Error evaluating where expression {expr!r}: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_fields(item_dict: dict[str, Any], fields: dict[str, str]) -> dict[str, Any]:
|
|
81
|
+
"""Extract sub-fields from a single matched item using relative JSONPath expressions."""
|
|
82
|
+
result: dict[str, Any] = {}
|
|
83
|
+
for field_name, path in fields.items():
|
|
84
|
+
matches = jpath_parse(path).find(item_dict)
|
|
85
|
+
if not matches:
|
|
86
|
+
result[field_name] = None
|
|
87
|
+
elif len(matches) == 1:
|
|
88
|
+
result[field_name] = matches[0].value
|
|
89
|
+
else:
|
|
90
|
+
result[field_name] = [m.value for m in matches]
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def extract_json(body: bytes, spec: dict[str, "ExtractSpec"]) -> dict[str, Any]:
|
|
95
|
+
"""Run all extraction specs in *spec* against a JSON *body* and return the collected data.
|
|
96
|
+
|
|
97
|
+
Each key in *spec* becomes a key in the returned dict whose value is a list of matched items
|
|
98
|
+
(or a single value when only one item matches and no fields mapping is defined).
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(body)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise ExtractionError(f"Cannot parse JSON response body: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
result: dict[str, Any] = {}
|
|
106
|
+
|
|
107
|
+
for output_name, s in spec.items():
|
|
108
|
+
try:
|
|
109
|
+
matches = jpath_parse(s.path).find(data)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
raise ExtractionError(f"Invalid JSONPath {s.path!r}: {exc}") from exc
|
|
112
|
+
|
|
113
|
+
items = [m.value for m in matches]
|
|
114
|
+
|
|
115
|
+
if s.where:
|
|
116
|
+
items = [item for item in items if _eval_predicate(s.where, item)]
|
|
117
|
+
|
|
118
|
+
if s.fields:
|
|
119
|
+
items = [
|
|
120
|
+
_extract_fields(item if isinstance(item, dict) else {}, s.fields) for item in items
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
result[output_name] = items
|
|
124
|
+
|
|
125
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime: the orchestrator that turns a Blueprint into a Result by driving the selected Act."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""RunContext: mutable per-run state (resolved inputs, injected secrets, step outputs)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from ..blueprint.models import Blueprint
|
|
11
|
+
from ..errors import BlueprintValidationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RunContext:
|
|
16
|
+
run_id: str
|
|
17
|
+
blueprint: Blueprint
|
|
18
|
+
inputs: dict[str, Any]
|
|
19
|
+
secrets: dict[str, str]
|
|
20
|
+
step_outputs: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
22
|
+
|
|
23
|
+
def template_ctx(self) -> dict[str, Any]:
|
|
24
|
+
"""Build the Jinja2 context dict for rendering step fields and outputs."""
|
|
25
|
+
return {
|
|
26
|
+
"inputs": self.inputs,
|
|
27
|
+
"secrets": self.secrets,
|
|
28
|
+
"vars": self.blueprint.vars,
|
|
29
|
+
"env": dict(os.environ),
|
|
30
|
+
"steps": self.step_outputs,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_inputs(
|
|
35
|
+
blueprint: Blueprint,
|
|
36
|
+
raw: Mapping[str, Any] | None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""Validate and resolve caller-supplied inputs against the Blueprint input specs.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
BlueprintValidationError: a required input is missing.
|
|
42
|
+
"""
|
|
43
|
+
raw = dict(raw or {})
|
|
44
|
+
resolved: dict[str, Any] = {}
|
|
45
|
+
|
|
46
|
+
for name, spec in blueprint.inputs.items():
|
|
47
|
+
if name in raw:
|
|
48
|
+
resolved[name] = raw[name]
|
|
49
|
+
elif spec.default is not None:
|
|
50
|
+
resolved[name] = spec.default
|
|
51
|
+
elif spec.required:
|
|
52
|
+
raise BlueprintValidationError(
|
|
53
|
+
f"Missing required input '{name}' for Blueprint '{blueprint.name}'."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Pass through any extra caller inputs (Blueprint may not declare all runtime vars).
|
|
57
|
+
for name, value in raw.items():
|
|
58
|
+
if name not in resolved:
|
|
59
|
+
resolved[name] = value
|
|
60
|
+
|
|
61
|
+
return resolved
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""RunEngine: orchestrates Act selection, step pipeline, retries, events, and Result assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from ..blueprint.models import Blueprint
|
|
11
|
+
from ..blueprint.template import render_value
|
|
12
|
+
from ..blueprint.validator import validate_for_act
|
|
13
|
+
from ..errors import AetheriusError, ActionError, RunError
|
|
14
|
+
from ..events.bus import EventBus
|
|
15
|
+
from ..events.models import EventType, RunEvent
|
|
16
|
+
from ..events.sinks import LogSink, NullSink, Sink
|
|
17
|
+
from ..runtime.context import RunContext, resolve_inputs
|
|
18
|
+
from ..runtime.result import Result, RunStatus, StepResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
IMPLEMENTED_ACTS: frozenset[str] = frozenset({"vector", "continuum"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_driver(act: str) -> Any:
|
|
25
|
+
# Drivers are imported lazily so `import aetherius` never pulls in an Act's heavy
|
|
26
|
+
# dependencies (Playwright, ONNX, ...). Each driver defers its own extra to runtime.
|
|
27
|
+
if act == "vector":
|
|
28
|
+
from ...acts.vector.driver import VectorDriver
|
|
29
|
+
|
|
30
|
+
return VectorDriver()
|
|
31
|
+
if act == "continuum":
|
|
32
|
+
from ...acts.continuum.driver import ContinuumDriver
|
|
33
|
+
|
|
34
|
+
return ContinuumDriver()
|
|
35
|
+
raise ActionError(f"Act {act!r} is not implemented yet. Available: {sorted(IMPLEMENTED_ACTS)}.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RunEngine:
|
|
39
|
+
def run(
|
|
40
|
+
self,
|
|
41
|
+
blueprint: Blueprint,
|
|
42
|
+
inputs: Mapping[str, Any] | None = None,
|
|
43
|
+
secrets: Mapping[str, str] | None = None,
|
|
44
|
+
*,
|
|
45
|
+
sinks: list[Sink] | None = None,
|
|
46
|
+
run_id: str | None = None,
|
|
47
|
+
) -> Result:
|
|
48
|
+
validate_for_act(blueprint)
|
|
49
|
+
|
|
50
|
+
# The daemon assigns the id it returned to the caller (HTTP 202) before the run starts, so
|
|
51
|
+
# the streamed events carry a run_id the client already holds. In-process callers omit it.
|
|
52
|
+
run_id = run_id or uuid.uuid4().hex
|
|
53
|
+
started_at = datetime.now(timezone.utc)
|
|
54
|
+
|
|
55
|
+
from ...config.secrets import resolve_secrets
|
|
56
|
+
|
|
57
|
+
resolved_inputs = resolve_inputs(blueprint, inputs)
|
|
58
|
+
resolved_secrets = resolve_secrets(blueprint.secrets, secrets)
|
|
59
|
+
ctx = RunContext(
|
|
60
|
+
run_id=run_id,
|
|
61
|
+
blueprint=blueprint,
|
|
62
|
+
inputs=resolved_inputs,
|
|
63
|
+
secrets=resolved_secrets,
|
|
64
|
+
started_at=started_at,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
bus = EventBus()
|
|
68
|
+
if sinks is not None:
|
|
69
|
+
for sink in sinks:
|
|
70
|
+
bus.register(sink)
|
|
71
|
+
else:
|
|
72
|
+
default_sink: Sink = (
|
|
73
|
+
LogSink(debug=blueprint.options.debug) if blueprint.options.debug else NullSink()
|
|
74
|
+
)
|
|
75
|
+
bus.register(default_sink)
|
|
76
|
+
|
|
77
|
+
driver = _make_driver(blueprint.act)
|
|
78
|
+
driver.setup(ctx)
|
|
79
|
+
|
|
80
|
+
bus.emit(
|
|
81
|
+
RunEvent(
|
|
82
|
+
run_id=run_id,
|
|
83
|
+
type=EventType.PROGRESS,
|
|
84
|
+
message=f"run started: {blueprint.name}",
|
|
85
|
+
level="info",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
step_results: list[StepResult] = []
|
|
90
|
+
final_status = RunStatus.SUCCESS
|
|
91
|
+
run_error: str | None = None
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
for i, step in enumerate(blueprint.steps):
|
|
95
|
+
step_key = step.id or f"_step_{i}"
|
|
96
|
+
|
|
97
|
+
bus.emit(
|
|
98
|
+
RunEvent(
|
|
99
|
+
run_id=run_id, type=EventType.STEP_STARTED, step_id=step.id, level="debug"
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
t0 = time.monotonic()
|
|
103
|
+
|
|
104
|
+
def renderer(value: Any) -> Any:
|
|
105
|
+
return render_value(value, ctx.template_ctx())
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
outputs = driver.run_step(step, ctx, bus, renderer)
|
|
109
|
+
ctx.step_outputs[step_key] = outputs
|
|
110
|
+
duration_ms = (time.monotonic() - t0) * 1000
|
|
111
|
+
step_results.append(
|
|
112
|
+
StepResult(
|
|
113
|
+
step_id=step.id,
|
|
114
|
+
action=step.action,
|
|
115
|
+
status=RunStatus.SUCCESS,
|
|
116
|
+
outputs=outputs,
|
|
117
|
+
duration_ms=duration_ms,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
bus.emit(
|
|
121
|
+
RunEvent(
|
|
122
|
+
run_id=run_id,
|
|
123
|
+
type=EventType.STEP_FINISHED,
|
|
124
|
+
step_id=step.id,
|
|
125
|
+
level="debug",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
except AetheriusError as exc:
|
|
130
|
+
duration_ms = (time.monotonic() - t0) * 1000
|
|
131
|
+
step_results.append(
|
|
132
|
+
StepResult(
|
|
133
|
+
step_id=step.id,
|
|
134
|
+
action=step.action,
|
|
135
|
+
status=RunStatus.FAILED,
|
|
136
|
+
error=str(exc),
|
|
137
|
+
duration_ms=duration_ms,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
bus.emit(
|
|
141
|
+
RunEvent(
|
|
142
|
+
run_id=run_id,
|
|
143
|
+
type=EventType.ERROR,
|
|
144
|
+
step_id=step.id,
|
|
145
|
+
message=str(exc),
|
|
146
|
+
level="error",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
except AetheriusError as exc:
|
|
152
|
+
final_status = RunStatus.FAILED
|
|
153
|
+
run_error = str(exc)
|
|
154
|
+
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
final_status = RunStatus.FAILED
|
|
157
|
+
run_error = str(exc)
|
|
158
|
+
raise RunError(f"Unexpected error during run {run_id}: {exc}", cause=exc) from exc
|
|
159
|
+
|
|
160
|
+
finally:
|
|
161
|
+
driver.teardown(ctx)
|
|
162
|
+
|
|
163
|
+
# Render the outputs dict through the template engine.
|
|
164
|
+
final_outputs: dict[str, Any] = {}
|
|
165
|
+
if final_status == RunStatus.SUCCESS and blueprint.outputs:
|
|
166
|
+
final_outputs = render_value(blueprint.outputs, ctx.template_ctx())
|
|
167
|
+
|
|
168
|
+
finished_at = datetime.now(timezone.utc)
|
|
169
|
+
bus.emit(
|
|
170
|
+
RunEvent(
|
|
171
|
+
run_id=run_id,
|
|
172
|
+
type=EventType.DONE,
|
|
173
|
+
message=f"run finished: {final_status.value}",
|
|
174
|
+
level="info" if final_status == RunStatus.SUCCESS else "error",
|
|
175
|
+
data={"status": final_status.value, "error": run_error},
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return Result(
|
|
180
|
+
run_id=run_id,
|
|
181
|
+
blueprint_name=blueprint.name,
|
|
182
|
+
status=final_status,
|
|
183
|
+
outputs=final_outputs,
|
|
184
|
+
step_results=step_results,
|
|
185
|
+
error=run_error,
|
|
186
|
+
started_at=started_at,
|
|
187
|
+
finished_at=finished_at,
|
|
188
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Result and StepResult: structured outputs returned to the caller after a Blueprint run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunStatus(str, Enum):
|
|
13
|
+
SUCCESS = "success"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
PARTIAL = "partial"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StepResult(BaseModel):
|
|
19
|
+
step_id: str | None
|
|
20
|
+
action: str
|
|
21
|
+
status: RunStatus
|
|
22
|
+
outputs: dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
error: str | None = None
|
|
24
|
+
duration_ms: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Result(BaseModel):
|
|
28
|
+
run_id: str
|
|
29
|
+
blueprint_name: str
|
|
30
|
+
status: RunStatus
|
|
31
|
+
outputs: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
step_results: list[StepResult] = Field(default_factory=list)
|
|
33
|
+
error: str | None = None
|
|
34
|
+
started_at: datetime
|
|
35
|
+
finished_at: datetime
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def duration_ms(self) -> float:
|
|
39
|
+
return (self.finished_at - self.started_at).total_seconds() * 1000
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Unified target model abstracting css, xpath, text and vision targets so actions are expressed independently of the Act."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime ML asset management (download, cache, versioning). Training lives under training/."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Model registry: resolve, download, cache and version the vision models referenced by Blueprints."""
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Recorders that generate artifacts: Blueprints from a live session, and mouse gestures for the stealth library.
|
|
2
|
+
|
|
3
|
+
Public entry points are re-exported here. None of the imports pull Playwright at module load: the
|
|
4
|
+
browser is imported lazily inside the recording functions, so ``import aetherius`` stays light.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .blueprint_recorder import record_blueprint
|
|
10
|
+
from .gesture_recorder import record_gestures
|
|
11
|
+
|
|
12
|
+
__all__ = ["record_blueprint", "record_gestures"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""In-page capture script for the Blueprint recorder (action capture).
|
|
2
|
+
|
|
3
|
+
Listens for the real DOM events a demonstration produces (click, change, Enter) and reports a JSON
|
|
4
|
+
descriptor for each to the ``__aetherius_capture`` binding. Element descriptors are built by the
|
|
5
|
+
shared primitives on ``window.__aeSel`` (:mod:`._selector_js`), so the candidate/uniqueness logic is
|
|
6
|
+
defined once and shared with the pick overlay.
|
|
7
|
+
|
|
8
|
+
Two guards keep action capture and the overlay from stepping on each other: events originating in the
|
|
9
|
+
overlay are ignored, and while a pick mode is active (``window.__aeRecorderPicking``) page actions are
|
|
10
|
+
not recorded — the user is designating data, not driving the site.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# Self-invoking, guarded against double injection. Password fields never send their value: only a
|
|
16
|
+
# redacted marker. Requires window.__aeSel (inject _selector_js.SELECTOR_JS first).
|
|
17
|
+
RECORDER_JS = r"""
|
|
18
|
+
(() => {
|
|
19
|
+
if (window.__aetheriusRecorder) return;
|
|
20
|
+
window.__aetheriusRecorder = true;
|
|
21
|
+
|
|
22
|
+
const TEXTUAL = new Set(['text', 'email', 'search', 'tel', 'url', 'number', 'password', '']);
|
|
23
|
+
const OVERLAY_ID = '__ae-recorder-overlay';
|
|
24
|
+
|
|
25
|
+
const send = (payload) => {
|
|
26
|
+
try { window.__aetherius_capture(JSON.stringify(payload)); } catch (e) {}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Ignore events from the overlay itself, and any event while a pick mode is active.
|
|
30
|
+
const ignored = (e) => {
|
|
31
|
+
if (window.__aeRecorderPicking) return true;
|
|
32
|
+
const path = e.composedPath ? e.composedPath() : [];
|
|
33
|
+
return path.some((n) => n && n.id === OVERLAY_ID);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
document.addEventListener('click', (e) => {
|
|
37
|
+
if (ignored(e)) return;
|
|
38
|
+
const el = e.target.closest(window.__aeSel.CLICKABLE);
|
|
39
|
+
if (!el) return; // plain clicks (focusing a text field, selecting text) are not steps
|
|
40
|
+
const payload = { kind: 'click', descriptor: window.__aeSel.descriptorFor(el) };
|
|
41
|
+
// A link navigating to a real URL is far more robust replayed as a navigate (stable URL) than as
|
|
42
|
+
// a click (fragile selector). Buttons/submits have no reproducible URL, so they stay clicks.
|
|
43
|
+
if (el.tagName === 'A') {
|
|
44
|
+
const raw = el.getAttribute('href') || '';
|
|
45
|
+
if (raw && !raw.startsWith('#') && !raw.startsWith('javascript:') && /^https?:/i.test(el.href)) {
|
|
46
|
+
payload.href = el.href;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
send(payload);
|
|
50
|
+
}, true);
|
|
51
|
+
|
|
52
|
+
document.addEventListener('change', (e) => {
|
|
53
|
+
if (ignored(e)) return;
|
|
54
|
+
const el = e.target;
|
|
55
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
56
|
+
if (tag === 'select') {
|
|
57
|
+
send({ kind: 'select', value: el.value, descriptor: window.__aeSel.descriptorFor(el) });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (tag !== 'input' && tag !== 'textarea') return;
|
|
61
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
62
|
+
if (type === 'checkbox' || type === 'radio') return; // captured as a click
|
|
63
|
+
if (tag === 'input' && !TEXTUAL.has(type)) return;
|
|
64
|
+
if (type === 'password') {
|
|
65
|
+
send({ kind: 'fill', redacted: true, descriptor: window.__aeSel.descriptorFor(el) });
|
|
66
|
+
} else {
|
|
67
|
+
send({ kind: 'fill', value: el.value, descriptor: window.__aeSel.descriptorFor(el) });
|
|
68
|
+
}
|
|
69
|
+
}, true);
|
|
70
|
+
|
|
71
|
+
document.addEventListener('keydown', (e) => {
|
|
72
|
+
if (ignored(e)) return;
|
|
73
|
+
if (e.key !== 'Enter') return;
|
|
74
|
+
const el = e.target;
|
|
75
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
76
|
+
if (tag !== 'input') return; // Enter in a text field commonly submits without a visible button
|
|
77
|
+
send({ kind: 'press', key: 'Enter', descriptor: window.__aeSel.descriptorFor(el) });
|
|
78
|
+
}, true);
|
|
79
|
+
})();
|
|
80
|
+
"""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""In-page capture script for the gesture recorder.
|
|
2
|
+
|
|
3
|
+
Streams raw pointer samples ``[x, y, t]`` (viewport pixels, seconds since page load) and click
|
|
4
|
+
timestamps to the ``__aetherius_gesture`` binding. Moves are buffered and flushed on a timer and on
|
|
5
|
+
every click, rather than sent one per ``pointermove``, so the binding is not hammered thousands of
|
|
6
|
+
times a second. Segmentation into individual gestures is done in pure Python (:mod:`.gesture_recorder`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Self-invoking, guarded against double injection. performance.now() is milliseconds; we send seconds.
|
|
12
|
+
GESTURE_RECORDER_JS = r"""
|
|
13
|
+
(() => {
|
|
14
|
+
if (window.__aetheriusGestureRecorder) return;
|
|
15
|
+
window.__aetheriusGestureRecorder = true;
|
|
16
|
+
|
|
17
|
+
let buffer = [];
|
|
18
|
+
let clickAt = null;
|
|
19
|
+
|
|
20
|
+
const flush = () => {
|
|
21
|
+
if (buffer.length === 0 && clickAt === null) return;
|
|
22
|
+
const payload = { moves: buffer, click: clickAt };
|
|
23
|
+
buffer = [];
|
|
24
|
+
clickAt = null;
|
|
25
|
+
try { window.__aetherius_gesture(JSON.stringify(payload)); } catch (e) {}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
document.addEventListener('pointermove', (e) => {
|
|
29
|
+
buffer.push([Math.round(e.clientX), Math.round(e.clientY), +(performance.now() / 1000).toFixed(4)]);
|
|
30
|
+
}, true);
|
|
31
|
+
|
|
32
|
+
document.addEventListener('pointerdown', (e) => {
|
|
33
|
+
buffer.push([Math.round(e.clientX), Math.round(e.clientY), +(performance.now() / 1000).toFixed(4)]);
|
|
34
|
+
clickAt = +(performance.now() / 1000).toFixed(4);
|
|
35
|
+
flush();
|
|
36
|
+
}, true);
|
|
37
|
+
|
|
38
|
+
setInterval(flush, 250);
|
|
39
|
+
})();
|
|
40
|
+
"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Tiny naming helpers shared by the recorder transforms: sanitize and de-duplicate identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ident(raw: str | None, *, fallback: str) -> str:
|
|
9
|
+
"""Sanitize a free string into a lowercase identifier, or *fallback* if it is empty."""
|
|
10
|
+
base = re.sub(r"[^a-z0-9_]+", "_", (raw or "").lower()).strip("_")
|
|
11
|
+
return base or fallback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def unique(base: str, taken: set[str]) -> str:
|
|
15
|
+
"""Return *base*, suffixed (_2, _3, …) until it is not already in *taken*."""
|
|
16
|
+
name, counter = base, 2
|
|
17
|
+
while name in taken:
|
|
18
|
+
name = f"{base}_{counter}"
|
|
19
|
+
counter += 1
|
|
20
|
+
return name
|