@widgex/wgx 0.0.0-stage → 0.4.2

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 (131) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4685 -0
  4. package/package.json +30 -4
  5. package/provider-clis.json +1211 -0
  6. package/python/XACTOR.md +146 -0
  7. package/python/browser_actor_host.py +456 -0
  8. package/python/cdp_page.py +363 -0
  9. package/python/harness_check.py +77 -0
  10. package/python/requirements.txt +1 -0
  11. package/python/test_line_endings.py +185 -0
  12. package/python/xactor/__init__.py +18 -0
  13. package/python/xactor/__main__.py +226 -0
  14. package/python/xactor/api.py +882 -0
  15. package/python/xactor/bridge.py +784 -0
  16. package/python/xactor/dom.py +117 -0
  17. package/python/xactor/man/add_card.md +89 -0
  18. package/python/xactor/man/agent-vs-fork.md +108 -0
  19. package/python/xactor/man/agent.md +333 -0
  20. package/python/xactor/man/ask_model.md +110 -0
  21. package/python/xactor/man/ask_reference.md +56 -0
  22. package/python/xactor/man/ask_user.md +103 -0
  23. package/python/xactor/man/attach_app.md +595 -0
  24. package/python/xactor/man/attach_ide.md +65 -0
  25. package/python/xactor/man/attach_ide_surface.md +31 -0
  26. package/python/xactor/man/await_user.md +214 -0
  27. package/python/xactor/man/bash.md +142 -0
  28. package/python/xactor/man/browser-driver.md +137 -0
  29. package/python/xactor/man/browser_activity.md +53 -0
  30. package/python/xactor/man/browser_attach.md +84 -0
  31. package/python/xactor/man/browser_close_tab.md +50 -0
  32. package/python/xactor/man/browser_detach.md +50 -0
  33. package/python/xactor/man/browser_exec.md +66 -0
  34. package/python/xactor/man/browser_focus_tab.md +47 -0
  35. package/python/xactor/man/browser_hybrid.md +47 -0
  36. package/python/xactor/man/browser_open.md +50 -0
  37. package/python/xactor/man/browser_open_tab.md +58 -0
  38. package/python/xactor/man/browser_plumbing.md +107 -0
  39. package/python/xactor/man/browser_screenshot.md +56 -0
  40. package/python/xactor/man/browser_snapshot.md +52 -0
  41. package/python/xactor/man/browser_tabs.md +67 -0
  42. package/python/xactor/man/bulletin.md +88 -0
  43. package/python/xactor/man/capture_page.md +54 -0
  44. package/python/xactor/man/cd.md +64 -0
  45. package/python/xactor/man/chess_move.md +53 -0
  46. package/python/xactor/man/children.md +89 -0
  47. package/python/xactor/man/close_task.md +14 -0
  48. package/python/xactor/man/compact.md +75 -0
  49. package/python/xactor/man/drive.md +140 -0
  50. package/python/xactor/man/drive_space.md +72 -0
  51. package/python/xactor/man/driving.md +168 -0
  52. package/python/xactor/man/edit_file.md +112 -0
  53. package/python/xactor/man/finding.md +194 -0
  54. package/python/xactor/man/fork_agent.md +45 -0
  55. package/python/xactor/man/glob.md +83 -0
  56. package/python/xactor/man/grep.md +115 -0
  57. package/python/xactor/man/harness.md +165 -0
  58. package/python/xactor/man/harness_check.md +70 -0
  59. package/python/xactor/man/harness_format.md +257 -0
  60. package/python/xactor/man/highlight.md +66 -0
  61. package/python/xactor/man/instruct.md +111 -0
  62. package/python/xactor/man/ls.md +63 -0
  63. package/python/xactor/man/man.md +86 -0
  64. package/python/xactor/man/merge_wiz.md +40 -0
  65. package/python/xactor/man/notebook.md +98 -0
  66. package/python/xactor/man/overview.md +89 -0
  67. package/python/xactor/man/permissions.md +137 -0
  68. package/python/xactor/man/phase.md +150 -0
  69. package/python/xactor/man/powershell.md +74 -0
  70. package/python/xactor/man/profiles.md +60 -0
  71. package/python/xactor/man/project_drive.md +49 -0
  72. package/python/xactor/man/read_bulletin.md +89 -0
  73. package/python/xactor/man/read_file.md +90 -0
  74. package/python/xactor/man/relay.md +216 -0
  75. package/python/xactor/man/reply.md +72 -0
  76. package/python/xactor/man/reply_message.md +78 -0
  77. package/python/xactor/man/report_bug.md +118 -0
  78. package/python/xactor/man/request_answer.md +142 -0
  79. package/python/xactor/man/request_merge_decisions.md +121 -0
  80. package/python/xactor/man/request_operator_action.md +152 -0
  81. package/python/xactor/man/request_review.md +122 -0
  82. package/python/xactor/man/request_user_action.md +126 -0
  83. package/python/xactor/man/return_card.md +71 -0
  84. package/python/xactor/man/run.md +99 -0
  85. package/python/xactor/man/sandbox.md +144 -0
  86. package/python/xactor/man/session_fork.md +74 -0
  87. package/python/xactor/man/session_harness.md +109 -0
  88. package/python/xactor/man/session_join.md +102 -0
  89. package/python/xactor/man/session_land.md +73 -0
  90. package/python/xactor/man/session_nudge.md +73 -0
  91. package/python/xactor/man/session_rest.md +73 -0
  92. package/python/xactor/man/session_scope.md +84 -0
  93. package/python/xactor/man/session_terminate.md +58 -0
  94. package/python/xactor/man/session_token.md +78 -0
  95. package/python/xactor/man/sessions.md +93 -0
  96. package/python/xactor/man/set_harness.md +15 -0
  97. package/python/xactor/man/spawn_swarm.md +80 -0
  98. package/python/xactor/man/submit_evidence.md +54 -0
  99. package/python/xactor/man/swarm_brief.md +30 -0
  100. package/python/xactor/man/task_status.md +82 -0
  101. package/python/xactor/man/tasks.md +47 -0
  102. package/python/xactor/man/todo_write.md +104 -0
  103. package/python/xactor/man/update_card.md +70 -0
  104. package/python/xactor/man/use_profile.md +90 -0
  105. package/python/xactor/man/use_widget.md +82 -0
  106. package/python/xactor/man/wait_for_task.md +105 -0
  107. package/python/xactor/man/widget-model.md +284 -0
  108. package/python/xactor/man/widgets.md +59 -0
  109. package/python/xactor/man/write_file.md +76 -0
  110. package/python/xactor/man/writing-a-widget.md +142 -0
  111. package/python/xactor/man/xylon.md +120 -0
  112. package/python/xactor/man/xylon_blame.md +80 -0
  113. package/python/xactor/man/xylon_read.md +131 -0
  114. package/python/xactor/man/xylon_search.md +125 -0
  115. package/python/xactor/manual.py +195 -0
  116. package/python/xactor/parent_watch.py +115 -0
  117. package/python/xactor/plan_mode.py +350 -0
  118. package/python/xactor/sandbox.py +856 -0
  119. package/python/xactor/seals.py +438 -0
  120. package/python/xactor/shell_hints.py +210 -0
  121. package/python/xactor/states/__init__.py +13 -0
  122. package/python/xactor/states/base.py +523 -0
  123. package/python/xactor/states/registry.py +274 -0
  124. package/python/xactor/stdlib_api.py +6345 -0
  125. package/python/xactor/widgets/__init__.py +1 -0
  126. package/python/xactor/widgets/github_tokens.py +314 -0
  127. package/python/xactor/widgets/google_aistudio.py +365 -0
  128. package/settings-schema.json +403 -0
  129. package/viewer/index.css +160 -0
  130. package/viewer/index.js +15 -0
  131. package/wgx.mjs +290659 -0
