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,124 @@
|
|
|
1
|
+
"""Browse the Blueprint library: discover, validate, and open one into the Runs screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.screen import Screen
|
|
11
|
+
from textual.widgets import DataTable, Footer, Header, Static
|
|
12
|
+
|
|
13
|
+
from ...core.runtime.engine import IMPLEMENTED_ACTS
|
|
14
|
+
from ..theme import ACT_LABELS, AMBER, LAUREL, POMPEIAN, STONE, act_color, starred
|
|
15
|
+
from .library_scan import (
|
|
16
|
+
BlueprintEntry,
|
|
17
|
+
EntryStatus,
|
|
18
|
+
discover_blueprint_dirs,
|
|
19
|
+
entry_status,
|
|
20
|
+
scan_blueprints,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# How each status reads in the table: label + colour. Runnable is the only "go" state.
|
|
24
|
+
_STATUS_STYLE: dict[EntryStatus, tuple[str, str]] = {
|
|
25
|
+
EntryStatus.READY: ("ready", f"bold {LAUREL}"),
|
|
26
|
+
EntryStatus.ACT_PENDING: ("act pending", AMBER),
|
|
27
|
+
EntryStatus.INVALID: ("invalid", f"bold {POMPEIAN}"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LibraryScreen(Screen[None]):
|
|
32
|
+
"""Lists every Blueprint found under the discovered directories."""
|
|
33
|
+
|
|
34
|
+
BINDINGS = [
|
|
35
|
+
Binding("r", "rescan", "Rescan"),
|
|
36
|
+
Binding("e", "edit", "Edit in Studio"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self._entries: list[BlueprintEntry] = []
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
yield Header()
|
|
45
|
+
with Vertical(classes="console-body"):
|
|
46
|
+
yield Static(starred("Blueprint Library"), classes="console-title")
|
|
47
|
+
yield Static(
|
|
48
|
+
"Enter opens a Blueprint in Runs. 'ready' runs now; 'act pending' is well-formed "
|
|
49
|
+
"but its Act has no driver yet; 'invalid' fails the schema or its Act's actions.",
|
|
50
|
+
classes="console-subtitle",
|
|
51
|
+
)
|
|
52
|
+
yield DataTable(id="library-table", cursor_type="row")
|
|
53
|
+
yield Footer()
|
|
54
|
+
|
|
55
|
+
def on_mount(self) -> None:
|
|
56
|
+
self._rescan()
|
|
57
|
+
|
|
58
|
+
def action_rescan(self) -> None:
|
|
59
|
+
self._rescan()
|
|
60
|
+
|
|
61
|
+
def action_edit(self) -> None:
|
|
62
|
+
"""Open the highlighted Blueprint in the Studio (any file that parsed)."""
|
|
63
|
+
entry = self._highlighted_entry()
|
|
64
|
+
if entry is None:
|
|
65
|
+
return
|
|
66
|
+
if entry.blueprint is None:
|
|
67
|
+
self.app.notify(
|
|
68
|
+
"This file does not parse — edit the JSON by hand.", severity="error", timeout=8
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
from .builder.screen import BlueprintStudioScreen
|
|
72
|
+
|
|
73
|
+
self.app.push_screen(BlueprintStudioScreen(entry.path))
|
|
74
|
+
|
|
75
|
+
def _highlighted_entry(self) -> BlueprintEntry | None:
|
|
76
|
+
table = self.query_one("#library-table", DataTable)
|
|
77
|
+
if table.row_count == 0:
|
|
78
|
+
return None
|
|
79
|
+
row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
|
|
80
|
+
return next((e for e in self._entries if str(e.path) == str(row_key.value)), None)
|
|
81
|
+
|
|
82
|
+
def _rescan(self) -> None:
|
|
83
|
+
dirs = discover_blueprint_dirs()
|
|
84
|
+
self._entries = scan_blueprints(dirs)
|
|
85
|
+
self._render_table()
|
|
86
|
+
|
|
87
|
+
def _render_table(self) -> None:
|
|
88
|
+
table = self.query_one("#library-table", DataTable)
|
|
89
|
+
table.clear(columns=True)
|
|
90
|
+
table.add_columns("Name", "Act", "Status", "Path")
|
|
91
|
+
for entry in self._entries:
|
|
92
|
+
status = entry_status(entry)
|
|
93
|
+
label_text, style = _STATUS_STYLE[status]
|
|
94
|
+
# Surface the concrete error inline; keep the muted note for a pending Act.
|
|
95
|
+
if status is EntryStatus.INVALID and entry.error:
|
|
96
|
+
label_text = f"invalid: {entry.error}"
|
|
97
|
+
elif status is EntryStatus.ACT_PENDING:
|
|
98
|
+
label_text = "act pending — no driver yet"
|
|
99
|
+
status_cell = Text(label_text, style=style)
|
|
100
|
+
|
|
101
|
+
if entry.act is None:
|
|
102
|
+
act_cell: str | Text = Text("-", style=STONE)
|
|
103
|
+
else:
|
|
104
|
+
act_label = ACT_LABELS.get(entry.act, entry.act)
|
|
105
|
+
act_cell = Text(
|
|
106
|
+
act_label, style=act_color(entry.act, entry.act in IMPLEMENTED_ACTS)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
name = entry.blueprint.name if entry.blueprint else entry.path.stem
|
|
110
|
+
table.add_row(name, act_cell, status_cell, str(entry.path), key=str(entry.path))
|
|
111
|
+
|
|
112
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
113
|
+
entry = next((e for e in self._entries if str(e.path) == str(event.row_key.value)), None)
|
|
114
|
+
if entry is None:
|
|
115
|
+
return
|
|
116
|
+
if entry.error or entry.blueprint is None:
|
|
117
|
+
self.app.notify(
|
|
118
|
+
f"Cannot open an invalid Blueprint: {entry.error}", severity="error", timeout=8
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
from .runs import RunsScreen
|
|
123
|
+
|
|
124
|
+
self.app.push_screen(RunsScreen(entry.path))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Blueprint discovery and validation for the Library screen.
|
|
2
|
+
|
|
3
|
+
Pure functions, deliberately free of Textual imports, so they're trivially unit-testable and
|
|
4
|
+
reusable (e.g. by a future `aetherius list` CLI command) without pulling in the TUI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ...core.blueprint.loader import load_blueprint
|
|
14
|
+
from ...core.blueprint.models import Blueprint
|
|
15
|
+
from ...core.blueprint.validator import validate_for_act
|
|
16
|
+
from ...core.errors import BlueprintError
|
|
17
|
+
from ...core.runtime.engine import IMPLEMENTED_ACTS
|
|
18
|
+
|
|
19
|
+
_BLUEPRINT_GLOBS = ("*.blueprint.json", "*.blueprint.yaml", "*.blueprint.yml")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class BlueprintEntry:
|
|
24
|
+
path: Path
|
|
25
|
+
blueprint: Blueprint | None
|
|
26
|
+
act: str | None
|
|
27
|
+
error: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EntryStatus(str, Enum):
|
|
31
|
+
"""A Blueprint's real state in the Library: well-formed and runnable, or why not."""
|
|
32
|
+
|
|
33
|
+
INVALID = "invalid" # schema or capability error: unusable as-is
|
|
34
|
+
READY = "ready" # valid and its Act has a driver: runnable now
|
|
35
|
+
ACT_PENDING = "act pending" # valid but its Act is not implemented yet
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def entry_status(entry: BlueprintEntry) -> EntryStatus:
|
|
39
|
+
"""Classify *entry* by validity and Act runnability (the single source is IMPLEMENTED_ACTS)."""
|
|
40
|
+
if entry.error is not None or entry.act is None:
|
|
41
|
+
return EntryStatus.INVALID
|
|
42
|
+
if entry.act in IMPLEMENTED_ACTS:
|
|
43
|
+
return EntryStatus.READY
|
|
44
|
+
return EntryStatus.ACT_PENDING
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover_blueprint_dirs(start: Path | None = None) -> list[Path]:
|
|
48
|
+
"""Directories scanned by the Library: a repo checkout's `examples/`, plus `./blueprints/`
|
|
49
|
+
relative to the current working directory, whichever of the two actually exist."""
|
|
50
|
+
dirs: list[Path] = []
|
|
51
|
+
|
|
52
|
+
repo_examples = _find_repo_examples(Path(__file__).resolve())
|
|
53
|
+
if repo_examples is not None:
|
|
54
|
+
dirs.append(repo_examples)
|
|
55
|
+
|
|
56
|
+
cwd_blueprints = (start or Path.cwd()) / "blueprints"
|
|
57
|
+
if cwd_blueprints.is_dir() and cwd_blueprints not in dirs:
|
|
58
|
+
dirs.append(cwd_blueprints)
|
|
59
|
+
|
|
60
|
+
return dirs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_repo_examples(start: Path) -> Path | None:
|
|
64
|
+
"""Walk up from this file looking for a sibling `examples/` directory (repo checkout only;
|
|
65
|
+
absent from an installed, non-editable distribution)."""
|
|
66
|
+
for parent in start.parents:
|
|
67
|
+
candidate = parent / "examples"
|
|
68
|
+
if candidate.is_dir():
|
|
69
|
+
return candidate
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scan_blueprints(dirs: list[Path]) -> list[BlueprintEntry]:
|
|
74
|
+
"""Load and validate every Blueprint file found in *dirs*, recursively.
|
|
75
|
+
|
|
76
|
+
The scan recurses so examples can be organised into per-Act subdirectories (examples/vector,
|
|
77
|
+
examples/continuum, ...) and user libraries into any tree. Invalid files are kept in the result
|
|
78
|
+
with their error instead of raising."""
|
|
79
|
+
entries: list[BlueprintEntry] = []
|
|
80
|
+
seen: set[Path] = set()
|
|
81
|
+
|
|
82
|
+
for directory in dirs:
|
|
83
|
+
for pattern in _BLUEPRINT_GLOBS:
|
|
84
|
+
for path in sorted(directory.rglob(pattern)):
|
|
85
|
+
if path in seen:
|
|
86
|
+
continue
|
|
87
|
+
seen.add(path)
|
|
88
|
+
entries.append(_load_entry(path))
|
|
89
|
+
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_entry(path: Path) -> BlueprintEntry:
|
|
94
|
+
try:
|
|
95
|
+
blueprint = load_blueprint(path)
|
|
96
|
+
validate_for_act(blueprint)
|
|
97
|
+
except BlueprintError as exc:
|
|
98
|
+
return BlueprintEntry(path=path, blueprint=None, act=None, error=str(exc))
|
|
99
|
+
return BlueprintEntry(path=path, blueprint=blueprint, act=blueprint.act, error=None)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Launch and drive the Blueprint recorder from the Console.
|
|
2
|
+
|
|
3
|
+
Captures a browser demonstration and synthesizes a clean Blueprint with robust selectors, instead of
|
|
4
|
+
writing steps by hand. The recorder engine (`aetherius.recorder`) is synchronous and blocking (it
|
|
5
|
+
owns a live browser), so it runs in a Textual `@work(thread=True)` worker, exactly like a Blueprint
|
|
6
|
+
run. Captured actions stream into the event log through the same Sink pattern (`run_bridge`); Stop
|
|
7
|
+
sets a threading.Event the recorder's pump loop honors to close the browser.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from textual import work
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
19
|
+
from textual.css.query import NoMatches
|
|
20
|
+
from textual.screen import Screen
|
|
21
|
+
from textual.widgets import Button, Footer, Header, Input, Label, Select, Static, Switch
|
|
22
|
+
|
|
23
|
+
from ...core.errors import AetheriusError
|
|
24
|
+
from ...core.events.models import EventType, RunEvent
|
|
25
|
+
from ...recorder import record_blueprint
|
|
26
|
+
from ...recorder.base import recorder_acts
|
|
27
|
+
from ..run_bridge import TextualRunSink
|
|
28
|
+
from ..theme import starred
|
|
29
|
+
from ..widgets.event_log import EventLog
|
|
30
|
+
from ..widgets.json_preview import JsonPreview
|
|
31
|
+
|
|
32
|
+
# How each recorder Act reads in the picker. Derived list stays in sync with recorder_acts().
|
|
33
|
+
_ACT_LABELS: dict[str, str] = {
|
|
34
|
+
"continuum": "Continuum — browser (DOM)",
|
|
35
|
+
"vector": "Vector — API (network)",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RecorderScreen(Screen[None]):
|
|
40
|
+
"""Record a Blueprint by demonstration: enter a name and start URL, act in the browser, save."""
|
|
41
|
+
|
|
42
|
+
AUTO_FOCUS = "#rec-name"
|
|
43
|
+
|
|
44
|
+
DEFAULT_CSS = """
|
|
45
|
+
RecorderScreen .rec-field {
|
|
46
|
+
height: auto;
|
|
47
|
+
padding: 1 1 0 1;
|
|
48
|
+
}
|
|
49
|
+
RecorderScreen #rec-creds-row {
|
|
50
|
+
height: auto;
|
|
51
|
+
padding: 1 1 0 1;
|
|
52
|
+
}
|
|
53
|
+
RecorderScreen #rec-creds-label {
|
|
54
|
+
padding: 1 1 0 0;
|
|
55
|
+
}
|
|
56
|
+
RecorderScreen #rec-start, RecorderScreen #rec-stop {
|
|
57
|
+
border: double $accent;
|
|
58
|
+
background: $panel;
|
|
59
|
+
color: $accent;
|
|
60
|
+
text-style: bold;
|
|
61
|
+
padding: 0 3;
|
|
62
|
+
}
|
|
63
|
+
RecorderScreen #rec-start:hover, RecorderScreen #rec-stop:hover {
|
|
64
|
+
background: $accent 20%;
|
|
65
|
+
}
|
|
66
|
+
RecorderScreen #rec-result {
|
|
67
|
+
border: double $primary;
|
|
68
|
+
padding: 0 1;
|
|
69
|
+
margin: 1 0;
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self._stop = threading.Event()
|
|
76
|
+
self._sink: TextualRunSink | None = None
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
yield Header()
|
|
80
|
+
with VerticalScroll(classes="console-body"):
|
|
81
|
+
yield Static(starred("Recorder"), classes="console-title")
|
|
82
|
+
yield Static(
|
|
83
|
+
"Pick an Act, enter a name and a start URL, then Start: a browser opens. Continuum "
|
|
84
|
+
"records DOM actions and the floating menu picks data; Vector observes API calls. "
|
|
85
|
+
"Closing the window (or Stop) saves the Blueprint to ./blueprints.",
|
|
86
|
+
classes="console-subtitle",
|
|
87
|
+
)
|
|
88
|
+
with Vertical(classes="rec-field"):
|
|
89
|
+
yield Label("Act")
|
|
90
|
+
yield Select(
|
|
91
|
+
[(_ACT_LABELS.get(a, a), a) for a in sorted(recorder_acts())],
|
|
92
|
+
value="continuum",
|
|
93
|
+
allow_blank=False,
|
|
94
|
+
id="rec-act",
|
|
95
|
+
)
|
|
96
|
+
with Vertical(classes="rec-field"):
|
|
97
|
+
yield Label("Blueprint name (e.g. quotes.login)")
|
|
98
|
+
yield Input(placeholder="domain.task", id="rec-name")
|
|
99
|
+
with Vertical(classes="rec-field"):
|
|
100
|
+
yield Label("Start URL")
|
|
101
|
+
yield Input(placeholder="https://…", id="rec-url")
|
|
102
|
+
with Horizontal(id="rec-creds-row"):
|
|
103
|
+
yield Static("Turn credential fields into secrets", id="rec-creds-label")
|
|
104
|
+
yield Switch(value=True, id="rec-creds")
|
|
105
|
+
with Horizontal(classes="run-actions"):
|
|
106
|
+
yield Button("✦ Start recording ✦", id="rec-start", variant="primary")
|
|
107
|
+
stop = Button("Stop", id="rec-stop")
|
|
108
|
+
stop.display = False
|
|
109
|
+
yield stop
|
|
110
|
+
event_log = EventLog(id="rec-events")
|
|
111
|
+
event_log.border_title = "✦ Captured actions ✦"
|
|
112
|
+
yield event_log
|
|
113
|
+
result = JsonPreview(id="rec-result")
|
|
114
|
+
result.border_title = "✦ Result ✦"
|
|
115
|
+
result.display = False
|
|
116
|
+
yield result
|
|
117
|
+
yield Footer()
|
|
118
|
+
|
|
119
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
120
|
+
if event.button.id == "rec-stop":
|
|
121
|
+
self._stop.set()
|
|
122
|
+
self.app.notify("Stopping — closing the browser…", timeout=4)
|
|
123
|
+
return
|
|
124
|
+
if event.button.id != "rec-start":
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
name = self.query_one("#rec-name", Input).value.strip()
|
|
128
|
+
url = self.query_one("#rec-url", Input).value.strip()
|
|
129
|
+
if not name or not url:
|
|
130
|
+
self.app.notify("A name and a start URL are required.", severity="warning")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
act = str(self.query_one("#rec-act", Select).value)
|
|
134
|
+
credentials = self.query_one("#rec-creds", Switch).value
|
|
135
|
+
self._stop = threading.Event()
|
|
136
|
+
self._sink = TextualRunSink(self.app, self.query_one("#rec-events", EventLog))
|
|
137
|
+
self.query_one("#rec-events", EventLog).clear()
|
|
138
|
+
self.query_one("#rec-result", JsonPreview).display = False
|
|
139
|
+
self._set_recording(True)
|
|
140
|
+
self._record(name, url, act, credentials)
|
|
141
|
+
|
|
142
|
+
@work(thread=True, exclusive=True)
|
|
143
|
+
def _record(self, name: str, url: str, act: str, credentials: bool) -> None:
|
|
144
|
+
try:
|
|
145
|
+
path = record_blueprint(
|
|
146
|
+
name,
|
|
147
|
+
url,
|
|
148
|
+
act=act,
|
|
149
|
+
on_event=self._push_event,
|
|
150
|
+
stop_event=self._stop,
|
|
151
|
+
credentials_as_secrets=credentials,
|
|
152
|
+
)
|
|
153
|
+
except AetheriusError as exc:
|
|
154
|
+
self.app.call_from_thread(self._fail, exc)
|
|
155
|
+
return
|
|
156
|
+
self.app.call_from_thread(self._finish, path)
|
|
157
|
+
|
|
158
|
+
def _push_event(self, description: str) -> None:
|
|
159
|
+
"""Forward one captured action's description into the event log (from the worker thread)."""
|
|
160
|
+
if self._sink is None:
|
|
161
|
+
return
|
|
162
|
+
self._sink.on_event(
|
|
163
|
+
RunEvent(
|
|
164
|
+
run_id="recorder",
|
|
165
|
+
type=EventType.STEP_FINISHED,
|
|
166
|
+
level="info",
|
|
167
|
+
message=description,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _finish(self, path: Path) -> None:
|
|
172
|
+
self._set_recording(False)
|
|
173
|
+
try:
|
|
174
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
data = {}
|
|
177
|
+
result = self.query_one("#rec-result", JsonPreview)
|
|
178
|
+
result.display = True
|
|
179
|
+
result.show(data)
|
|
180
|
+
result.scroll_visible()
|
|
181
|
+
self.app.notify(f"Saved {path}", timeout=6)
|
|
182
|
+
|
|
183
|
+
def _fail(self, exc: Exception) -> None:
|
|
184
|
+
self._set_recording(False)
|
|
185
|
+
self.app.notify(str(exc), title="Recorder error", severity="error", timeout=8)
|
|
186
|
+
|
|
187
|
+
def _set_recording(self, recording: bool) -> None:
|
|
188
|
+
"""Toggle the Start/Stop buttons; tolerate the screen being popped mid-run."""
|
|
189
|
+
try:
|
|
190
|
+
self.query_one("#rec-start", Button).disabled = recording
|
|
191
|
+
self.query_one("#rec-stop", Button).display = recording
|
|
192
|
+
except NoMatches:
|
|
193
|
+
pass
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Run a Blueprint and follow its events, logs and artifacts live."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual import work
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
10
|
+
from textual.css.query import NoMatches
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Button, Footer, Header, Static, Switch
|
|
13
|
+
|
|
14
|
+
from ...core.blueprint.loader import load_blueprint
|
|
15
|
+
from ...core.blueprint.models import Blueprint
|
|
16
|
+
from ...core.blueprint.validator import validate_for_act
|
|
17
|
+
from ...core.errors import AetheriusError
|
|
18
|
+
from ...core.runtime.engine import IMPLEMENTED_ACTS, RunEngine
|
|
19
|
+
from ...core.runtime.result import Result
|
|
20
|
+
from ..run_bridge import TextualRunSink
|
|
21
|
+
from ..theme import starred
|
|
22
|
+
from ..widgets.event_log import EventLog
|
|
23
|
+
from ..widgets.form import BlueprintInputForm
|
|
24
|
+
from ..widgets.run_summary import RunSummary
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunsScreen(Screen[None]):
|
|
28
|
+
"""Load a Blueprint, collect its inputs, run it, and show live events + the final result.
|
|
29
|
+
|
|
30
|
+
The body is a VerticalScroll so every section stays reachable in short terminals.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Land directly in the first form field instead of the scroll container.
|
|
34
|
+
AUTO_FOCUS = "BlueprintInputForm Input"
|
|
35
|
+
|
|
36
|
+
def __init__(self, blueprint_path: Path) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self._blueprint_path = blueprint_path
|
|
39
|
+
self._blueprint: Blueprint | None = None
|
|
40
|
+
self._load_error: str | None = None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
self._blueprint = load_blueprint(blueprint_path)
|
|
44
|
+
validate_for_act(self._blueprint)
|
|
45
|
+
except AetheriusError as exc:
|
|
46
|
+
self._load_error = str(exc)
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
yield Header()
|
|
50
|
+
with VerticalScroll(classes="console-body"):
|
|
51
|
+
if self._load_error is not None:
|
|
52
|
+
yield Static(f"Cannot load Blueprint: {self._load_error}", classes="console-title")
|
|
53
|
+
yield Footer()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
assert self._blueprint is not None
|
|
57
|
+
bp = self._blueprint
|
|
58
|
+
yield Static(starred(f"Run — {bp.name}"), classes="console-title")
|
|
59
|
+
yield Static(f"Path: {self._blueprint_path}", classes="console-subtitle")
|
|
60
|
+
|
|
61
|
+
if bp.act not in IMPLEMENTED_ACTS:
|
|
62
|
+
yield Static(
|
|
63
|
+
f"Act {bp.act!r} has no driver yet — only Vector (Act I) is runnable. "
|
|
64
|
+
"This Blueprint is valid but cannot be executed from the Console.",
|
|
65
|
+
classes="console-subtitle",
|
|
66
|
+
)
|
|
67
|
+
yield Footer()
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
from ...config.secrets import available_from_env
|
|
71
|
+
|
|
72
|
+
form = BlueprintInputForm(bp.inputs, bp.secrets, available_from_env(bp.secrets))
|
|
73
|
+
form.border_title = "✦ Inputs ✦"
|
|
74
|
+
yield form
|
|
75
|
+
with Horizontal(classes="run-actions"):
|
|
76
|
+
yield Switch(value=bp.options.debug, id="run-debug")
|
|
77
|
+
yield Static("Debug — visible browser + slow-mo", id="run-debug-label")
|
|
78
|
+
with Horizontal(classes="run-actions"):
|
|
79
|
+
yield Button("✦ Run ✦", id="run-button", variant="primary")
|
|
80
|
+
event_log = EventLog(id="run-event-log")
|
|
81
|
+
event_log.border_title = "✦ Events ✦"
|
|
82
|
+
yield event_log
|
|
83
|
+
summary = RunSummary(id="run-summary")
|
|
84
|
+
summary.border_title = "✦ Result ✦"
|
|
85
|
+
yield summary
|
|
86
|
+
yield Footer()
|
|
87
|
+
|
|
88
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
89
|
+
if event.button.id != "run-button":
|
|
90
|
+
return
|
|
91
|
+
assert self._blueprint is not None
|
|
92
|
+
|
|
93
|
+
form = self.query_one(BlueprintInputForm)
|
|
94
|
+
errors = form.validation_errors()
|
|
95
|
+
if errors:
|
|
96
|
+
self.app.notify("\n".join(errors), title="Missing inputs", severity="warning")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Debug is a per-run choice here, not baked into the file: toggle it on the loaded model.
|
|
100
|
+
self._blueprint.options.debug = self.query_one("#run-debug", Switch).value
|
|
101
|
+
|
|
102
|
+
input_values, secret_values = form.collect()
|
|
103
|
+
event.button.disabled = True
|
|
104
|
+
self.query_one("#run-event-log", EventLog).clear()
|
|
105
|
+
self.query_one("#run-summary", RunSummary).reset()
|
|
106
|
+
self._run_blueprint(input_values, secret_values)
|
|
107
|
+
|
|
108
|
+
@work(thread=True, exclusive=True)
|
|
109
|
+
def _run_blueprint(self, inputs: dict[str, object], secrets: dict[str, str]) -> None:
|
|
110
|
+
assert self._blueprint is not None
|
|
111
|
+
event_log = self.query_one("#run-event-log", EventLog)
|
|
112
|
+
sink = TextualRunSink(self.app, event_log)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
result = RunEngine().run(self._blueprint, inputs=inputs, secrets=secrets, sinks=[sink])
|
|
116
|
+
except AetheriusError as exc:
|
|
117
|
+
self.app.call_from_thread(self._notify_error, exc)
|
|
118
|
+
self.app.call_from_thread(self._reenable_run_button)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
self.app.call_from_thread(self._show_result, result)
|
|
122
|
+
|
|
123
|
+
def _show_result(self, result: Result) -> None:
|
|
124
|
+
summary = self.query_one("#run-summary", RunSummary)
|
|
125
|
+
summary.show(result)
|
|
126
|
+
summary.scroll_visible()
|
|
127
|
+
self._reenable_run_button()
|
|
128
|
+
|
|
129
|
+
def _reenable_run_button(self) -> None:
|
|
130
|
+
# The screen may have been popped while the worker was still running; that's fine,
|
|
131
|
+
# there's nothing left to re-enable.
|
|
132
|
+
try:
|
|
133
|
+
self.query_one("#run-button", Button).disabled = False
|
|
134
|
+
except NoMatches:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def _notify_error(self, exc: Exception) -> None:
|
|
138
|
+
self.app.notify(str(exc), title="Error", severity="error", timeout=8)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Manage persistent profiles and sessions, and launch warmup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._pending import PendingScreen
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionsScreen(PendingScreen):
|
|
9
|
+
title_text = "Sessions"
|
|
10
|
+
description_text = (
|
|
11
|
+
"Will manage persistent browser profiles (cookies, cache) and let you launch a warmup "
|
|
12
|
+
"run to build an authentic history before automation."
|
|
13
|
+
)
|
|
14
|
+
milestone_text = "the discretion/stealth subsystem (src/aetherius/stealth/session/)."
|