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.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. 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/)."