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,97 @@
|
|
|
1
|
+
/* Aetherius Console stylesheet: screen-level layout only. Rules that belong to a single
|
|
2
|
+
reusable widget live in that widget's DEFAULT_CSS (scoped), not here. */
|
|
3
|
+
|
|
4
|
+
Screen {
|
|
5
|
+
align: center top;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.console-title {
|
|
9
|
+
text-style: bold;
|
|
10
|
+
padding: 1 2 0 2;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.console-subtitle {
|
|
14
|
+
color: $text-muted;
|
|
15
|
+
padding: 0 2 1 2;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.console-body {
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 1fr;
|
|
21
|
+
padding: 0 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.console-placeholder {
|
|
25
|
+
width: 100%;
|
|
26
|
+
height: 1fr;
|
|
27
|
+
align: center middle;
|
|
28
|
+
padding: 2 4;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.console-placeholder Static {
|
|
32
|
+
text-align: center;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.pending-frame {
|
|
36
|
+
height: auto;
|
|
37
|
+
align: center middle;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.pending-column {
|
|
41
|
+
width: auto;
|
|
42
|
+
padding: 0 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.pending-content {
|
|
46
|
+
width: 1fr;
|
|
47
|
+
height: auto;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.console-footer-hint {
|
|
51
|
+
color: $text-muted;
|
|
52
|
+
padding: 1 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.run-actions {
|
|
56
|
+
height: auto;
|
|
57
|
+
padding: 1 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#run-debug-label {
|
|
61
|
+
padding: 1 0 0 1;
|
|
62
|
+
color: $text-muted;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
RunsScreen BlueprintInputForm {
|
|
66
|
+
border: double $accent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#run-button {
|
|
70
|
+
border: double $accent;
|
|
71
|
+
background: $panel;
|
|
72
|
+
color: $accent;
|
|
73
|
+
text-style: bold;
|
|
74
|
+
padding: 0 3;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#run-button:hover {
|
|
78
|
+
background: $accent 20%;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#run-button:focus {
|
|
82
|
+
text-style: bold;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#home-banner {
|
|
86
|
+
width: 100%;
|
|
87
|
+
padding: 1 0 0 0;
|
|
88
|
+
text-align: center;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#home-status {
|
|
92
|
+
text-align: center;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#home-menu {
|
|
96
|
+
margin: 1 0 0 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Manage the local daemon as a child process, on behalf of the Console.
|
|
2
|
+
|
|
3
|
+
The Console can start and stop ``aetherius serve`` without leaving the terminal. The daemon runs as a
|
|
4
|
+
separate process (``python -m aetherius serve``) so it can be killed cleanly and, deliberately,
|
|
5
|
+
survives navigating away from the Settings screen — it is a service, not a screen's state. It is
|
|
6
|
+
owned by the App (one per session) and stopped on exit, with an ``atexit`` guard against orphans.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from ..server.config import DaemonConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DaemonController:
|
|
20
|
+
"""Start, stop and probe a local daemon child process."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._config = DaemonConfig()
|
|
24
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
25
|
+
atexit.register(self.stop)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def config(self) -> DaemonConfig:
|
|
29
|
+
return self._config
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def base_url(self) -> str:
|
|
33
|
+
return self._config.base_url
|
|
34
|
+
|
|
35
|
+
def is_running(self) -> bool:
|
|
36
|
+
return self._process is not None and self._process.poll() is None
|
|
37
|
+
|
|
38
|
+
def start(self) -> None:
|
|
39
|
+
"""Spawn the daemon with the resolved config. No-op if already running."""
|
|
40
|
+
if self.is_running():
|
|
41
|
+
return
|
|
42
|
+
# The token is passed through the environment, never argv, so it does not leak into `ps`.
|
|
43
|
+
env = dict(os.environ)
|
|
44
|
+
if self._config.token:
|
|
45
|
+
env["AETHERIUS_DAEMON_TOKEN"] = self._config.token
|
|
46
|
+
self._process = subprocess.Popen(
|
|
47
|
+
[
|
|
48
|
+
sys.executable,
|
|
49
|
+
"-m",
|
|
50
|
+
"aetherius",
|
|
51
|
+
"serve",
|
|
52
|
+
"--host",
|
|
53
|
+
self._config.host,
|
|
54
|
+
"--port",
|
|
55
|
+
str(self._config.port),
|
|
56
|
+
],
|
|
57
|
+
env=env,
|
|
58
|
+
stdout=subprocess.DEVNULL,
|
|
59
|
+
stderr=subprocess.DEVNULL,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def stop(self) -> None:
|
|
63
|
+
"""Terminate the daemon if running. Idempotent."""
|
|
64
|
+
process = self._process
|
|
65
|
+
if process is None:
|
|
66
|
+
return
|
|
67
|
+
if process.poll() is None:
|
|
68
|
+
process.terminate()
|
|
69
|
+
try:
|
|
70
|
+
process.wait(timeout=5)
|
|
71
|
+
except subprocess.TimeoutExpired:
|
|
72
|
+
process.kill()
|
|
73
|
+
self._process = None
|
|
74
|
+
|
|
75
|
+
def healthy(self) -> bool:
|
|
76
|
+
"""True if the daemon answers its health probe. Never raises."""
|
|
77
|
+
import httpx
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
response = httpx.get(f"{self.base_url}/health", timeout=1.0)
|
|
81
|
+
return response.status_code == 200
|
|
82
|
+
except httpx.HTTPError:
|
|
83
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Bridges RunEngine events (emitted on a worker thread) into a Textual widget on the UI thread.
|
|
2
|
+
|
|
3
|
+
RunEngine.run() is synchronous and blocking (HTTP calls), so the Console drives it from a
|
|
4
|
+
Textual `@work(thread=True)` worker. Sinks are called from that worker thread, but Textual
|
|
5
|
+
widgets may only be touched from the app's own thread — `App.call_from_thread` is Textual's
|
|
6
|
+
documented mechanism for crossing that boundary safely.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from textual.app import App
|
|
15
|
+
|
|
16
|
+
from ..core.events.models import RunEvent
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger("aetherius.console")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class SupportsWriteEvent(Protocol):
|
|
23
|
+
"""Structural type for the widget passed to TextualRunSink (e.g. EventLog)."""
|
|
24
|
+
|
|
25
|
+
def write_event(self, event: RunEvent) -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TextualRunSink:
|
|
29
|
+
"""Sink that forwards each RunEvent to an EventLog-like widget's `write_event` method.
|
|
30
|
+
|
|
31
|
+
Structurally satisfies core.events.sinks.Sink; never raises, so a widget failure cannot
|
|
32
|
+
abort the Blueprint run it is merely observing.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, app: App[object], writer: SupportsWriteEvent) -> None:
|
|
36
|
+
self._app = app
|
|
37
|
+
self._writer = writer
|
|
38
|
+
|
|
39
|
+
def on_event(self, event: RunEvent) -> None:
|
|
40
|
+
try:
|
|
41
|
+
self._app.call_from_thread(self._writer.write_event, event)
|
|
42
|
+
except Exception:
|
|
43
|
+
_log.exception("Failed to forward run event %r to the Console.", event.type)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Console screens."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared base for the Console sections whose backing subsystem doesn't exist yet.
|
|
2
|
+
|
|
3
|
+
Kept intentionally minimal: a static explanation of what the screen will do and which
|
|
4
|
+
milestone it depends on, framed by mosaic columns. No interactivity to fake.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.screen import Screen
|
|
14
|
+
from textual.widgets import Footer, Header, Static
|
|
15
|
+
|
|
16
|
+
from ..theme import TYRIAN, frieze, frieze_column, starred
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PendingScreen(Screen[None]):
|
|
20
|
+
"""Base class for a Console section pending its underlying subsystem."""
|
|
21
|
+
|
|
22
|
+
title_text: str = ""
|
|
23
|
+
description_text: str = ""
|
|
24
|
+
milestone_text: str = ""
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
yield Header()
|
|
28
|
+
with Vertical(classes="console-placeholder"):
|
|
29
|
+
with Horizontal(classes="pending-frame"):
|
|
30
|
+
yield Static(Text(frieze_column(7), style=TYRIAN), classes="pending-column")
|
|
31
|
+
with Vertical(classes="pending-content"):
|
|
32
|
+
yield Static(Text(frieze(24), style=TYRIAN))
|
|
33
|
+
yield Static(starred(self.title_text), classes="console-title")
|
|
34
|
+
yield Static(self.description_text, classes="console-subtitle")
|
|
35
|
+
yield Static(f"Pending: {self.milestone_text}", classes="console-footer-hint")
|
|
36
|
+
yield Static(Text(frieze(24), style=TYRIAN))
|
|
37
|
+
yield Static(Text(frieze_column(7), style=TYRIAN), classes="pending-column")
|
|
38
|
+
yield Footer()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Blueprint Studio: guided Blueprint creation without writing JSON."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Act picker with inline explanations of each Act.
|
|
2
|
+
|
|
3
|
+
A Select of the four Acts (their runnable status from the shared catalogue) plus a live one-line
|
|
4
|
+
explanation. Emits :class:`ActPicker.ActChanged` so the Studio can re-key the available actions and
|
|
5
|
+
re-validate when the Act changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.containers import Vertical
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widgets import Label, Select, Static
|
|
14
|
+
|
|
15
|
+
from ....builder.catalog import act_infos, get_act_info
|
|
16
|
+
from ...theme import ACT_LABELS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _option_label(act: str, implemented: bool) -> str:
|
|
20
|
+
return ACT_LABELS[act] + ("" if implemented else " (not runnable yet)")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ActPicker(Vertical):
|
|
24
|
+
"""Choose the Blueprint's Act and show what it does."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_CSS = """
|
|
27
|
+
ActPicker {
|
|
28
|
+
height: auto;
|
|
29
|
+
padding: 1 1 0 1;
|
|
30
|
+
}
|
|
31
|
+
ActPicker #act-explanation {
|
|
32
|
+
color: $text-muted;
|
|
33
|
+
padding: 1 0 0 0;
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
class ActChanged(Message):
|
|
38
|
+
"""Posted when the selected Act changes."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, act: str) -> None:
|
|
41
|
+
self.act = act
|
|
42
|
+
super().__init__()
|
|
43
|
+
|
|
44
|
+
def __init__(self, act: str = "vector") -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self._act = act
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
yield Label("Act")
|
|
50
|
+
yield Select(
|
|
51
|
+
[(_option_label(info.act, info.implemented), info.act) for info in act_infos()],
|
|
52
|
+
value=self._act,
|
|
53
|
+
allow_blank=False,
|
|
54
|
+
id="act-select",
|
|
55
|
+
)
|
|
56
|
+
yield Static(self._explanation(self._act), id="act-explanation")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def act(self) -> str:
|
|
60
|
+
return self._act
|
|
61
|
+
|
|
62
|
+
def _explanation(self, act: str) -> str:
|
|
63
|
+
info = get_act_info(act)
|
|
64
|
+
note = (
|
|
65
|
+
"" if info.implemented else " This Act has no driver yet — valid to author, not run."
|
|
66
|
+
)
|
|
67
|
+
return info.summary + note
|
|
68
|
+
|
|
69
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
70
|
+
# Handle it here and stop it, so the Studio only reacts to the dedicated ActChanged message.
|
|
71
|
+
event.stop()
|
|
72
|
+
act = str(event.value)
|
|
73
|
+
self._act = act
|
|
74
|
+
self.query_one("#act-explanation", Static).update(self._explanation(act))
|
|
75
|
+
self.post_message(self.ActChanged(act))
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Editors for a Blueprint's inputs, secrets, vars and outputs.
|
|
2
|
+
|
|
3
|
+
``InputsEditor`` renders typed input rows (name, type, required) plus a secrets list, and preserves
|
|
4
|
+
any extra keys of an existing input (default, format, description) so editing a file never drops
|
|
5
|
+
them. ``VarsOutputsEditor`` edits the two free-form maps as JSON, keeping the last valid parse so the
|
|
6
|
+
live preview stays consistent while the user is mid-edit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.containers import Horizontal, Vertical
|
|
16
|
+
from textual.message import Message
|
|
17
|
+
from textual.widgets import Button, Input, Label, Select, Switch, TextArea
|
|
18
|
+
|
|
19
|
+
_INPUT_TYPES = ("string", "number", "integer", "boolean", "date", "path", "object", "array")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _InputRow(Horizontal):
|
|
23
|
+
"""One input declaration; keeps the original spec's extra keys to stay lossless."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_CSS = "_InputRow { height: auto; padding: 0 0 1 0; } _InputRow Input { width: 20; }"
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str = "", spec: dict[str, Any] | None = None) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
spec = dict(spec or {})
|
|
30
|
+
self._name = name
|
|
31
|
+
self._type = str(spec.pop("type", "string"))
|
|
32
|
+
self._required = bool(spec.pop("required", False))
|
|
33
|
+
self._extra = spec # format / default / description preserved verbatim
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
yield Input(value=self._name, placeholder="name", classes="io-input-name")
|
|
37
|
+
yield Select(
|
|
38
|
+
[(t, t) for t in _INPUT_TYPES],
|
|
39
|
+
value=self._type,
|
|
40
|
+
allow_blank=False,
|
|
41
|
+
classes="io-input-type",
|
|
42
|
+
)
|
|
43
|
+
yield Switch(value=self._required, classes="io-input-required")
|
|
44
|
+
yield Label("required")
|
|
45
|
+
yield Button("✕", classes="io-input-remove")
|
|
46
|
+
|
|
47
|
+
def collect(self) -> tuple[str, dict[str, Any]] | None:
|
|
48
|
+
"""Return (name, spec), or None when the row has no name yet or is still mounting."""
|
|
49
|
+
if not self.query(".io-input-name"):
|
|
50
|
+
return None # children not composed yet (just mounted)
|
|
51
|
+
name = self.query_one(".io-input-name", Input).value.strip()
|
|
52
|
+
if not name:
|
|
53
|
+
return None
|
|
54
|
+
spec: dict[str, Any] = dict(self._extra)
|
|
55
|
+
spec["type"] = str(self.query_one(".io-input-type", Select).value)
|
|
56
|
+
if self.query_one(".io-input-required", Switch).value:
|
|
57
|
+
spec["required"] = True
|
|
58
|
+
return name, spec
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InputsEditor(Vertical):
|
|
62
|
+
"""Typed input rows plus a comma-separated secrets list."""
|
|
63
|
+
|
|
64
|
+
DEFAULT_CSS = """
|
|
65
|
+
InputsEditor { height: auto; padding: 1 1 0 1; }
|
|
66
|
+
/* Without this the empty rows container grabs 1fr and inflates the whole editor. */
|
|
67
|
+
InputsEditor #io-input-rows { height: auto; }
|
|
68
|
+
InputsEditor #io-add-input { margin: 0 0 1 0; }
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
class Changed(Message):
|
|
72
|
+
"""Posted when a row is added or removed (text edits bubble as Input/Switch changes)."""
|
|
73
|
+
|
|
74
|
+
def compose(self) -> ComposeResult:
|
|
75
|
+
yield Label("Inputs")
|
|
76
|
+
yield Vertical(id="io-input-rows")
|
|
77
|
+
yield Button("+ Add input", id="io-add-input")
|
|
78
|
+
yield Label("Secrets (comma-separated names)")
|
|
79
|
+
yield Input(placeholder="password, api_key", id="io-secrets")
|
|
80
|
+
|
|
81
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
82
|
+
if event.button.id == "io-add-input":
|
|
83
|
+
event.stop()
|
|
84
|
+
self.query_one("#io-input-rows", Vertical).mount(_InputRow())
|
|
85
|
+
self.post_message(self.Changed())
|
|
86
|
+
elif event.button.has_class("io-input-remove"):
|
|
87
|
+
event.stop()
|
|
88
|
+
row = event.button.parent
|
|
89
|
+
if isinstance(row, _InputRow):
|
|
90
|
+
row.remove()
|
|
91
|
+
self.post_message(self.Changed())
|
|
92
|
+
|
|
93
|
+
def collect(self) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
|
94
|
+
"""Read the rows and the secrets field into (inputs, secrets)."""
|
|
95
|
+
inputs: dict[str, dict[str, Any]] = {}
|
|
96
|
+
for row in self.query(_InputRow):
|
|
97
|
+
collected = row.collect()
|
|
98
|
+
if collected is not None:
|
|
99
|
+
inputs[collected[0]] = collected[1]
|
|
100
|
+
raw = self.query_one("#io-secrets", Input).value
|
|
101
|
+
secrets = [s.strip() for s in raw.split(",") if s.strip()]
|
|
102
|
+
return inputs, secrets
|
|
103
|
+
|
|
104
|
+
def load(self, inputs: dict[str, dict[str, Any]], secrets: list[str]) -> None:
|
|
105
|
+
rows = self.query_one("#io-input-rows", Vertical)
|
|
106
|
+
rows.remove_children()
|
|
107
|
+
for name, spec in inputs.items():
|
|
108
|
+
rows.mount(_InputRow(name, spec))
|
|
109
|
+
self.query_one("#io-secrets", Input).value = ", ".join(secrets)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class VarsOutputsEditor(Vertical):
|
|
113
|
+
"""JSON editors for the two free-form maps, tolerant of mid-edit invalid JSON."""
|
|
114
|
+
|
|
115
|
+
DEFAULT_CSS = """
|
|
116
|
+
VarsOutputsEditor { height: auto; padding: 1 1 0 1; }
|
|
117
|
+
VarsOutputsEditor TextArea { height: 6; margin: 0 0 1 0; }
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
super().__init__()
|
|
122
|
+
self._vars: dict[str, Any] = {}
|
|
123
|
+
self._outputs: dict[str, Any] = {}
|
|
124
|
+
self._invalid: set[str] = set()
|
|
125
|
+
|
|
126
|
+
def compose(self) -> ComposeResult:
|
|
127
|
+
yield Label("Vars (JSON object)")
|
|
128
|
+
yield TextArea(id="io-vars")
|
|
129
|
+
yield Label("Outputs (JSON object)")
|
|
130
|
+
yield TextArea(id="io-outputs")
|
|
131
|
+
|
|
132
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
133
|
+
which = event.text_area.id
|
|
134
|
+
parsed = self._parse(event.text_area.text)
|
|
135
|
+
if parsed is None:
|
|
136
|
+
self._invalid.add(str(which))
|
|
137
|
+
return
|
|
138
|
+
self._invalid.discard(str(which))
|
|
139
|
+
if which == "io-vars":
|
|
140
|
+
self._vars = parsed
|
|
141
|
+
else:
|
|
142
|
+
self._outputs = parsed
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _parse(text: str) -> dict[str, Any] | None:
|
|
146
|
+
if not text.strip():
|
|
147
|
+
return {}
|
|
148
|
+
try:
|
|
149
|
+
value = json.loads(text)
|
|
150
|
+
except ValueError:
|
|
151
|
+
return None
|
|
152
|
+
return value if isinstance(value, dict) else None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def has_errors(self) -> bool:
|
|
156
|
+
return bool(self._invalid)
|
|
157
|
+
|
|
158
|
+
def collect(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
159
|
+
"""Return the last valid (vars, outputs)."""
|
|
160
|
+
return dict(self._vars), dict(self._outputs)
|
|
161
|
+
|
|
162
|
+
def load(self, vars: dict[str, Any], outputs: dict[str, Any]) -> None:
|
|
163
|
+
self._vars, self._outputs, self._invalid = dict(vars), dict(outputs), set()
|
|
164
|
+
self.query_one("#io-vars", TextArea).text = json.dumps(vars, indent=2) if vars else ""
|
|
165
|
+
self.query_one("#io-outputs", TextArea).text = (
|
|
166
|
+
json.dumps(outputs, indent=2) if outputs else ""
|
|
167
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Editor for a Blueprint's durable options (debug, timeout, retries, stealth, session).
|
|
2
|
+
|
|
3
|
+
These are the options the README says belong in the Studio, not the per-run Debug toggle of the Runs
|
|
4
|
+
screen. ``collect`` returns only non-default keys (an empty dict when everything is default, so the
|
|
5
|
+
options block is omitted); ``load`` restores them when editing an existing file. An advanced inline
|
|
6
|
+
``stealth`` object is preserved verbatim, since the presets Select cannot represent it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.containers import Horizontal, Vertical
|
|
15
|
+
from textual.widgets import Input, Label, Select, Switch
|
|
16
|
+
|
|
17
|
+
from ....stealth.policy import preset_names
|
|
18
|
+
|
|
19
|
+
_BACKOFFS = ("none", "linear", "exponential")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _as_int(raw: str) -> Any:
|
|
23
|
+
"""Parse an int field: '' -> None (omit), a valid int -> int, anything else -> the raw string
|
|
24
|
+
(kept so the preview's schema check flags it rather than silently dropping the mistake)."""
|
|
25
|
+
text = raw.strip()
|
|
26
|
+
if not text:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
return int(text)
|
|
30
|
+
except ValueError:
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OptionsEditor(Vertical):
|
|
35
|
+
"""Form for ``options`` with a shape matching the schema's options object."""
|
|
36
|
+
|
|
37
|
+
DEFAULT_CSS = """
|
|
38
|
+
OptionsEditor { height: auto; padding: 1 1 0 1; }
|
|
39
|
+
OptionsEditor .opt-row { height: auto; padding: 0 0 1 0; }
|
|
40
|
+
OptionsEditor Label { padding: 1 1 0 0; }
|
|
41
|
+
OptionsEditor Input { width: 24; }
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
# An inline stealth object (not a preset name) we cannot render in the Select; kept to avoid
|
|
47
|
+
# dropping it on save when the user leaves the Select untouched.
|
|
48
|
+
self._stealth_obj: dict[str, Any] | None = None
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
with Horizontal(classes="opt-row"):
|
|
52
|
+
yield Switch(value=False, id="opt-debug")
|
|
53
|
+
yield Label("Debug (visible browser + slow-mo)")
|
|
54
|
+
with Horizontal(classes="opt-row"):
|
|
55
|
+
yield Label("Timeout (ms)")
|
|
56
|
+
yield Input(placeholder="30000", id="opt-timeout")
|
|
57
|
+
with Horizontal(classes="opt-row"):
|
|
58
|
+
yield Label("Retries max")
|
|
59
|
+
yield Input(placeholder="0", id="opt-retries-max")
|
|
60
|
+
yield Label("backoff")
|
|
61
|
+
yield Select(
|
|
62
|
+
[(b, b) for b in _BACKOFFS], value="none", allow_blank=False, id="opt-backoff"
|
|
63
|
+
)
|
|
64
|
+
with Horizontal(classes="opt-row"):
|
|
65
|
+
yield Label("Stealth")
|
|
66
|
+
yield Select(
|
|
67
|
+
[(n, n) for n in preset_names()], value="off", allow_blank=False, id="opt-stealth"
|
|
68
|
+
)
|
|
69
|
+
with Horizontal(classes="opt-row"):
|
|
70
|
+
yield Label("Session profile")
|
|
71
|
+
yield Input(placeholder="my-profile", id="opt-session-profile")
|
|
72
|
+
yield Switch(value=False, id="opt-session-persist")
|
|
73
|
+
yield Label("persist")
|
|
74
|
+
|
|
75
|
+
def collect(self) -> dict[str, Any]:
|
|
76
|
+
"""Read the widgets into an options dict, omitting anything left at its default."""
|
|
77
|
+
options: dict[str, Any] = {}
|
|
78
|
+
if self.query_one("#opt-debug", Switch).value:
|
|
79
|
+
options["debug"] = True
|
|
80
|
+
|
|
81
|
+
timeout = _as_int(self.query_one("#opt-timeout", Input).value)
|
|
82
|
+
if timeout is not None:
|
|
83
|
+
options["timeout_ms"] = timeout
|
|
84
|
+
|
|
85
|
+
retries_max = _as_int(self.query_one("#opt-retries-max", Input).value)
|
|
86
|
+
backoff = str(self.query_one("#opt-backoff", Select).value)
|
|
87
|
+
if retries_max is not None and retries_max != 0:
|
|
88
|
+
retries: dict[str, Any] = {"max": retries_max}
|
|
89
|
+
if backoff != "none":
|
|
90
|
+
retries["backoff"] = backoff
|
|
91
|
+
options["retries"] = retries
|
|
92
|
+
|
|
93
|
+
stealth = str(self.query_one("#opt-stealth", Select).value)
|
|
94
|
+
if stealth != "off":
|
|
95
|
+
options["stealth"] = stealth
|
|
96
|
+
elif self._stealth_obj is not None:
|
|
97
|
+
options["stealth"] = self._stealth_obj # preserve an advanced inline config
|
|
98
|
+
|
|
99
|
+
profile = self.query_one("#opt-session-profile", Input).value.strip()
|
|
100
|
+
persist = self.query_one("#opt-session-persist", Switch).value
|
|
101
|
+
if profile or persist:
|
|
102
|
+
session: dict[str, Any] = {}
|
|
103
|
+
if profile:
|
|
104
|
+
session["profile"] = profile
|
|
105
|
+
if persist:
|
|
106
|
+
session["persist"] = True
|
|
107
|
+
options["session"] = session
|
|
108
|
+
return options
|
|
109
|
+
|
|
110
|
+
def load(self, options: dict[str, Any]) -> None:
|
|
111
|
+
"""Restore the widgets from an existing options dict (edit mode)."""
|
|
112
|
+
self.query_one("#opt-debug", Switch).value = bool(options.get("debug", False))
|
|
113
|
+
timeout = options.get("timeout_ms")
|
|
114
|
+
self.query_one("#opt-timeout", Input).value = "" if timeout is None else str(timeout)
|
|
115
|
+
|
|
116
|
+
retries = options.get("retries") or {}
|
|
117
|
+
self.query_one("#opt-retries-max", Input).value = str(retries.get("max", "") or "")
|
|
118
|
+
self.query_one("#opt-backoff", Select).value = retries.get("backoff", "none")
|
|
119
|
+
|
|
120
|
+
stealth = options.get("stealth")
|
|
121
|
+
if isinstance(stealth, dict):
|
|
122
|
+
self._stealth_obj = stealth
|
|
123
|
+
self.query_one("#opt-stealth", Select).value = "off"
|
|
124
|
+
else:
|
|
125
|
+
self._stealth_obj = None
|
|
126
|
+
self.query_one("#opt-stealth", Select).value = (
|
|
127
|
+
stealth if stealth in preset_names() else "off"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
session = options.get("session") or {}
|
|
131
|
+
self.query_one("#opt-session-profile", Input).value = str(session.get("profile", "") or "")
|
|
132
|
+
self.query_one("#opt-session-persist", Switch).value = bool(session.get("persist", False))
|