@@ -0,0 +1,523 @@
1
+ """Core Layer 2 classes: Observation, Step, Transition, PageState, LoadingState."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from ..api import ElementaryAPI
10
+ from ..dom import DOMSnapshot
11
+
12
+
13
+ class Observation:
14
+ """Declarative DOM data extraction.
15
+
16
+ Two modes:
17
+ - Structured: selector + extract (attribute name or list) → compiled to JS
18
+ - Raw: js expression evaluated directly
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ *,
25
+ description: str = "",
26
+ js: str | None = None,
27
+ selector: str | None = None,
28
+ extract: str | list[str] | None = None,
29
+ ) -> None:
30
+ self.name = name
31
+ self.description = description
32
+ self._js = js
33
+ self.selector = selector
34
+ self.extract = extract
35
+
36
+ if js is None and selector is None:
37
+ raise ValueError(f"Observation '{name}' needs either 'js' or 'selector'")
38
+ if js is not None and selector is not None:
39
+ raise ValueError(f"Observation '{name}' cannot have both 'js' and 'selector'")
40
+ if selector is not None and re.match(r'^[a-z]+"[^"]*"', selector):
41
+ # `button"Create Token"` is a Layer 0 path, not CSS; interpolated
42
+ # into querySelector it throws a SyntaxError in the page, at run
43
+ # time. Refuse it where the author can still see it.
44
+ raise ValueError(
45
+ f"Observation '{name}': selector {selector!r} looks like a path "
46
+ f"(role\"name\"), but Observation.selector is CSS. Use a CSS "
47
+ f"selector, or js= with an explicit query."
48
+ )
49
+
50
+ def compile_js(self) -> str:
51
+ """Compile this observation to a JavaScript expression."""
52
+ if self._js is not None:
53
+ return self._js
54
+
55
+ sel = self.selector
56
+ assert sel is not None
57
+
58
+ if isinstance(self.extract, list):
59
+ # Multiple attributes from all matches
60
+ attrs = self.extract
61
+ props = ", ".join(
62
+ f"{a}: el.getAttribute('{a}') ?? el.textContent?.trim() ?? null"
63
+ if a == "textContent"
64
+ else f"{a}: el.getAttribute('{a}') ?? null"
65
+ for a in attrs
66
+ )
67
+ return (
68
+ f"Array.from(document.querySelectorAll('{sel}')).map(el => "
69
+ f"({{ {props} }}))"
70
+ )
71
+ elif isinstance(self.extract, str):
72
+ # Single attribute from first match
73
+ attr = self.extract
74
+ if attr == "textContent":
75
+ return f"document.querySelector('{sel}')?.textContent?.trim() ?? null"
76
+ return f"document.querySelector('{sel}')?.getAttribute('{attr}') ?? document.querySelector('{sel}')?.textContent?.trim() ?? null"
77
+ else:
78
+ # No extract specified — return textContent of first match
79
+ return f"document.querySelector('{sel}')?.textContent?.trim() ?? null"
80
+
81
+ async def run(self, api: ElementaryAPI) -> Any:
82
+ """Execute this observation via the bridge."""
83
+ js = self.compile_js()
84
+ return await api.evaluate(js)
85
+
86
+
87
+ # ── Legacy target bridge ──────────────────────────────────────────────
88
+ #
89
+ # Widgets written against the old engine name elements the way that engine
90
+ # resolved them: by visible text, by a bare tag, by a CSS selector. Layer 0 no
91
+ # longer guesses -- it takes a uid or a path -- so those targets would resolve to
92
+ # nothing.
93
+ #
94
+ # Rather than let ~90 steps across the shipped widgets fail one at a time, this
95
+ # translates a legacy target into an explicit *search*. That is a deliberate
96
+ # choice about where the guessing lives: Layer 0 stays strict, and Layer 2 opts
97
+ # in visibly, under the same ranking and margin an agent would get.
98
+ #
99
+ # It is a bridge, not a destination. A rewritten widget should name paths from
100
+ # api.search() and skip all of this.
101
+
102
+ # `focus` is here because it takes a target and so needs the same legacy-target
103
+ # translation the others do. `press` is deliberately absent: its first argument
104
+ # is a chord, not an element, and putting it through the target bridge would
105
+ # have "Enter" resolved as a description of something on the page.
106
+ _HUMANISED = frozenset({
107
+ "click", "type_text", "clear_and_type", "hover", "scroll_to", "select",
108
+ "focus",
109
+ })
110
+
111
+ # What the caller is plainly aiming at, when the verb says so.
112
+ _ROLE_FOR_VERB = {
113
+ "type_text": "textbox",
114
+ "clear_and_type": "textbox",
115
+ "select": "combobox",
116
+ }
117
+
118
+ _TAG_ROLE = {
119
+ "button": "button", "a": "link", "input": "textbox", "textarea": "textbox",
120
+ "select": "combobox", "table": "table", "tr": "row", "li": "listitem",
121
+ "ul": "list", "ol": "list", "dialog": "dialog", "form": "form", "nav": "navigation",
122
+ }
123
+
124
+ _STEP_VERB_ALIASES = {"clear_type": "clear_and_type", "type": "type_text"}
125
+
126
+ _UID_RE = re.compile(r"^[0-9a-f]{6}(~\d+)?$")
127
+ _BARE_TAG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
128
+ _ATTR_RE = re.compile(r"""^\[([\w-]+)\s*=\s*["']?([^"'\]]+)["']?\]$""")
129
+
130
+
131
+ def _is_new_style(target: str) -> bool:
132
+ """A uid, or a path. Paths carry a separator, a quoted name, or a #id/@testid.
133
+
134
+ The leading-bracket test comes first and is not redundant: a CSS attribute
135
+ selector like ``[data-testid="x"]`` contains a double quote, which would
136
+ otherwise read as a path's quoted name. A real path never *starts* with a
137
+ bracket -- the index bracket only ever follows a role or a discriminator.
138
+ """
139
+ if target.startswith("["):
140
+ return False
141
+ return bool(
142
+ _UID_RE.match(target)
143
+ or "/" in target
144
+ or '"' in target
145
+ or target.startswith("#")
146
+ or target.startswith("@")
147
+ )
148
+
149
+
150
+ def legacy_target(action: str, selector: Any, kwargs: dict[str, Any]) -> Any:
151
+ """Translate an old-style target into something Layer 0 accepts.
152
+
153
+ Only for the humanised verbs. ``dom_click``, ``dom_fill``,
154
+ ``wait_for_element`` and ``extract_secret`` still take CSS directly, which is
155
+ why the three console widgets authored against them need no translation at
156
+ all.
157
+ """
158
+ if action not in _HUMANISED or not isinstance(selector, str) or not selector.strip():
159
+ return selector
160
+
161
+ s = selector.strip()
162
+ if _is_new_style(s):
163
+ return s
164
+
165
+ spec: dict[str, Any] = {}
166
+ attr_match = _ATTR_RE.match(s)
167
+ if attr_match:
168
+ spec["attr"] = {attr_match.group(1): attr_match.group(2)}
169
+ role = _ROLE_FOR_VERB.get(action)
170
+ if role:
171
+ spec["role"] = role
172
+ elif _BARE_TAG_RE.match(s):
173
+ # A bare tag was always "the only one of these on the page".
174
+ spec["role"] = _TAG_ROLE.get(s, s)
175
+ else:
176
+ # Visible text. Ranked rather than matched exactly, because the old
177
+ # engine did a substring walk and the accessible name the new one
178
+ # computes is not always character-identical to it.
179
+ spec["like"] = s
180
+ role = _ROLE_FOR_VERB.get(action)
181
+ if role:
182
+ spec["role"] = role
183
+
184
+ # `container=` scoped a lookup; `under=` is the same idea by its current name.
185
+ container = kwargs.pop("container", None)
186
+ if container:
187
+ spec["under"] = container
188
+
189
+ return spec
190
+
191
+
192
+ def _search_kw(spec: dict[str, Any]) -> dict[str, Any]:
193
+ """Turn a wire-form spec back into ``api.search`` keyword arguments."""
194
+ out: dict[str, Any] = {}
195
+ rename = {"textExact": "text_exact", "inView": "in_view",
196
+ "leftOf": "left_of", "rightOf": "right_of",
197
+ "inRow": "in_row", "inSection": "in_section",
198
+ "includeInert": "include_inert"}
199
+ for k, v in spec.items():
200
+ out[rename.get(k, k)] = v
201
+ return out
202
+
203
+
204
+ #: `$name` as it appears in a Step's target or kwargs. Bounded by the same
205
+ #: character class `_sub` replaces on, so what is detected and what is
206
+ #: substituted cannot disagree.
207
+ _PARAM_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
208
+
209
+
210
+ class Step:
211
+ """A single Layer 1 action within a transition."""
212
+
213
+ def __init__(
214
+ self,
215
+ action: str,
216
+ selector: str,
217
+ expects_transition: bool = False,
218
+ *,
219
+ when: str | None = None,
220
+ **kwargs: Any,
221
+ ) -> None:
222
+ # The wire names `type` and `clear_type` are not the verbs on `api`
223
+ # (`type_text`, `clear_and_type`); the man pages once taught the wire
224
+ # name, and a step so spelled failed only when finally dispatched.
225
+ self.action = _STEP_VERB_ALIASES.get(action, action)
226
+ self.selector = selector
227
+ self.expects_transition = expects_transition
228
+ #: Run this step only if the caller supplied this optional parameter.
229
+ #:
230
+ #: Skipping by `$ref` alone is not enough for a real form. Setting one
231
+ #: react-select takes three steps — focus, fill, Enter — and only the
232
+ #: middle one mentions the value. Leave the other two unconditional and
233
+ #: omitting the argument still opens the menu and presses Enter on it,
234
+ #: which commits whatever was highlighted. `when` ties the whole group
235
+ #: to one argument, so "not supplied" means the field is genuinely
236
+ #: untouched.
237
+ #:
238
+ #: Keyword-only, so it cannot be mistaken for the legacy third
239
+ #: positional (`expects_transition`).
240
+ self.when = when
241
+ self.kwargs = kwargs
242
+
243
+ def resolve(self, params: dict[str, Any]) -> tuple[str, str, dict[str, Any]]:
244
+ """Resolve $param references, returning (action, selector, kwargs)."""
245
+ selector = self._sub(self.selector, params)
246
+ kwargs = {k: self._sub(v, params) for k, v in self.kwargs.items()}
247
+ return self.action, selector, kwargs
248
+
249
+ def param_refs(self) -> set[str]:
250
+ """Every `$name` this step substitutes, in its target or its kwargs.
251
+
252
+ Used to decide whether a step is asking for a parameter the caller
253
+ chose not to supply, which is how an optional field is left at whatever
254
+ the page already had — see `Transition.optional`.
255
+ """
256
+ refs: set[str] = set()
257
+ for value in (self.selector, *self.kwargs.values()):
258
+ if not isinstance(value, str):
259
+ continue
260
+ for token in _PARAM_RE.findall(value):
261
+ refs.add(token)
262
+ return refs
263
+
264
+ @staticmethod
265
+ def _sub(value: Any, params: dict[str, Any]) -> Any:
266
+ if not isinstance(value, str):
267
+ return value
268
+ # Whole-string param ref: "$param" → params["param"] (preserves type)
269
+ if value.startswith("$") and value[1:] in params:
270
+ return params[value[1:]]
271
+ # Inline interpolation: replace $param within larger strings
272
+ if "$" in value and params:
273
+ result = value
274
+ for key, val in params.items():
275
+ result = result.replace(f"${key}", str(val))
276
+ return result
277
+ return value
278
+
279
+
280
+ class Transition:
281
+ """A named sequence of Layer 1 steps causing a page change."""
282
+
283
+ def __init__(
284
+ self,
285
+ name: str,
286
+ steps: list[Step],
287
+ *,
288
+ params: dict[str, str] | None = None,
289
+ optional: dict[str, str] | None = None,
290
+ description: str = "",
291
+ ) -> None:
292
+ self.name = name
293
+ self.description = description
294
+ self.params = params or {}
295
+ #: Parameters the caller may omit, name -> type, for a form field that
296
+ #: should keep whatever the page already had.
297
+ #:
298
+ #: A configure-style transition wants to set the two things you care
299
+ #: about and leave the other six alone. Without this every parameter is
300
+ #: required (`execute` rejects a missing one), so the only way to leave
301
+ #: a field at its default was a separate transition per combination.
302
+ #:
303
+ #: A step that substitutes an omitted optional parameter is *skipped* —
304
+ #: not run with an empty string, which would clear the field rather
305
+ #: than leave it. See `PageState.execute`.
306
+ self.optional = optional or {}
307
+ self.steps = steps
308
+
309
+
310
+ class PageState:
311
+ """A page condition and its action space.
312
+
313
+ Constructed with configuration, bound to a DOM snapshot + API at match time.
314
+ """
315
+
316
+ def __init__(
317
+ self,
318
+ name: str,
319
+ *,
320
+ url_pattern: str = ".*",
321
+ entry: str | None = None,
322
+ keywords: list[str] | None = None,
323
+ observations: list[Observation] | None = None,
324
+ transitions: list[Transition] | None = None,
325
+ expected_timeout: float | None = None,
326
+ ) -> None:
327
+ self.name = name
328
+ self.url_pattern = url_pattern
329
+ # A real URL you can navigate to, as opposed to url_pattern, which is a
330
+ # matcher: r"vercel\.com/[^/]+$" identifies the dashboard but cannot be
331
+ # visited. Without this a widget can only be *recognised* once you are
332
+ # already somewhere, never entered cold — which is why opening a browser
333
+ # at a hand-written URL had to come first.
334
+ #
335
+ # Optional, and most states should leave it unset: "CreateTokenDialog"
336
+ # is reached by acting, not by navigating.
337
+ self.entry = entry
338
+ self.keywords = keywords or []
339
+ self.observations = {o.name: o for o in (observations or [])}
340
+ self.transitions = {t.name: t for t in (transitions or [])}
341
+ self.expected_timeout = expected_timeout # seconds; None = use defaults
342
+
343
+ self._dom: DOMSnapshot | None = None
344
+ self._api: ElementaryAPI | None = None
345
+ self._compiled_url: re.Pattern[str] = re.compile(url_pattern)
346
+
347
+ def matches_url(self, url: str) -> bool:
348
+ """Check if this state's url_pattern matches the given URL."""
349
+ return bool(self._compiled_url.search(url))
350
+
351
+ def bind(self, dom: DOMSnapshot, api: ElementaryAPI) -> PageState:
352
+ """Create a bound copy with DOM/API refs."""
353
+ bound = PageState.__new__(PageState)
354
+ bound.name = self.name
355
+ bound.url_pattern = self.url_pattern
356
+ bound.entry = self.entry
357
+ bound.keywords = self.keywords
358
+ bound.observations = self.observations
359
+ bound.transitions = self.transitions
360
+ bound.expected_timeout = self.expected_timeout
361
+ bound._dom = dom
362
+ bound._api = api
363
+ bound._compiled_url = self._compiled_url
364
+ return bound
365
+
366
+ def describe(self) -> dict[str, Any]:
367
+ """Serializable summary for Claude."""
368
+ desc: dict[str, Any] = {
369
+ "name": self.name,
370
+ "url_pattern": self.url_pattern,
371
+ "entry": self.entry,
372
+ "keywords": self.keywords,
373
+ "observations": [
374
+ {"name": o.name, "description": o.description}
375
+ for o in self.observations.values()
376
+ ],
377
+ "transitions": [
378
+ {
379
+ "name": t.name,
380
+ "description": t.description,
381
+ "params": t.params,
382
+ # Advertised separately from `params`, and only when there
383
+ # are any. `describe()` is how an agent learns what a
384
+ # transition accepts, so an optional parameter it cannot
385
+ # see is one it will never pass — and the field silently
386
+ # keeps the page's default while the caller believes it
387
+ # asked for something.
388
+ **({"optional": t.optional} if t.optional else {}),
389
+ }
390
+ for t in self.transitions.values()
391
+ ],
392
+ }
393
+ if self.expected_timeout is not None:
394
+ desc["expected_timeout"] = self.expected_timeout
395
+ return desc
396
+
397
+ async def observe(self, name: str) -> Any:
398
+ """Execute an observation (live DOM query via bridge)."""
399
+ if self._api is None:
400
+ raise RuntimeError("PageState not bound — call bind() first")
401
+ obs = self.observations.get(name)
402
+ if obs is None:
403
+ available = list(self.observations.keys())
404
+ raise KeyError(f"No observation '{name}'. Available: {available}")
405
+ return await obs.run(self._api)
406
+
407
+ async def execute(self, name: str, /, **params: Any) -> PageState:
408
+ """Run transition steps, resolve new state.
409
+
410
+ `name` is positional-only, and that slash is load-bearing. Without it a
411
+ transition could never have a parameter called `name` — the most
412
+ obvious word for the field on any create form — because
413
+ ``execute("name_it", name="x")`` bound both to the same argument and
414
+ raised "got multiple values for argument 'name'". Nothing caught it at
415
+ authoring time: the Transition constructed, the widget loaded,
416
+ `describe()` listed the parameter, and it failed only when finally run,
417
+ as an arity error naming neither the widget nor the transition.
418
+ """
419
+ if self._api is None:
420
+ raise RuntimeError("PageState not bound — call bind() first")
421
+
422
+ transition = self.transitions.get(name)
423
+ if transition is None:
424
+ available = list(self.transitions.keys())
425
+ raise KeyError(f"No transition '{name}'. Available: {available}")
426
+
427
+ # Validate params
428
+ for pname in transition.params:
429
+ if pname not in params:
430
+ raise ValueError(f"Missing required parameter: {pname}")
431
+
432
+ unknown = set(params) - set(transition.params) - set(transition.optional)
433
+ if unknown:
434
+ # Caught here rather than ignored, because a typo in an optional
435
+ # name is otherwise indistinguishable from deliberately leaving the
436
+ # field alone — the step is skipped and the transition reports
437
+ # success having not done the thing you asked for.
438
+ raise ValueError(
439
+ f"Unknown parameter(s) for '{name}': {sorted(unknown)}. "
440
+ f"Accepts {sorted(transition.params)} "
441
+ f"and optionally {sorted(transition.optional)}."
442
+ )
443
+
444
+ # Execute steps.
445
+ #
446
+ # Every L1 verb now returns an ActionRecord dict rather than sometimes a
447
+ # snapshot and sometimes None, so there is no longer a value to inspect
448
+ # here to decide whether the page moved. A step that fails raises, which
449
+ # is the point: a transition that half-ran must not report the state it
450
+ # was aiming for.
451
+ for step in transition.steps:
452
+ # A step that needs an omitted optional parameter is skipped, which
453
+ # is what "defaults to whatever the form had" means. Substituting
454
+ # an empty string instead would *clear* the field — the opposite of
455
+ # leaving it alone, and silently so.
456
+ if step.when is not None and step.when not in params:
457
+ continue
458
+ missing = {r for r in step.param_refs() if r not in params}
459
+ if missing and missing <= set(transition.optional):
460
+ continue
461
+
462
+ action, selector, kwargs = step.resolve(params)
463
+ method = getattr(self._api, action, None)
464
+ if method is None:
465
+ raise AttributeError(f"ElementaryAPI has no method '{action}'")
466
+
467
+ # `expects_transition` was removed from Layer 0 -- readiness is now
468
+ # intrinsic to each action, so the flag has nothing to select. It is
469
+ # dropped rather than forwarded so an old widget still runs.
470
+ kwargs.pop("expects_transition", None)
471
+
472
+ # `index=` meant "the nth thing matching this". There is no nth in a
473
+ # ranked search, so it costs a lookup: find the candidates, take the
474
+ # one asked for, act on its uid. Slower than a path by one round
475
+ # trip -- which is the price of the old spelling, and the reason a
476
+ # rewritten widget should name the row instead.
477
+ index = kwargs.pop("index", None)
478
+ target = legacy_target(action, selector, kwargs)
479
+
480
+ if index and isinstance(target, dict):
481
+ matches = await self._api.search(**_search_kw(target))
482
+ if len(matches) <= index:
483
+ raise RuntimeError(
484
+ f"transition '{name}' step '{action}' wanted match #{index} of "
485
+ f"{selector!r}, but only {len(matches)} matched"
486
+ )
487
+ target = matches[index].as_target()
488
+ elif index:
489
+ raise RuntimeError(
490
+ f"transition '{name}' step '{action}' used index={index} with a "
491
+ f"path target ({selector!r}); scope the path instead"
492
+ )
493
+
494
+ record = await method(target, **kwargs)
495
+ if isinstance(record, dict) and record.get("ok") is False:
496
+ raise RuntimeError(
497
+ f"transition '{name}' failed at step '{action}' on {selector!r}: "
498
+ f"{record.get('outcome')}"
499
+ + (f" -- {record['error']}" if record.get("error") else "")
500
+ )
501
+
502
+ # Where did that leave us? Settle, then re-resolve.
503
+ loading = LoadingState(self.name, name, timeout=self.expected_timeout)
504
+ return await loading.wait_until_ready(self._api)
505
+
506
+
507
+ class LoadingState:
508
+ """Inert halt between transitions.
509
+
510
+ All it does is wait_until_ready(api) → PageState.
511
+ """
512
+
513
+ def __init__(self, previous_name: str, trigger: str, timeout: float | None = None) -> None:
514
+ self.previous_name = previous_name
515
+ self.trigger = trigger
516
+ self.timeout = timeout # seconds; None = default 30s
517
+
518
+ async def wait_until_ready(self, api: ElementaryAPI) -> PageState:
519
+ """Wait for the page to settle, then resolve to a new PageState."""
520
+ timeout_ms = int(self.timeout * 1000) if self.timeout else None
521
+ await api.wait_stable(timeout_ms=timeout_ms)
522
+ from .registry import StateRegistry
523
+ return await StateRegistry.here(api)