browser-control-and-automation-cli 0.1.1.dev3__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.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Semantic ref resolver adapted from bridgic-browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from browser_cli.refs.models import LocatorSpec, RefData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SemanticRefResolver:
|
|
12
|
+
INTERACTIVE_ROLES = {
|
|
13
|
+
"application",
|
|
14
|
+
"alertdialog",
|
|
15
|
+
"button",
|
|
16
|
+
"checkbox",
|
|
17
|
+
"combobox",
|
|
18
|
+
"dialog",
|
|
19
|
+
"grid",
|
|
20
|
+
"gridcell",
|
|
21
|
+
"link",
|
|
22
|
+
"listbox",
|
|
23
|
+
"menu",
|
|
24
|
+
"menubar",
|
|
25
|
+
"menuitem",
|
|
26
|
+
"menuitemcheckbox",
|
|
27
|
+
"menuitemradio",
|
|
28
|
+
"option",
|
|
29
|
+
"progressbar",
|
|
30
|
+
"radio",
|
|
31
|
+
"radiogroup",
|
|
32
|
+
"scrollbar",
|
|
33
|
+
"search",
|
|
34
|
+
"searchbox",
|
|
35
|
+
"slider",
|
|
36
|
+
"spinbutton",
|
|
37
|
+
"switch",
|
|
38
|
+
"tab",
|
|
39
|
+
"tablist",
|
|
40
|
+
"tabpanel",
|
|
41
|
+
"textbox",
|
|
42
|
+
"tree",
|
|
43
|
+
"treegrid",
|
|
44
|
+
"treeitem",
|
|
45
|
+
}
|
|
46
|
+
ROLE_TEXT_MATCH_ROLES = {
|
|
47
|
+
"cell",
|
|
48
|
+
"columnheader",
|
|
49
|
+
"gridcell",
|
|
50
|
+
"listitem",
|
|
51
|
+
"row",
|
|
52
|
+
"rowheader",
|
|
53
|
+
}
|
|
54
|
+
STRUCTURAL_NOISE_ROLES = {"generic", "group", "none", "presentation"}
|
|
55
|
+
TEXT_LEAF_ROLES = {"text"}
|
|
56
|
+
STRUCTURAL_NOISE_CSS = {
|
|
57
|
+
"generic": 'div:not([role]), legend, [role="generic"]',
|
|
58
|
+
"group": 'fieldset, details, optgroup, [role="group"]',
|
|
59
|
+
"none": '[role="none"]',
|
|
60
|
+
"presentation": '[role="presentation"]',
|
|
61
|
+
}
|
|
62
|
+
STRUCTURAL_NOISE_CSS_NAMED = {
|
|
63
|
+
**STRUCTURAL_NOISE_CSS,
|
|
64
|
+
"generic": 'div:not([role]), span:not([role]), legend, [role="generic"]',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def parse_ref(arg: str) -> str | None:
|
|
69
|
+
raw = arg.strip()
|
|
70
|
+
if raw.startswith("@"):
|
|
71
|
+
raw = raw[1:]
|
|
72
|
+
elif raw.startswith("ref="):
|
|
73
|
+
raw = raw[4:]
|
|
74
|
+
if re.fullmatch(r"[0-9a-fA-F]{8}", raw):
|
|
75
|
+
return raw.lower()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _text_pattern(value: str, *, exact: bool) -> re.Pattern[str]:
|
|
80
|
+
parts = [re.escape(part) for part in re.split(r"\s+", value.strip()) if part]
|
|
81
|
+
joined = r"\s+".join(parts) if parts else ""
|
|
82
|
+
if exact:
|
|
83
|
+
return re.compile(rf"^\s*{joined}\s*$")
|
|
84
|
+
return re.compile(joined)
|
|
85
|
+
|
|
86
|
+
def get_locator(
|
|
87
|
+
self,
|
|
88
|
+
page: Any,
|
|
89
|
+
ref_arg: str,
|
|
90
|
+
refs: dict[str, RefData],
|
|
91
|
+
) -> Any | None:
|
|
92
|
+
locator_spec = self.build_locator_spec(ref_arg, refs)
|
|
93
|
+
if locator_spec is None:
|
|
94
|
+
return None
|
|
95
|
+
return self.get_locator_from_spec(page, locator_spec)
|
|
96
|
+
|
|
97
|
+
def get_locator_from_spec(
|
|
98
|
+
self,
|
|
99
|
+
page: Any,
|
|
100
|
+
locator_spec: LocatorSpec,
|
|
101
|
+
) -> Any | None:
|
|
102
|
+
if not locator_spec.role:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
normalized_name = (
|
|
106
|
+
locator_spec.name.strip() if locator_spec.name and locator_spec.name.strip() else None
|
|
107
|
+
)
|
|
108
|
+
normalized_text = (
|
|
109
|
+
locator_spec.text_content.strip()
|
|
110
|
+
if locator_spec.text_content and locator_spec.text_content.strip()
|
|
111
|
+
else None
|
|
112
|
+
)
|
|
113
|
+
match_text = locator_spec.match_text
|
|
114
|
+
child_text = locator_spec.child_text
|
|
115
|
+
|
|
116
|
+
scope: Any = page
|
|
117
|
+
if locator_spec.frame_path:
|
|
118
|
+
for local_nth in locator_spec.frame_path:
|
|
119
|
+
scope = scope.frame_locator("iframe").nth(local_nth)
|
|
120
|
+
|
|
121
|
+
skip_nth = False
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
normalized_name
|
|
125
|
+
and locator_spec.role not in self.ROLE_TEXT_MATCH_ROLES
|
|
126
|
+
and locator_spec.role not in self.STRUCTURAL_NOISE_ROLES
|
|
127
|
+
and locator_spec.role not in self.TEXT_LEAF_ROLES
|
|
128
|
+
):
|
|
129
|
+
locator = scope.get_by_role(locator_spec.role, name=normalized_name, exact=True)
|
|
130
|
+
elif locator_spec.role in self.ROLE_TEXT_MATCH_ROLES and match_text:
|
|
131
|
+
if locator_spec.role == "row":
|
|
132
|
+
locator = scope.get_by_role("row").filter(
|
|
133
|
+
has_text=self._text_pattern(match_text, exact=False)
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
locator = scope.get_by_role(locator_spec.role).filter(
|
|
137
|
+
has_text=self._text_pattern(match_text, exact=True)
|
|
138
|
+
)
|
|
139
|
+
elif locator_spec.role in self.TEXT_LEAF_ROLES and match_text:
|
|
140
|
+
locator = scope.get_by_text(match_text, exact=True)
|
|
141
|
+
skip_nth = True
|
|
142
|
+
elif locator_spec.role in self.STRUCTURAL_NOISE_ROLES and match_text:
|
|
143
|
+
css = self.STRUCTURAL_NOISE_CSS.get(locator_spec.role)
|
|
144
|
+
if css:
|
|
145
|
+
locator = scope.locator(css).filter(
|
|
146
|
+
has_text=self._text_pattern(match_text, exact=True)
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
locator = scope.get_by_text(match_text, exact=True)
|
|
150
|
+
skip_nth = True
|
|
151
|
+
elif locator_spec.role in self.STRUCTURAL_NOISE_ROLES:
|
|
152
|
+
if child_text:
|
|
153
|
+
css = self.STRUCTURAL_NOISE_CSS.get(locator_spec.role)
|
|
154
|
+
if css:
|
|
155
|
+
locator = scope.locator(css).filter(
|
|
156
|
+
has_text=self._text_pattern(child_text, exact=True)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
locator = scope.get_by_text(child_text, exact=True)
|
|
160
|
+
skip_nth = True
|
|
161
|
+
else:
|
|
162
|
+
locator = scope.get_by_role(locator_spec.role)
|
|
163
|
+
elif normalized_text:
|
|
164
|
+
locator = scope.get_by_text(normalized_text, exact=True)
|
|
165
|
+
skip_nth = True
|
|
166
|
+
else:
|
|
167
|
+
locator = scope.get_by_role(locator_spec.role, name=re.compile(r"^$"))
|
|
168
|
+
|
|
169
|
+
if not skip_nth and locator_spec.nth is not None:
|
|
170
|
+
locator = locator.nth(locator_spec.nth)
|
|
171
|
+
return locator
|
|
172
|
+
|
|
173
|
+
def build_locator_spec(self, ref_arg: str, refs: dict[str, RefData]) -> LocatorSpec | None:
|
|
174
|
+
ref = self.parse_ref(ref_arg)
|
|
175
|
+
if not ref:
|
|
176
|
+
return None
|
|
177
|
+
ref_data = refs.get(ref)
|
|
178
|
+
if ref_data is None:
|
|
179
|
+
return None
|
|
180
|
+
normalized_name = ref_data.name.strip() if ref_data.name and ref_data.name.strip() else None
|
|
181
|
+
normalized_text = (
|
|
182
|
+
ref_data.text_content.strip()
|
|
183
|
+
if ref_data.text_content and ref_data.text_content.strip()
|
|
184
|
+
else None
|
|
185
|
+
)
|
|
186
|
+
match_text = normalized_name or normalized_text
|
|
187
|
+
child_text = None
|
|
188
|
+
if ref_data.role in self.STRUCTURAL_NOISE_ROLES:
|
|
189
|
+
child_text = next(
|
|
190
|
+
(
|
|
191
|
+
(child.name or child.text_content or "").strip()
|
|
192
|
+
for child in refs.values()
|
|
193
|
+
if child.parent_ref == ref
|
|
194
|
+
and (
|
|
195
|
+
child.role in self.TEXT_LEAF_ROLES
|
|
196
|
+
or child.role in self.STRUCTURAL_NOISE_ROLES
|
|
197
|
+
)
|
|
198
|
+
and (child.name or child.text_content)
|
|
199
|
+
),
|
|
200
|
+
None,
|
|
201
|
+
)
|
|
202
|
+
return LocatorSpec(
|
|
203
|
+
ref=ref,
|
|
204
|
+
role=ref_data.role,
|
|
205
|
+
name=normalized_name,
|
|
206
|
+
text_content=normalized_text,
|
|
207
|
+
match_text=match_text,
|
|
208
|
+
child_text=child_text,
|
|
209
|
+
nth=ref_data.nth,
|
|
210
|
+
tag=ref_data.tag,
|
|
211
|
+
interactive=ref_data.interactive,
|
|
212
|
+
frame_path=ref_data.frame_path,
|
|
213
|
+
selector_recipe=ref_data.selector_recipe,
|
|
214
|
+
)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Agent-scoped tab ownership and conflict tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from browser_cli.errors import BusyTabError, NoActiveTabError, TabNotFoundError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class BusyState:
|
|
16
|
+
request_id: str
|
|
17
|
+
command: str
|
|
18
|
+
agent_id: str
|
|
19
|
+
started_at: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class TabRecord:
|
|
24
|
+
page_id: str
|
|
25
|
+
owner_agent_id: str
|
|
26
|
+
created_at: float
|
|
27
|
+
last_used_at: float
|
|
28
|
+
url: str = ""
|
|
29
|
+
title: str = ""
|
|
30
|
+
busy: BusyState | None = None
|
|
31
|
+
last_snapshot_refs: set[str] = field(default_factory=set)
|
|
32
|
+
last_snapshot_id: str | None = None
|
|
33
|
+
last_snapshot_ref_count: int = 0
|
|
34
|
+
last_snapshot_url: str = ""
|
|
35
|
+
last_snapshot_at: float | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TabRegistry:
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._tabs: dict[str, TabRecord] = {}
|
|
41
|
+
self._active_tab_by_agent: dict[str, str] = {}
|
|
42
|
+
self._lock = asyncio.Lock()
|
|
43
|
+
|
|
44
|
+
async def add_tab(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
page_id: str,
|
|
48
|
+
owner_agent_id: str,
|
|
49
|
+
url: str = "",
|
|
50
|
+
title: str = "",
|
|
51
|
+
) -> TabRecord:
|
|
52
|
+
now = time.time()
|
|
53
|
+
async with self._lock:
|
|
54
|
+
record = TabRecord(
|
|
55
|
+
page_id=page_id,
|
|
56
|
+
owner_agent_id=owner_agent_id,
|
|
57
|
+
created_at=now,
|
|
58
|
+
last_used_at=now,
|
|
59
|
+
url=url,
|
|
60
|
+
title=title,
|
|
61
|
+
)
|
|
62
|
+
self._tabs[page_id] = record
|
|
63
|
+
self._active_tab_by_agent[owner_agent_id] = page_id
|
|
64
|
+
return record
|
|
65
|
+
|
|
66
|
+
async def update_tab(
|
|
67
|
+
self,
|
|
68
|
+
page_id: str,
|
|
69
|
+
*,
|
|
70
|
+
url: str | None = None,
|
|
71
|
+
title: str | None = None,
|
|
72
|
+
last_snapshot_refs: set[str] | None = None,
|
|
73
|
+
last_snapshot_id: str | None = None,
|
|
74
|
+
last_snapshot_ref_count: int | None = None,
|
|
75
|
+
last_snapshot_url: str | None = None,
|
|
76
|
+
last_snapshot_at: float | None = None,
|
|
77
|
+
) -> TabRecord:
|
|
78
|
+
async with self._lock:
|
|
79
|
+
record = self._tabs.get(page_id)
|
|
80
|
+
if record is None:
|
|
81
|
+
raise TabNotFoundError()
|
|
82
|
+
record.last_used_at = time.time()
|
|
83
|
+
if url is not None:
|
|
84
|
+
record.url = url
|
|
85
|
+
if title is not None:
|
|
86
|
+
record.title = title
|
|
87
|
+
if last_snapshot_refs is not None:
|
|
88
|
+
record.last_snapshot_refs = set(last_snapshot_refs)
|
|
89
|
+
if last_snapshot_id is not None:
|
|
90
|
+
record.last_snapshot_id = last_snapshot_id
|
|
91
|
+
if last_snapshot_ref_count is not None:
|
|
92
|
+
record.last_snapshot_ref_count = last_snapshot_ref_count
|
|
93
|
+
if last_snapshot_url is not None:
|
|
94
|
+
record.last_snapshot_url = last_snapshot_url
|
|
95
|
+
if last_snapshot_at is not None:
|
|
96
|
+
record.last_snapshot_at = last_snapshot_at
|
|
97
|
+
return record
|
|
98
|
+
|
|
99
|
+
async def list_tabs(self, agent_id: str) -> list[TabRecord]:
|
|
100
|
+
async with self._lock:
|
|
101
|
+
active_page_id = self._active_tab_by_agent.get(agent_id)
|
|
102
|
+
visible = [
|
|
103
|
+
self._copy_record(record)
|
|
104
|
+
for record in self._tabs.values()
|
|
105
|
+
if record.owner_agent_id == agent_id
|
|
106
|
+
]
|
|
107
|
+
visible.sort(key=lambda item: item.created_at)
|
|
108
|
+
if active_page_id:
|
|
109
|
+
visible.sort(key=lambda item: item.page_id != active_page_id)
|
|
110
|
+
return visible
|
|
111
|
+
|
|
112
|
+
async def has_tabs(self, agent_id: str) -> bool:
|
|
113
|
+
async with self._lock:
|
|
114
|
+
return any(record.owner_agent_id == agent_id for record in self._tabs.values())
|
|
115
|
+
|
|
116
|
+
async def get_active_tab(self, agent_id: str) -> TabRecord:
|
|
117
|
+
async with self._lock:
|
|
118
|
+
page_id = self._active_tab_by_agent.get(agent_id)
|
|
119
|
+
if page_id is None:
|
|
120
|
+
raise NoActiveTabError()
|
|
121
|
+
record = self._tabs.get(page_id)
|
|
122
|
+
if record is None or record.owner_agent_id != agent_id:
|
|
123
|
+
raise NoActiveTabError()
|
|
124
|
+
return self._copy_record(record)
|
|
125
|
+
|
|
126
|
+
async def set_active_tab(self, agent_id: str, page_id: str) -> TabRecord:
|
|
127
|
+
async with self._lock:
|
|
128
|
+
record = self._tabs.get(page_id)
|
|
129
|
+
if record is None or record.owner_agent_id != agent_id:
|
|
130
|
+
raise TabNotFoundError()
|
|
131
|
+
self._active_tab_by_agent[agent_id] = page_id
|
|
132
|
+
record.last_used_at = time.time()
|
|
133
|
+
return self._copy_record(record)
|
|
134
|
+
|
|
135
|
+
async def get_tab(self, agent_id: str, page_id: str) -> TabRecord:
|
|
136
|
+
async with self._lock:
|
|
137
|
+
record = self._tabs.get(page_id)
|
|
138
|
+
if record is None or record.owner_agent_id != agent_id:
|
|
139
|
+
raise TabNotFoundError()
|
|
140
|
+
return self._copy_record(record)
|
|
141
|
+
|
|
142
|
+
async def remove_tab(self, agent_id: str, page_id: str) -> TabRecord:
|
|
143
|
+
async with self._lock:
|
|
144
|
+
record = self._tabs.get(page_id)
|
|
145
|
+
if record is None or record.owner_agent_id != agent_id:
|
|
146
|
+
raise TabNotFoundError()
|
|
147
|
+
removed = self._tabs.pop(page_id)
|
|
148
|
+
active_page_id = self._active_tab_by_agent.get(agent_id)
|
|
149
|
+
if active_page_id == page_id:
|
|
150
|
+
replacement = self._find_latest_page_id_locked(agent_id)
|
|
151
|
+
if replacement is None:
|
|
152
|
+
self._active_tab_by_agent.pop(agent_id, None)
|
|
153
|
+
else:
|
|
154
|
+
self._active_tab_by_agent[agent_id] = replacement
|
|
155
|
+
return self._copy_record(removed)
|
|
156
|
+
|
|
157
|
+
async def current_active_page_id(self, agent_id: str) -> str:
|
|
158
|
+
return (await self.get_active_tab(agent_id)).page_id
|
|
159
|
+
|
|
160
|
+
async def snapshot_state(self) -> tuple[list[TabRecord], dict[str, str]]:
|
|
161
|
+
async with self._lock:
|
|
162
|
+
records = [self._copy_record(record) for record in self._tabs.values()]
|
|
163
|
+
records.sort(key=lambda item: item.created_at)
|
|
164
|
+
return records, dict(self._active_tab_by_agent)
|
|
165
|
+
|
|
166
|
+
async def replace_tab_ids(self, replacements: dict[str, str]) -> None:
|
|
167
|
+
if not replacements:
|
|
168
|
+
return
|
|
169
|
+
async with self._lock:
|
|
170
|
+
new_tabs: dict[str, TabRecord] = {}
|
|
171
|
+
for old_page_id, record in list(self._tabs.items()):
|
|
172
|
+
new_page_id = replacements.get(old_page_id, old_page_id)
|
|
173
|
+
new_record = self._copy_record(record)
|
|
174
|
+
new_record.page_id = new_page_id
|
|
175
|
+
new_tabs[new_page_id] = new_record
|
|
176
|
+
self._tabs = new_tabs
|
|
177
|
+
for agent_id, page_id in list(self._active_tab_by_agent.items()):
|
|
178
|
+
self._active_tab_by_agent[agent_id] = replacements.get(page_id, page_id)
|
|
179
|
+
|
|
180
|
+
async def clear_snapshot_state(self) -> None:
|
|
181
|
+
async with self._lock:
|
|
182
|
+
for record in self._tabs.values():
|
|
183
|
+
record.last_snapshot_refs = set()
|
|
184
|
+
record.last_snapshot_id = None
|
|
185
|
+
record.last_snapshot_ref_count = 0
|
|
186
|
+
record.last_snapshot_url = ""
|
|
187
|
+
record.last_snapshot_at = None
|
|
188
|
+
|
|
189
|
+
async def clear(self) -> None:
|
|
190
|
+
async with self._lock:
|
|
191
|
+
self._tabs.clear()
|
|
192
|
+
self._active_tab_by_agent.clear()
|
|
193
|
+
|
|
194
|
+
async def is_visible(self, agent_id: str, page_id: str) -> bool:
|
|
195
|
+
async with self._lock:
|
|
196
|
+
record = self._tabs.get(page_id)
|
|
197
|
+
return bool(record and record.owner_agent_id == agent_id)
|
|
198
|
+
|
|
199
|
+
@asynccontextmanager
|
|
200
|
+
async def claim_active_tab(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
agent_id: str,
|
|
204
|
+
request_id: str,
|
|
205
|
+
command: str,
|
|
206
|
+
) -> AsyncIterator[TabRecord]:
|
|
207
|
+
page_id: str
|
|
208
|
+
async with self._lock:
|
|
209
|
+
page_id = self._active_tab_by_agent.get(agent_id, "")
|
|
210
|
+
if not page_id:
|
|
211
|
+
raise NoActiveTabError()
|
|
212
|
+
record = self._tabs.get(page_id)
|
|
213
|
+
if record is None or record.owner_agent_id != agent_id:
|
|
214
|
+
raise NoActiveTabError()
|
|
215
|
+
if record.busy and record.busy.request_id != request_id:
|
|
216
|
+
raise BusyTabError()
|
|
217
|
+
record.busy = BusyState(
|
|
218
|
+
request_id=request_id,
|
|
219
|
+
command=command,
|
|
220
|
+
agent_id=agent_id,
|
|
221
|
+
started_at=time.time(),
|
|
222
|
+
)
|
|
223
|
+
record.last_used_at = time.time()
|
|
224
|
+
claimed = self._copy_record(record)
|
|
225
|
+
try:
|
|
226
|
+
yield claimed
|
|
227
|
+
finally:
|
|
228
|
+
await self.release_tab(page_id=page_id, request_id=request_id)
|
|
229
|
+
|
|
230
|
+
async def claim_page(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
agent_id: str,
|
|
234
|
+
page_id: str,
|
|
235
|
+
request_id: str,
|
|
236
|
+
command: str,
|
|
237
|
+
) -> TabRecord:
|
|
238
|
+
async with self._lock:
|
|
239
|
+
record = self._tabs.get(page_id)
|
|
240
|
+
if record is None or record.owner_agent_id != agent_id:
|
|
241
|
+
raise TabNotFoundError()
|
|
242
|
+
if record.busy and record.busy.request_id != request_id:
|
|
243
|
+
raise BusyTabError()
|
|
244
|
+
record.busy = BusyState(
|
|
245
|
+
request_id=request_id,
|
|
246
|
+
command=command,
|
|
247
|
+
agent_id=agent_id,
|
|
248
|
+
started_at=time.time(),
|
|
249
|
+
)
|
|
250
|
+
record.last_used_at = time.time()
|
|
251
|
+
return self._copy_record(record)
|
|
252
|
+
|
|
253
|
+
async def release_tab(self, *, page_id: str, request_id: str) -> None:
|
|
254
|
+
async with self._lock:
|
|
255
|
+
record = self._tabs.get(page_id)
|
|
256
|
+
if record is None:
|
|
257
|
+
return
|
|
258
|
+
if record.busy and record.busy.request_id == request_id:
|
|
259
|
+
record.busy = None
|
|
260
|
+
|
|
261
|
+
def _find_latest_page_id_locked(self, agent_id: str) -> str | None:
|
|
262
|
+
candidates = [record for record in self._tabs.values() if record.owner_agent_id == agent_id]
|
|
263
|
+
if not candidates:
|
|
264
|
+
return None
|
|
265
|
+
candidates.sort(key=lambda item: item.last_used_at, reverse=True)
|
|
266
|
+
return candidates[0].page_id
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _copy_record(record: TabRecord) -> TabRecord:
|
|
270
|
+
return TabRecord(
|
|
271
|
+
page_id=record.page_id,
|
|
272
|
+
owner_agent_id=record.owner_agent_id,
|
|
273
|
+
created_at=record.created_at,
|
|
274
|
+
last_used_at=record.last_used_at,
|
|
275
|
+
url=record.url,
|
|
276
|
+
title=record.title,
|
|
277
|
+
busy=record.busy,
|
|
278
|
+
last_snapshot_refs=set(record.last_snapshot_refs),
|
|
279
|
+
last_snapshot_id=record.last_snapshot_id,
|
|
280
|
+
last_snapshot_ref_count=record.last_snapshot_ref_count,
|
|
281
|
+
last_snapshot_url=record.last_snapshot_url,
|
|
282
|
+
last_snapshot_at=record.last_snapshot_at,
|
|
283
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Thin Python runtime for Browser CLI-backed tasks."""
|
|
2
|
+
|
|
3
|
+
from browser_cli.task_runtime.client import BrowserCliTaskClient
|
|
4
|
+
from browser_cli.task_runtime.entrypoint import (
|
|
5
|
+
load_task_entrypoint,
|
|
6
|
+
parse_input_overrides,
|
|
7
|
+
run_task_entrypoint,
|
|
8
|
+
validate_task_dir,
|
|
9
|
+
)
|
|
10
|
+
from browser_cli.task_runtime.flow import Flow
|
|
11
|
+
from browser_cli.task_runtime.models import (
|
|
12
|
+
FlowContext,
|
|
13
|
+
SnapshotRef,
|
|
14
|
+
SnapshotResult,
|
|
15
|
+
validate_task_metadata,
|
|
16
|
+
)
|
|
17
|
+
from browser_cli.task_runtime.read import ReadResult
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BrowserCliTaskClient",
|
|
21
|
+
"Flow",
|
|
22
|
+
"FlowContext",
|
|
23
|
+
"ReadResult",
|
|
24
|
+
"SnapshotRef",
|
|
25
|
+
"SnapshotResult",
|
|
26
|
+
"load_task_entrypoint",
|
|
27
|
+
"parse_input_overrides",
|
|
28
|
+
"run_task_entrypoint",
|
|
29
|
+
"validate_task_dir",
|
|
30
|
+
"validate_task_metadata",
|
|
31
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Thin Browser CLI Python client for task execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from browser_cli.daemon.client import send_command
|
|
9
|
+
from browser_cli.profiles.discovery import ChromeEnvironment
|
|
10
|
+
from browser_cli.task_runtime.models import SnapshotResult
|
|
11
|
+
from browser_cli.task_runtime.read import ReadRequest, ReadResult, run_read_request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BrowserCliTaskClient:
|
|
15
|
+
def __init__(self, *, chrome_environment: ChromeEnvironment | None = None) -> None:
|
|
16
|
+
self._chrome_environment = chrome_environment
|
|
17
|
+
|
|
18
|
+
def invoke(self, action: str, **args: Any) -> dict[str, Any]:
|
|
19
|
+
response = send_command(action, args)
|
|
20
|
+
return dict(response.get("data") or {})
|
|
21
|
+
|
|
22
|
+
def command(self, action: str, **args: Any) -> dict[str, Any]:
|
|
23
|
+
return self.invoke(action, **args)
|
|
24
|
+
|
|
25
|
+
def read(
|
|
26
|
+
self,
|
|
27
|
+
url: str,
|
|
28
|
+
*,
|
|
29
|
+
output_mode: str = "html",
|
|
30
|
+
scroll_bottom: bool = False,
|
|
31
|
+
) -> ReadResult:
|
|
32
|
+
return asyncio.run(
|
|
33
|
+
run_read_request(
|
|
34
|
+
ReadRequest(
|
|
35
|
+
url=url,
|
|
36
|
+
output_mode=output_mode,
|
|
37
|
+
scroll_bottom=scroll_bottom,
|
|
38
|
+
),
|
|
39
|
+
chrome_environment=self._chrome_environment,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def open(self, url: str) -> dict[str, Any]:
|
|
44
|
+
return self.invoke("open", url=url).get("page", {})
|
|
45
|
+
|
|
46
|
+
def search(self, query: str, *, engine: str = "duckduckgo") -> dict[str, Any]:
|
|
47
|
+
return self.invoke("search", query=query, engine=engine).get("page", {})
|
|
48
|
+
|
|
49
|
+
def snapshot(self, *, interactive: bool = False, full_page: bool = True) -> SnapshotResult:
|
|
50
|
+
return SnapshotResult.from_payload(
|
|
51
|
+
self.invoke("snapshot", interactive=interactive, full_page=full_page)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def html(self) -> str:
|
|
55
|
+
return str(self.invoke("html").get("html") or "")
|
|
56
|
+
|
|
57
|
+
def click(self, ref: str) -> dict[str, Any]:
|
|
58
|
+
return self.invoke("click", ref=ref)
|
|
59
|
+
|
|
60
|
+
def fill(self, ref: str, text: str, *, submit: bool = False) -> dict[str, Any]:
|
|
61
|
+
return self.invoke("fill", ref=ref, text=text, submit=submit)
|
|
62
|
+
|
|
63
|
+
def select(self, ref: str, text: str) -> dict[str, Any]:
|
|
64
|
+
return self.invoke("select", ref=ref, text=text)
|
|
65
|
+
|
|
66
|
+
def check(self, ref: str) -> dict[str, Any]:
|
|
67
|
+
return self.invoke("check", ref=ref)
|
|
68
|
+
|
|
69
|
+
def uncheck(self, ref: str) -> dict[str, Any]:
|
|
70
|
+
return self.invoke("uncheck", ref=ref)
|
|
71
|
+
|
|
72
|
+
def focus(self, ref: str) -> dict[str, Any]:
|
|
73
|
+
return self.invoke("focus", ref=ref)
|
|
74
|
+
|
|
75
|
+
def hover(self, ref: str) -> dict[str, Any]:
|
|
76
|
+
return self.invoke("hover", ref=ref)
|
|
77
|
+
|
|
78
|
+
def wait(self, seconds: float) -> dict[str, Any]:
|
|
79
|
+
return self.invoke("wait", seconds=seconds)
|
|
80
|
+
|
|
81
|
+
def wait_text(
|
|
82
|
+
self, text: str, *, timeout: float = 5.0, gone: bool = False, exact: bool = False
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
return self.invoke("wait", seconds=timeout, text=text, gone=gone, exact=exact)
|
|
85
|
+
|
|
86
|
+
def eval(self, code: str) -> Any:
|
|
87
|
+
return self.invoke("eval", code=code).get("result")
|
|
88
|
+
|
|
89
|
+
def eval_on(self, ref: str, code: str) -> Any:
|
|
90
|
+
return self.invoke("eval-on", ref=ref, code=code).get("result")
|
|
91
|
+
|
|
92
|
+
def verify_text(
|
|
93
|
+
self, text: str, *, exact: bool = False, timeout: float = 5.0
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
return self.invoke("verify-text", text=text, exact=exact, timeout=timeout)
|
|
96
|
+
|
|
97
|
+
def verify_state(self, ref: str, state: str) -> dict[str, Any]:
|
|
98
|
+
return self.invoke("verify-state", ref=ref, state=state)
|
|
99
|
+
|
|
100
|
+
def verify_value(self, ref: str, expected: str) -> dict[str, Any]:
|
|
101
|
+
return self.invoke("verify-value", ref=ref, expected=expected)
|
|
102
|
+
|
|
103
|
+
def screenshot(self, path: str, *, full_page: bool = False) -> dict[str, Any]:
|
|
104
|
+
return self.invoke("screenshot", path=path, full_page=full_page)
|
|
105
|
+
|
|
106
|
+
def close(self) -> dict[str, Any]:
|
|
107
|
+
return self.invoke("close")
|