r2flow-engine 0.8.11__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.
- r2flow/__init__.py +116 -0
- r2flow/core/__init__.py +28 -0
- r2flow/core/asset_tools.py +84 -0
- r2flow/core/assets.py +258 -0
- r2flow/core/blocking.py +185 -0
- r2flow/core/config.py +211 -0
- r2flow/core/errors.py +105 -0
- r2flow/core/events.py +92 -0
- r2flow/core/excel.py +308 -0
- r2flow/core/files.py +349 -0
- r2flow/core/http_queue.py +416 -0
- r2flow/core/logging.py +173 -0
- r2flow/core/queue.py +581 -0
- r2flow/core/redact.py +67 -0
- r2flow/core/registry.py +86 -0
- r2flow/core/retry.py +115 -0
- r2flow/core/schema.py +92 -0
- r2flow/core/selectors.py +113 -0
- r2flow/core/tool.py +119 -0
- r2flow/core/transactions.py +630 -0
- r2flow/facade.py +908 -0
- r2flow/flow.py +1188 -0
- r2flow/pack.py +916 -0
- r2flow/py.typed +0 -0
- r2flow/run_flow.py +468 -0
- r2flow/trace.py +143 -0
- r2flow/windows/__init__.py +0 -0
- r2flow/windows/element.py +109 -0
- r2flow/windows/selector.py +373 -0
- r2flow/windows/selector_rank.py +286 -0
- r2flow/windows/tools/__init__.py +90 -0
- r2flow/windows/tools/_resolve.py +117 -0
- r2flow/windows/tools/click.py +152 -0
- r2flow/windows/tools/clipboard.py +82 -0
- r2flow/windows/tools/control_action.py +113 -0
- r2flow/windows/tools/delay.py +55 -0
- r2flow/windows/tools/drag.py +82 -0
- r2flow/windows/tools/exists.py +59 -0
- r2flow/windows/tools/get_element.py +59 -0
- r2flow/windows/tools/get_table.py +156 -0
- r2flow/windows/tools/get_text.py +76 -0
- r2flow/windows/tools/highlight.py +156 -0
- r2flow/windows/tools/hover.py +62 -0
- r2flow/windows/tools/image.py +278 -0
- r2flow/windows/tools/input_text.py +92 -0
- r2flow/windows/tools/keyboard.py +372 -0
- r2flow/windows/tools/list_elements.py +107 -0
- r2flow/windows/tools/ocr.py +235 -0
- r2flow/windows/tools/process.py +485 -0
- r2flow/windows/tools/screenshot.py +234 -0
- r2flow/windows/tools/scroll.py +100 -0
- r2flow/windows/tools/select.py +67 -0
- r2flow/windows/tools/selector_capture/__init__.py +43 -0
- r2flow/windows/tools/selector_capture/__main__.py +5 -0
- r2flow/windows/tools/selector_capture/api.py +148 -0
- r2flow/windows/tools/selector_capture/capture.py +387 -0
- r2flow/windows/tools/selector_capture/cli.py +152 -0
- r2flow/windows/tools/selector_capture/emit.py +160 -0
- r2flow/windows/tools/selector_capture/flowgen.py +84 -0
- r2flow/windows/tools/selector_capture/generate.py +351 -0
- r2flow/windows/tools/selector_capture/record.py +211 -0
- r2flow/windows/tools/selector_capture/recorder.py +886 -0
- r2flow/windows/tools/set_text.py +116 -0
- r2flow/windows/tools/wait.py +149 -0
- r2flow/windows/tools/window.py +189 -0
- r2flow_engine-0.8.11.dist-info/METADATA +563 -0
- r2flow_engine-0.8.11.dist-info/RECORD +70 -0
- r2flow_engine-0.8.11.dist-info/WHEEL +4 -0
- r2flow_engine-0.8.11.dist-info/entry_points.txt +3 -0
- r2flow_engine-0.8.11.dist-info/licenses/LICENSE +21 -0
r2flow/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""R2Flow — Free Python RPA engine for creating automation bots."""
|
|
2
|
+
|
|
3
|
+
from r2flow.core.config import Config, load_config
|
|
4
|
+
from r2flow.core.errors import (
|
|
5
|
+
BusinessError,
|
|
6
|
+
Cancelled,
|
|
7
|
+
ConfigError,
|
|
8
|
+
ElementNotFound,
|
|
9
|
+
InfrastructureError,
|
|
10
|
+
InvalidInput,
|
|
11
|
+
PlatformError,
|
|
12
|
+
ToolError,
|
|
13
|
+
)
|
|
14
|
+
from r2flow.core.http_queue import HttpQueue, HttpQueueError
|
|
15
|
+
from r2flow.core.logging import JsonlEventLogger
|
|
16
|
+
from r2flow.core.queue import (
|
|
17
|
+
ClaimedItem,
|
|
18
|
+
InMemoryQueue,
|
|
19
|
+
LeaseRenewable,
|
|
20
|
+
Queue,
|
|
21
|
+
QueueInfo,
|
|
22
|
+
QueueItem,
|
|
23
|
+
SqliteQueue,
|
|
24
|
+
)
|
|
25
|
+
from r2flow.core.retry import RetryTool
|
|
26
|
+
from r2flow.core.schema import validate_against_schema
|
|
27
|
+
from r2flow.core.selectors import SelectorStore
|
|
28
|
+
from r2flow.core.tool import AbstractTool, Tool, tool
|
|
29
|
+
from r2flow.core.transactions import (
|
|
30
|
+
ItemOutcome,
|
|
31
|
+
TransactionContextMiddleware,
|
|
32
|
+
TransactionReport,
|
|
33
|
+
current_transaction_id,
|
|
34
|
+
run_transactions,
|
|
35
|
+
run_transactions_async,
|
|
36
|
+
)
|
|
37
|
+
from r2flow.facade import (
|
|
38
|
+
ClickResult,
|
|
39
|
+
InputTextResult,
|
|
40
|
+
ProcessHandle,
|
|
41
|
+
SetTextResult,
|
|
42
|
+
R2Flow,
|
|
43
|
+
)
|
|
44
|
+
from r2flow.flow import FlowError, FlowRunner
|
|
45
|
+
from r2flow.pack import (
|
|
46
|
+
PACK_MANIFEST,
|
|
47
|
+
TEMPLATE_FILE,
|
|
48
|
+
build_pack,
|
|
49
|
+
fetch_pack,
|
|
50
|
+
load_manifest,
|
|
51
|
+
load_template,
|
|
52
|
+
publish_pack,
|
|
53
|
+
validate_template,
|
|
54
|
+
verify_pack,
|
|
55
|
+
zip_pack,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from importlib.metadata import PackageNotFoundError
|
|
60
|
+
from importlib.metadata import version as _pkg_version
|
|
61
|
+
|
|
62
|
+
__version__ = _pkg_version("r2flow-engine")
|
|
63
|
+
except PackageNotFoundError: # pragma: no cover — running from an uninstalled tree
|
|
64
|
+
__version__ = "0.8.11"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"AbstractTool",
|
|
68
|
+
"BusinessError",
|
|
69
|
+
"Cancelled",
|
|
70
|
+
"ClaimedItem",
|
|
71
|
+
"ClickResult",
|
|
72
|
+
"Config",
|
|
73
|
+
"ConfigError",
|
|
74
|
+
"ElementNotFound",
|
|
75
|
+
"FlowError",
|
|
76
|
+
"FlowRunner",
|
|
77
|
+
"HttpQueue",
|
|
78
|
+
"HttpQueueError",
|
|
79
|
+
"InMemoryQueue",
|
|
80
|
+
"InputTextResult",
|
|
81
|
+
"InvalidInput",
|
|
82
|
+
"ItemOutcome",
|
|
83
|
+
"JsonlEventLogger",
|
|
84
|
+
"LeaseRenewable",
|
|
85
|
+
"PACK_MANIFEST",
|
|
86
|
+
"PlatformError",
|
|
87
|
+
"ProcessHandle",
|
|
88
|
+
"Queue",
|
|
89
|
+
"QueueInfo",
|
|
90
|
+
"QueueItem",
|
|
91
|
+
"RetryTool",
|
|
92
|
+
"SelectorStore",
|
|
93
|
+
"SetTextResult",
|
|
94
|
+
"R2Flow",
|
|
95
|
+
"SqliteQueue",
|
|
96
|
+
"InfrastructureError",
|
|
97
|
+
"TEMPLATE_FILE",
|
|
98
|
+
"Tool",
|
|
99
|
+
"ToolError",
|
|
100
|
+
"TransactionContextMiddleware",
|
|
101
|
+
"TransactionReport",
|
|
102
|
+
"build_pack",
|
|
103
|
+
"current_transaction_id",
|
|
104
|
+
"fetch_pack",
|
|
105
|
+
"load_config",
|
|
106
|
+
"load_manifest",
|
|
107
|
+
"load_template",
|
|
108
|
+
"publish_pack",
|
|
109
|
+
"run_transactions",
|
|
110
|
+
"run_transactions_async",
|
|
111
|
+
"tool",
|
|
112
|
+
"validate_against_schema",
|
|
113
|
+
"validate_template",
|
|
114
|
+
"verify_pack",
|
|
115
|
+
"zip_pack",
|
|
116
|
+
]
|
r2flow/core/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Core traits, types, and error definitions."""
|
|
2
|
+
|
|
3
|
+
from r2flow.core.errors import (
|
|
4
|
+
BusinessError,
|
|
5
|
+
Cancelled,
|
|
6
|
+
ConfigError,
|
|
7
|
+
ElementNotFound,
|
|
8
|
+
InfrastructureError,
|
|
9
|
+
InvalidInput,
|
|
10
|
+
PlatformError,
|
|
11
|
+
ToolError,
|
|
12
|
+
)
|
|
13
|
+
from r2flow.core.registry import ToolRegistry
|
|
14
|
+
from r2flow.core.tool import AbstractTool, Tool
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AbstractTool",
|
|
18
|
+
"BusinessError",
|
|
19
|
+
"Cancelled",
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"ElementNotFound",
|
|
22
|
+
"InfrastructureError",
|
|
23
|
+
"InvalidInput",
|
|
24
|
+
"PlatformError",
|
|
25
|
+
"Tool",
|
|
26
|
+
"ToolError",
|
|
27
|
+
"ToolRegistry",
|
|
28
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Asset tools — fetch secrets at run time without storing them in the flow.
|
|
2
|
+
|
|
3
|
+
The agent injects assets as environment variables (``R2FLOW_ASSET_*`` for a
|
|
4
|
+
text asset, ``R2FLOW_ASSET_<NAME>_<FIELD>`` for credential fields); these
|
|
5
|
+
tools read them back.
|
|
6
|
+
|
|
7
|
+
Both tools set :attr:`AbstractTool.produces_secrets`, so the flow runner
|
|
8
|
+
redacts the returned values from logs/errors and keeps the variables they
|
|
9
|
+
land in out of the run-result snapshot sent back to the orchestrator.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from r2flow.core.assets import asset_provider_from_env
|
|
17
|
+
from r2flow.core.errors import InvalidInput
|
|
18
|
+
from r2flow.core.tool import AbstractTool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _asset_name(config: dict[str, Any]) -> str:
|
|
22
|
+
name = config.get("name")
|
|
23
|
+
if not isinstance(name, str) or not name.strip():
|
|
24
|
+
raise InvalidInput(
|
|
25
|
+
"Missing required parameter: name (asset name)",
|
|
26
|
+
param="name",
|
|
27
|
+
input_value=name,
|
|
28
|
+
)
|
|
29
|
+
return name.strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AssetGetTool(AbstractTool):
|
|
33
|
+
"""Fetch a text asset (secret) by name."""
|
|
34
|
+
|
|
35
|
+
produces_secrets = True
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
return "asset.get"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def description(self) -> str:
|
|
43
|
+
return "Fetches a text asset (secret) from the agent environment by name"
|
|
44
|
+
|
|
45
|
+
def schema(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"name": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Asset name (e.g. crm-url) or a credential field (crm.login)",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"required": ["name"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async def execute(self, config: dict[str, Any]) -> Any:
|
|
58
|
+
return asset_provider_from_env().get(_asset_name(config))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AssetCredentialTool(AbstractTool):
|
|
62
|
+
"""Fetch a credential asset's fields (login / password / …)."""
|
|
63
|
+
|
|
64
|
+
produces_secrets = True
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
return "asset.credential"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def description(self) -> str:
|
|
72
|
+
return "Fetches a credential asset's fields (login, password, …) by name"
|
|
73
|
+
|
|
74
|
+
def schema(self) -> dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"name": {"type": "string", "description": "Credential asset name (e.g. crm)"},
|
|
79
|
+
},
|
|
80
|
+
"required": ["name"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async def execute(self, config: dict[str, Any]) -> Any:
|
|
84
|
+
return asset_provider_from_env().credential(_asset_name(config))
|
r2flow/core/assets.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Asset provider — runtime secrets for bots.
|
|
2
|
+
|
|
3
|
+
Config files deliberately store only *references* to secrets (asset
|
|
4
|
+
names, GUIDs) — never values. Values are fetched at runtime through an
|
|
5
|
+
:class:`AssetProvider`, called from bot code (``bot.asset("db_password")``)
|
|
6
|
+
instead of flowing through tool configs/results — so they can never
|
|
7
|
+
leak into the JSONL audit log or flow files.
|
|
8
|
+
|
|
9
|
+
The default provider reads environment variables (``R2FLOW_ASSET_*``);
|
|
10
|
+
wire an orchestrator-backed implementation behind the same protocol.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from r2flow.core.errors import InvalidInput, PlatformError
|
|
24
|
+
|
|
25
|
+
DEFAULT_ASSET_PREFIX = "R2FLOW_ASSET_"
|
|
26
|
+
|
|
27
|
+
_MAX_ASSET_BYTES = 1_000_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_no_redirect(response: Any, url: str) -> None:
|
|
31
|
+
"""Fail when urlopen followed a redirect away from *url*."""
|
|
32
|
+
geturl = getattr(response, "geturl", None)
|
|
33
|
+
if callable(geturl):
|
|
34
|
+
try:
|
|
35
|
+
final = str(geturl())
|
|
36
|
+
except Exception:
|
|
37
|
+
return
|
|
38
|
+
if final and final != url:
|
|
39
|
+
raise PlatformError(f"asset fetch refused redirect to {final!r}", source=None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize(name: str) -> str:
|
|
43
|
+
return re.sub(r"[^A-Z0-9]+", "_", name.strip().upper())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class AssetProvider(Protocol):
|
|
48
|
+
"""Secret source contract."""
|
|
49
|
+
|
|
50
|
+
def get(self, name: str) -> str:
|
|
51
|
+
"""Return the secret value for *name*; raise if unknown."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def credential(self, name: str) -> dict[str, str]:
|
|
55
|
+
"""Return a credential's fields (or ``{"value": ...}`` for text)."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EnvAssetProvider:
|
|
60
|
+
"""Asset provider over ``R2FLOW_ASSET_*`` environment variables.
|
|
61
|
+
|
|
62
|
+
``bot.asset("db.password")`` reads ``R2FLOW_ASSET_DB_PASSWORD``:
|
|
63
|
+
the name is upper-cased and every non-alphanumeric run becomes a
|
|
64
|
+
single underscore.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, prefix: str = DEFAULT_ASSET_PREFIX) -> None:
|
|
68
|
+
if not isinstance(prefix, str) or not prefix:
|
|
69
|
+
raise InvalidInput(
|
|
70
|
+
"prefix must be a non-empty string", param="prefix", input_value=prefix
|
|
71
|
+
)
|
|
72
|
+
self._prefix = prefix
|
|
73
|
+
self._cache: dict[str, str] = {}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def prefix(self) -> str:
|
|
77
|
+
"""Env var prefix of this provider."""
|
|
78
|
+
return self._prefix
|
|
79
|
+
|
|
80
|
+
def get(self, name: str) -> str:
|
|
81
|
+
"""Look up the env var for *name*.
|
|
82
|
+
|
|
83
|
+
Results are cached for the lifetime of the provider, so a bot
|
|
84
|
+
that fetches the same asset repeatedly pays the environment
|
|
85
|
+
lookup once.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
InvalidInput: If *name* is empty or the env var is not set.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(name, str) or not name.strip():
|
|
91
|
+
raise InvalidInput(
|
|
92
|
+
"asset name must be a non-empty string", param="name", input_value=name
|
|
93
|
+
)
|
|
94
|
+
key = self._prefix + re.sub(r"[^A-Z0-9]+", "_", name.strip().upper())
|
|
95
|
+
cached = self._cache.get(key)
|
|
96
|
+
if cached is not None:
|
|
97
|
+
return cached
|
|
98
|
+
value = os.environ.get(key)
|
|
99
|
+
if value is None:
|
|
100
|
+
raise InvalidInput(
|
|
101
|
+
f"Unknown asset {name!r}: environment variable {key!r} is not set",
|
|
102
|
+
param="name",
|
|
103
|
+
input_value=name,
|
|
104
|
+
)
|
|
105
|
+
self._cache[key] = value
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
def credential(self, name: str) -> dict[str, str]:
|
|
109
|
+
"""Credential fields for *name*, or ``{"value": ...}`` for text."""
|
|
110
|
+
fields = credential_fields(name, self._prefix)
|
|
111
|
+
if fields:
|
|
112
|
+
return fields
|
|
113
|
+
return {"value": self.get(name)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def credential_fields(name: str, prefix: str = DEFAULT_ASSET_PREFIX) -> dict[str, str]:
|
|
117
|
+
"""All ``<prefix><NAME>_<FIELD>`` env vars for a credential *name*.
|
|
118
|
+
|
|
119
|
+
The agent injects a credential as one env var per field, e.g. asset
|
|
120
|
+
``crm`` with ``login``/``password`` becomes ``R2FLOW_ASSET_CRM_LOGIN``
|
|
121
|
+
and ``R2FLOW_ASSET_CRM_PASSWORD``. Keys are returned lower-cased
|
|
122
|
+
(``{"login": ..., "password": ...}``); an empty result means either a
|
|
123
|
+
text asset or an unknown name.
|
|
124
|
+
"""
|
|
125
|
+
base = prefix + _normalize(name)
|
|
126
|
+
marker = base + "_"
|
|
127
|
+
fields: dict[str, str] = {}
|
|
128
|
+
for key, value in os.environ.items():
|
|
129
|
+
if key.startswith(marker) and value != "":
|
|
130
|
+
field = key[len(marker) :].lower()
|
|
131
|
+
if field:
|
|
132
|
+
fields[field] = value
|
|
133
|
+
return fields
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HttpAssetProvider:
|
|
137
|
+
"""Fetch assets on demand from a r2flow-cloud orchestrator.
|
|
138
|
+
|
|
139
|
+
Resolves an asset by **id/GUID or name** (and ``name.field`` for a
|
|
140
|
+
credential field), so the engine no longer needs the whole vault
|
|
141
|
+
injected into the environment. Only used when the agent configures
|
|
142
|
+
``R2FLOW_ORCHESTRATOR_URL`` + ``R2FLOW_AGENT_ID`` + a token; otherwise
|
|
143
|
+
:class:`EnvAssetProvider` is used.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
base_url: str,
|
|
149
|
+
*,
|
|
150
|
+
agent_id: str,
|
|
151
|
+
token: str,
|
|
152
|
+
process_id: str | None = None,
|
|
153
|
+
timeout: float = 15.0,
|
|
154
|
+
allow_insecure: bool = False,
|
|
155
|
+
) -> None:
|
|
156
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
157
|
+
if parsed.scheme not in ("http", "https"):
|
|
158
|
+
raise InvalidInput(
|
|
159
|
+
"orchestrator URL must be http(s)", param="base_url", input_value=base_url
|
|
160
|
+
)
|
|
161
|
+
host = parsed.hostname or ""
|
|
162
|
+
loopback = host in ("127.0.0.1", "::1", "localhost")
|
|
163
|
+
if parsed.scheme == "http" and not loopback and not allow_insecure:
|
|
164
|
+
raise InvalidInput(
|
|
165
|
+
"plain http to a non-loopback orchestrator is refused "
|
|
166
|
+
"(assets would travel in cleartext)",
|
|
167
|
+
param="base_url",
|
|
168
|
+
input_value=base_url,
|
|
169
|
+
)
|
|
170
|
+
self._base = base_url.rstrip("/")
|
|
171
|
+
self._agent_id = str(agent_id)
|
|
172
|
+
self._token = token
|
|
173
|
+
self._process_id = process_id
|
|
174
|
+
self._timeout = timeout
|
|
175
|
+
self._cache: dict[str, dict[str, Any]] = {}
|
|
176
|
+
|
|
177
|
+
def _fetch(self, ref: str) -> dict[str, Any] | None:
|
|
178
|
+
"""One asset by id or name; ``None`` on 404 (so ``name.field`` can split)."""
|
|
179
|
+
if ref in self._cache:
|
|
180
|
+
return self._cache[ref]
|
|
181
|
+
url = (
|
|
182
|
+
f"{self._base}/api/agents/{urllib.parse.quote(self._agent_id, safe='')}"
|
|
183
|
+
f"/assets/{urllib.parse.quote(ref, safe='')}"
|
|
184
|
+
)
|
|
185
|
+
if self._process_id:
|
|
186
|
+
url += f"?process_id={urllib.parse.quote(self._process_id, safe='')}"
|
|
187
|
+
request = urllib.request.Request(url)
|
|
188
|
+
# Unredirected: never replayed to a redirect target.
|
|
189
|
+
request.add_unredirected_header("Authorization", f"Bearer {self._token}")
|
|
190
|
+
try:
|
|
191
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
192
|
+
_check_no_redirect(response, url)
|
|
193
|
+
try:
|
|
194
|
+
raw = response.read(_MAX_ASSET_BYTES + 1)
|
|
195
|
+
except TypeError:
|
|
196
|
+
raw = response.read()
|
|
197
|
+
if isinstance(raw, str):
|
|
198
|
+
raw = raw.encode("utf-8")
|
|
199
|
+
if len(raw) > _MAX_ASSET_BYTES:
|
|
200
|
+
raise PlatformError("asset response too large", source=None)
|
|
201
|
+
data: Any = json.loads(bytes(raw).decode("utf-8"))
|
|
202
|
+
except urllib.error.HTTPError as exc:
|
|
203
|
+
exc.close()
|
|
204
|
+
if exc.code == 404:
|
|
205
|
+
return None
|
|
206
|
+
raise PlatformError(f"asset fetch failed with HTTP {exc.code}", source=exc) from exc
|
|
207
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
208
|
+
raise PlatformError(f"asset fetch failed: {exc}", source=exc) from exc
|
|
209
|
+
if isinstance(data, dict):
|
|
210
|
+
self._cache[ref] = data
|
|
211
|
+
return data
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def get(self, name: str) -> str:
|
|
215
|
+
data = self._fetch(name)
|
|
216
|
+
if data is not None:
|
|
217
|
+
if str(data.get("kind")) == "credential":
|
|
218
|
+
raise InvalidInput(
|
|
219
|
+
f'asset {name!r} is a credential — use "name.field" or asset.credential',
|
|
220
|
+
param="name",
|
|
221
|
+
input_value=name,
|
|
222
|
+
)
|
|
223
|
+
return str(data.get("value") or "")
|
|
224
|
+
if "." in name:
|
|
225
|
+
base, _, field = name.rpartition(".")
|
|
226
|
+
parent = self._fetch(base)
|
|
227
|
+
fields = (parent or {}).get("fields")
|
|
228
|
+
if isinstance(fields, dict) and field in fields:
|
|
229
|
+
return str(fields[field])
|
|
230
|
+
raise InvalidInput(f"Unknown asset {name!r}", param="name", input_value=name)
|
|
231
|
+
|
|
232
|
+
def credential(self, name: str) -> dict[str, str]:
|
|
233
|
+
data = self._fetch(name)
|
|
234
|
+
if data is None and "." in name:
|
|
235
|
+
data = self._fetch(name.rpartition(".")[0])
|
|
236
|
+
if data is None:
|
|
237
|
+
raise InvalidInput(f"Unknown asset {name!r}", param="name", input_value=name)
|
|
238
|
+
if str(data.get("kind")) == "credential":
|
|
239
|
+
fields = data.get("fields") or {}
|
|
240
|
+
if isinstance(fields, dict):
|
|
241
|
+
return {str(key): str(value) for key, value in fields.items()}
|
|
242
|
+
return {}
|
|
243
|
+
return {"value": str(data.get("value") or "")}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def asset_provider_from_env() -> AssetProvider:
|
|
247
|
+
"""Orchestrator-backed provider when configured, else env vars.
|
|
248
|
+
|
|
249
|
+
The agent sets ``R2FLOW_ORCHESTRATOR_URL`` / ``R2FLOW_AGENT_ID`` /
|
|
250
|
+
``R2FLOW_AGENT_TOKEN``; self-host runs use ``R2FLOW_ASSET_*`` directly.
|
|
251
|
+
"""
|
|
252
|
+
url = os.environ.get("R2FLOW_ORCHESTRATOR_URL")
|
|
253
|
+
agent_id = os.environ.get("R2FLOW_AGENT_ID")
|
|
254
|
+
token = os.environ.get("R2FLOW_AGENT_TOKEN") or os.environ.get("R2FLOW_API_TOKEN")
|
|
255
|
+
process_id = os.environ.get("R2FLOW_PROCESS_ID") or None
|
|
256
|
+
if url and agent_id and token:
|
|
257
|
+
return HttpAssetProvider(url, agent_id=agent_id, token=token, process_id=process_id)
|
|
258
|
+
return EnvAssetProvider()
|
r2flow/core/blocking.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Offload blocking calls to a worker thread with a hard timeout.
|
|
2
|
+
|
|
3
|
+
UIA/COM calls can hang indefinitely (dead dialogs, stalled COM apartments).
|
|
4
|
+
Every blocking offload in the windows tools goes through :func:`run_blocking`,
|
|
5
|
+
which bounds the wait: default 30 s, tunable via the ``R2FLOW_BLOCKING_TIMEOUT``
|
|
6
|
+
environment variable. A timeout raises :class:`PlatformError` instead of
|
|
7
|
+
leaving the bot blocked forever.
|
|
8
|
+
|
|
9
|
+
COM threading model
|
|
10
|
+
-------------------
|
|
11
|
+
|
|
12
|
+
UIA elements are apartment-bound COM objects: using a pointer created in
|
|
13
|
+
one thread's apartment from another thread (or after that apartment is
|
|
14
|
+
torn down) fails with ``E_FAIL`` or crashes with an access violation.
|
|
15
|
+
``uiautomation``'s official guidance is equally strict — "you can't use
|
|
16
|
+
a Control created in a different thread".
|
|
17
|
+
|
|
18
|
+
Therefore all blocking UIA work runs on a single, long-lived worker
|
|
19
|
+
thread that owns one COM apartment (initialized once via
|
|
20
|
+
``CoInitializeEx``). UIA elements are created and consumed on that same
|
|
21
|
+
thread, so they never cross apartments. The trade-off is serialization —
|
|
22
|
+
irrelevant in practice because bot and flow code is sequential.
|
|
23
|
+
|
|
24
|
+
If a call hangs past *timeout*, the thread is abandoned in place (a hung
|
|
25
|
+
COM call is not interruptible) and the executor is replaced before the
|
|
26
|
+
next call, so one dead dialog cannot poison every subsequent tool call.
|
|
27
|
+
Abandoned threads are joined at interpreter exit.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import functools
|
|
34
|
+
import os
|
|
35
|
+
import threading
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
38
|
+
from typing import Any, TypeVar
|
|
39
|
+
|
|
40
|
+
from r2flow.core.errors import PlatformError
|
|
41
|
+
|
|
42
|
+
_T = TypeVar("_T")
|
|
43
|
+
|
|
44
|
+
_executor_lock = threading.Lock()
|
|
45
|
+
_executor: ThreadPoolExecutor | None = None
|
|
46
|
+
_active_calls = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_timeout() -> float:
|
|
50
|
+
raw = os.environ.get("R2FLOW_BLOCKING_TIMEOUT", "30")
|
|
51
|
+
try:
|
|
52
|
+
value = float(raw)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return 30.0
|
|
55
|
+
return value if value > 0 else 30.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _com_thread_init() -> None:
|
|
59
|
+
"""Initialize this worker thread's COM apartment (once per thread)."""
|
|
60
|
+
try:
|
|
61
|
+
import comtypes
|
|
62
|
+
except Exception: # pragma: no cover — non-Windows
|
|
63
|
+
return
|
|
64
|
+
comtypes.CoInitializeEx()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_with_com(fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
|
68
|
+
"""Run *fn* with a COM apartment initialized in the current thread.
|
|
69
|
+
|
|
70
|
+
For callers outside the shared worker thread (e.g. the synchronous
|
|
71
|
+
CLI capture path on the main thread). A no-op when ``comtypes`` is
|
|
72
|
+
unavailable (non-Windows hosts).
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
import comtypes
|
|
76
|
+
except Exception: # pragma: no cover — non-Windows
|
|
77
|
+
return fn(*args, **kwargs)
|
|
78
|
+
comtypes.CoInitializeEx()
|
|
79
|
+
try:
|
|
80
|
+
return fn(*args, **kwargs)
|
|
81
|
+
finally:
|
|
82
|
+
comtypes.CoUninitialize()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_executor() -> ThreadPoolExecutor:
|
|
86
|
+
"""The shared single-thread executor (created on first use)."""
|
|
87
|
+
global _executor
|
|
88
|
+
with _executor_lock:
|
|
89
|
+
if _executor is None:
|
|
90
|
+
_executor = ThreadPoolExecutor(
|
|
91
|
+
max_workers=1,
|
|
92
|
+
thread_name_prefix="r2flow-blocking",
|
|
93
|
+
initializer=_com_thread_init,
|
|
94
|
+
)
|
|
95
|
+
return _executor
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _abandon_executor() -> bool:
|
|
99
|
+
"""Drop the executor after a hung call (a fresh one is made lazily).
|
|
100
|
+
|
|
101
|
+
Only safe when this call is the sole user of the executor: abandoning
|
|
102
|
+
it shuts down pending/running futures, so concurrent calls would see
|
|
103
|
+
``CancelledError`` or ``RuntimeError``. Returns True when the executor
|
|
104
|
+
was actually abandoned.
|
|
105
|
+
"""
|
|
106
|
+
global _executor
|
|
107
|
+
with _executor_lock:
|
|
108
|
+
if _executor is None or _active_calls > 1:
|
|
109
|
+
return False
|
|
110
|
+
executor, _executor = _executor, None
|
|
111
|
+
executor.shutdown(wait=False)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _enter_call() -> None:
|
|
116
|
+
global _active_calls
|
|
117
|
+
with _executor_lock:
|
|
118
|
+
_active_calls += 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _exit_call() -> None:
|
|
122
|
+
global _active_calls
|
|
123
|
+
with _executor_lock:
|
|
124
|
+
_active_calls -= 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def run_on_uia_thread(fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T:
|
|
128
|
+
"""Run ``fn(*args, **kwargs)`` on the shared UIA thread — no timeout.
|
|
129
|
+
|
|
130
|
+
For interactive flows (e.g. waiting for a human to press CTRL during
|
|
131
|
+
dev capture) that legitimately take longer than any sane timeout.
|
|
132
|
+
Uses the same apartment-bound thread as :func:`run_blocking`, so
|
|
133
|
+
everything it touches shares the process-wide COM apartment.
|
|
134
|
+
"""
|
|
135
|
+
loop = asyncio.get_running_loop()
|
|
136
|
+
_enter_call()
|
|
137
|
+
try:
|
|
138
|
+
future = loop.run_in_executor(_get_executor(), functools.partial(fn, *args, **kwargs))
|
|
139
|
+
return await future
|
|
140
|
+
finally:
|
|
141
|
+
_exit_call()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def run_blocking(
|
|
145
|
+
fn: Callable[..., Any],
|
|
146
|
+
/,
|
|
147
|
+
*args: Any,
|
|
148
|
+
timeout: float | None = None,
|
|
149
|
+
**kwargs: Any,
|
|
150
|
+
) -> Any:
|
|
151
|
+
"""Run ``fn(*args, **kwargs)`` on the shared UIA worker thread.
|
|
152
|
+
|
|
153
|
+
The thread owns a COM apartment for the whole process lifetime, so
|
|
154
|
+
UIA elements created by one call stay valid for the next. After a
|
|
155
|
+
timeout the thread is abandoned (hung calls are not interruptible)
|
|
156
|
+
and the next call gets a fresh thread — but only when no other call
|
|
157
|
+
is in flight, so a timeout can never cancel a concurrent call.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
PlatformError: If the call does not finish within *timeout* seconds
|
|
161
|
+
(default: ``R2FLOW_BLOCKING_TIMEOUT`` env var, else 30 s).
|
|
162
|
+
"""
|
|
163
|
+
limit = timeout if timeout is not None else _default_timeout()
|
|
164
|
+
loop = asyncio.get_running_loop()
|
|
165
|
+
_enter_call()
|
|
166
|
+
try:
|
|
167
|
+
executor = _get_executor()
|
|
168
|
+
try:
|
|
169
|
+
future = loop.run_in_executor(executor, functools.partial(fn, *args, **kwargs))
|
|
170
|
+
except RuntimeError:
|
|
171
|
+
# The executor was abandoned between _get_executor and submit
|
|
172
|
+
# (only possible if a concurrent call timed out). Retry once
|
|
173
|
+
# against the fresh executor.
|
|
174
|
+
executor = _get_executor()
|
|
175
|
+
future = loop.run_in_executor(executor, functools.partial(fn, *args, **kwargs))
|
|
176
|
+
return await asyncio.wait_for(future, limit)
|
|
177
|
+
except TimeoutError as exc:
|
|
178
|
+
_abandon_executor()
|
|
179
|
+
name = getattr(fn, "__qualname__", None) or repr(fn)
|
|
180
|
+
raise PlatformError(
|
|
181
|
+
f"blocking call {name} timed out after {limit:g}s "
|
|
182
|
+
"(tune R2FLOW_BLOCKING_TIMEOUT if this is expected)",
|
|
183
|
+
) from exc
|
|
184
|
+
finally:
|
|
185
|
+
_exit_call()
|