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,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))