vrex-flow-engine 0.2.4__tar.gz → 0.3.0__tar.gz
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.
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/PKG-INFO +1 -1
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/__init__.py +1 -1
- vrex_flow_engine-0.3.0/flow_engine/bridge/flow_client.py +315 -0
- vrex_flow_engine-0.3.0/flow_engine/bridge/flow_rpc.py +546 -0
- vrex_flow_engine-0.3.0/flow_engine/bridge/flow_sdk.py +482 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/catalog.py +92 -117
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/config.py +11 -3
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/ingest.py +22 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/jobs.py +8 -7
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/images.py +8 -3
- vrex_flow_engine-0.3.0/flow_engine/openai/models.py +41 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/uploads.py +13 -9
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/videos.py +54 -156
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/pool.py +2 -1
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/session.py +34 -20
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/pyproject.toml +1 -1
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/PKG-INFO +1 -1
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/SOURCES.txt +1 -1
- vrex_flow_engine-0.2.4/README.md +0 -268
- vrex_flow_engine-0.2.4/flow_engine/bridge/flow_client.py +0 -466
- vrex_flow_engine-0.2.4/flow_engine/bridge/flow_sdk.py +0 -1890
- vrex_flow_engine-0.2.4/flow_engine/openai/models.py +0 -38
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/PYPI_README.md +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/bridge/__init__.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/bridge/ws_server.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/__init__.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/app.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/config.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/dashboard.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/health.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/processes.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/supervisor.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/job_store.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/main.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/media.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/media_store.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/__init__.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/_util.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/posthog_client.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/video_context.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/video_context_store.py +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/setup.cfg +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/dependency_links.txt +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/entry_points.txt +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/requires.txt +0 -0
- {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Bridge to the Chrome MV3 extension over WebSocket.
|
|
2
|
+
|
|
3
|
+
Control flow:
|
|
4
|
+
1. Extension opens WS to :9223 and sends ``register_instance``.
|
|
5
|
+
2. Agent replies ``{type:"callback_secret", secret}``.
|
|
6
|
+
3. To call Flow, the agent sends ``{id, method:"rpc_request", params:{rpcId,
|
|
7
|
+
payload, captchaAction?, captchaPaths?, sourcePath?}}``. The extension
|
|
8
|
+
solves reCAPTCHA in a flow.google.com tab when asked, POSTs the
|
|
9
|
+
``batchexecute`` request with the user's cookies + XSRF (``at``) token,
|
|
10
|
+
parses the response envelope, and POSTs ``{id, status, data, rpcError}``
|
|
11
|
+
to ``/api/ext/callback`` with ``X-Callback-Secret``. Video bytes take a
|
|
12
|
+
separate ``upload_request`` command (resumable upload, not batchexecute).
|
|
13
|
+
4. That HTTP handler resolves the pending future by id.
|
|
14
|
+
5. Inbound WS messages (``extension_ready``, ``session_captured``,
|
|
15
|
+
``session_lost``, ``pong``) update per-instance state. A fresh session
|
|
16
|
+
triggers an account refresh (credits + paygate tier) through the bridge.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import secrets
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from flow_engine.bridge import flow_rpc
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Minimum gap between account refreshes for the same session — the extension
|
|
33
|
+
# re-announces its session on every reconnect and on periodic re-bootstraps.
|
|
34
|
+
_ACCOUNT_REFRESH_MIN_INTERVAL_S = 60.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FlowClient:
|
|
38
|
+
"""Bridge client. One instance per pool entry (one per Chrome profile)."""
|
|
39
|
+
|
|
40
|
+
DEFAULT_TIMEOUT = 180.0 # seconds
|
|
41
|
+
|
|
42
|
+
def __init__(self, semaphore: Optional[asyncio.Semaphore] = None) -> None:
|
|
43
|
+
self._ws: Optional[Any] = None
|
|
44
|
+
self._pending: dict[str, asyncio.Future] = {}
|
|
45
|
+
self._callback_secret: str = secrets.token_urlsafe(32)
|
|
46
|
+
from flow_engine.config import POOL_MAX_CONCURRENCY
|
|
47
|
+
self._semaphore: asyncio.Semaphore = semaphore or asyncio.Semaphore(
|
|
48
|
+
POOL_MAX_CONCURRENCY
|
|
49
|
+
)
|
|
50
|
+
# Set by PoolInstance so _send can update in_flight without importing pool.
|
|
51
|
+
self._on_send_start: Any = lambda: None
|
|
52
|
+
self._on_send_end: Any = lambda: None
|
|
53
|
+
|
|
54
|
+
self._session_present: bool = False
|
|
55
|
+
self._session_captured_at: Optional[float] = None
|
|
56
|
+
self._last_account_refresh_at: Optional[float] = None
|
|
57
|
+
# Identity pushed by the extension from the page bootstrap data.
|
|
58
|
+
self._user_info: Optional[dict] = None
|
|
59
|
+
# Account facts from the credits RPC. paygate_tier is the integer tier
|
|
60
|
+
# id Flow's catalog keys its credit mapping by (1 / 2 / 3).
|
|
61
|
+
self._paygate_tier: Optional[int] = None
|
|
62
|
+
self._credits: Optional[int] = None
|
|
63
|
+
self._plan_code: Optional[int] = None
|
|
64
|
+
self._request_count = 0
|
|
65
|
+
self._success_count = 0
|
|
66
|
+
self._failed_count = 0
|
|
67
|
+
self._last_error: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
# ── connection ─────────────────────────────────────────────────────────
|
|
70
|
+
@property
|
|
71
|
+
def connected(self) -> bool:
|
|
72
|
+
return self._ws is not None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def callback_secret(self) -> str:
|
|
76
|
+
return self._callback_secret
|
|
77
|
+
|
|
78
|
+
def set_extension(self, ws: Any) -> None:
|
|
79
|
+
self._ws = ws
|
|
80
|
+
|
|
81
|
+
def clear_extension(self) -> None:
|
|
82
|
+
self._ws = None
|
|
83
|
+
self._clear_session()
|
|
84
|
+
for fut in self._pending.values():
|
|
85
|
+
if not fut.done():
|
|
86
|
+
fut.set_exception(ConnectionError("extension_disconnected"))
|
|
87
|
+
self._pending.clear()
|
|
88
|
+
|
|
89
|
+
def _clear_session(self) -> None:
|
|
90
|
+
self._session_present = False
|
|
91
|
+
self._user_info = None
|
|
92
|
+
self._paygate_tier = None
|
|
93
|
+
self._credits = None
|
|
94
|
+
self._plan_code = None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def user_info(self) -> Optional[dict]:
|
|
98
|
+
return self._user_info
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def paygate_tier(self) -> Optional[int]:
|
|
102
|
+
return self._paygate_tier
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def credits(self) -> Optional[int]:
|
|
106
|
+
return self._credits
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def session_present(self) -> bool:
|
|
110
|
+
return self._session_present
|
|
111
|
+
|
|
112
|
+
async def refresh_account(self) -> bool:
|
|
113
|
+
"""Resolve credits + paygate tier via the credits RPC. Returns True when
|
|
114
|
+
the tier is cached. Runs through the extension, so it needs a live
|
|
115
|
+
signed-in flow.google.com session."""
|
|
116
|
+
resp = await self.rpc(flow_rpc.RPC_ACCOUNT_CREDITS, flow_rpc.credits_payload(), timeout=30.0)
|
|
117
|
+
if resp.get("error"):
|
|
118
|
+
logger.warning("refresh_account failed: %s", resp["error"])
|
|
119
|
+
return False
|
|
120
|
+
account = flow_rpc.parse_credits(resp.get("data"))
|
|
121
|
+
tier = account.get("paygate_tier")
|
|
122
|
+
if tier is None:
|
|
123
|
+
logger.warning("refresh_account: no paygate tier in response %r", resp.get("data"))
|
|
124
|
+
return False
|
|
125
|
+
self._paygate_tier = tier
|
|
126
|
+
self._credits = account.get("credits")
|
|
127
|
+
self._plan_code = account.get("plan_code")
|
|
128
|
+
logger.info("account resolved tier=%s credits=%s", tier, self._credits)
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
# ── inbound handling ───────────────────────────────────────────────────
|
|
132
|
+
async def handle_message(self, data: dict) -> None:
|
|
133
|
+
t = data.get("type")
|
|
134
|
+
if t == "extension_ready":
|
|
135
|
+
self._session_present = bool(data.get("sessionPresent"))
|
|
136
|
+
logger.info("extension_ready sessionPresent=%s", self._session_present)
|
|
137
|
+
return
|
|
138
|
+
if t == "session_captured":
|
|
139
|
+
self._session_present = True
|
|
140
|
+
self._session_captured_at = time.time()
|
|
141
|
+
email = data.get("email")
|
|
142
|
+
if isinstance(email, str) and email:
|
|
143
|
+
self._user_info = {"email": email}
|
|
144
|
+
now = time.time()
|
|
145
|
+
last = self._last_account_refresh_at or 0.0
|
|
146
|
+
if self._paygate_tier is None or (now - last) > _ACCOUNT_REFRESH_MIN_INTERVAL_S:
|
|
147
|
+
self._last_account_refresh_at = now
|
|
148
|
+
logger.info("session_captured for %s", email or "<no email>")
|
|
149
|
+
# Don't await: the WS handler must stay responsive.
|
|
150
|
+
asyncio.create_task(self.refresh_account())
|
|
151
|
+
return
|
|
152
|
+
if t == "session_lost":
|
|
153
|
+
logger.warning("session_lost: %s", data.get("reason"))
|
|
154
|
+
self._clear_session()
|
|
155
|
+
return
|
|
156
|
+
if t == "pong":
|
|
157
|
+
return
|
|
158
|
+
# Inbound response (legacy path; production flow uses HTTP callback)
|
|
159
|
+
req_id = data.get("id")
|
|
160
|
+
if req_id and req_id in self._pending:
|
|
161
|
+
self._resolve(req_id, data)
|
|
162
|
+
|
|
163
|
+
def resolve_callback(self, data: dict) -> bool:
|
|
164
|
+
"""Called by the HTTP callback endpoint after validating the secret.
|
|
165
|
+
Returns True if a pending future matched."""
|
|
166
|
+
req_id = data.get("id")
|
|
167
|
+
if not req_id or req_id not in self._pending:
|
|
168
|
+
return False
|
|
169
|
+
self._resolve(req_id, data)
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
def _resolve(self, req_id: str, data: dict) -> None:
|
|
173
|
+
fut = self._pending.pop(req_id, None)
|
|
174
|
+
if not fut or fut.done():
|
|
175
|
+
return
|
|
176
|
+
status = data.get("status")
|
|
177
|
+
http_error = isinstance(status, int) and status >= 400
|
|
178
|
+
if http_error or data.get("error") or data.get("rpcError"):
|
|
179
|
+
self._failed_count += 1
|
|
180
|
+
msg = data.get("error") or data.get("rpcError") or f"API_{status}"
|
|
181
|
+
self._last_error = str(msg)[:200]
|
|
182
|
+
else:
|
|
183
|
+
self._success_count += 1
|
|
184
|
+
fut.set_result(data)
|
|
185
|
+
|
|
186
|
+
# ── outbound ──────────────────────────────────────────────────────────
|
|
187
|
+
async def notify(self, message: dict) -> bool:
|
|
188
|
+
"""Fire-and-forget WS push to the extension."""
|
|
189
|
+
if not self.connected or self._ws is None:
|
|
190
|
+
return False
|
|
191
|
+
try:
|
|
192
|
+
await self._ws.send(json.dumps(message))
|
|
193
|
+
return True
|
|
194
|
+
except Exception as exc: # noqa: BLE001
|
|
195
|
+
logger.warning("notify failed: %s", exc)
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
async def _send(self, method: str, params: dict, timeout: Optional[float] = None) -> dict:
|
|
199
|
+
if not self.connected:
|
|
200
|
+
return {"error": "extension_disconnected"}
|
|
201
|
+
# Per-instance concurrency semaphore — excess callers queue, no 503.
|
|
202
|
+
async with self._semaphore:
|
|
203
|
+
self._on_send_start()
|
|
204
|
+
try:
|
|
205
|
+
return await self._send_inner(method, params, timeout)
|
|
206
|
+
finally:
|
|
207
|
+
self._on_send_end()
|
|
208
|
+
|
|
209
|
+
async def _send_inner(
|
|
210
|
+
self, method: str, params: dict, timeout: Optional[float] = None
|
|
211
|
+
) -> dict:
|
|
212
|
+
req_id = str(uuid.uuid4())
|
|
213
|
+
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
214
|
+
self._pending[req_id] = fut
|
|
215
|
+
self._request_count += 1
|
|
216
|
+
payload = {"id": req_id, "method": method, "params": params}
|
|
217
|
+
try:
|
|
218
|
+
await self._ws.send(json.dumps(payload))
|
|
219
|
+
return await asyncio.wait_for(fut, timeout=timeout or self.DEFAULT_TIMEOUT)
|
|
220
|
+
except asyncio.TimeoutError:
|
|
221
|
+
self._pending.pop(req_id, None)
|
|
222
|
+
self._failed_count += 1
|
|
223
|
+
self._last_error = "timeout"
|
|
224
|
+
return {"error": "timeout"}
|
|
225
|
+
except Exception as exc: # noqa: BLE001
|
|
226
|
+
self._pending.pop(req_id, None)
|
|
227
|
+
self._failed_count += 1
|
|
228
|
+
self._last_error = str(exc)
|
|
229
|
+
return {"error": str(exc)}
|
|
230
|
+
|
|
231
|
+
async def rpc(
|
|
232
|
+
self,
|
|
233
|
+
rpc_id: str,
|
|
234
|
+
payload: Any,
|
|
235
|
+
*,
|
|
236
|
+
captcha_action: Optional[str] = None,
|
|
237
|
+
captcha_path: Optional[list[int]] = None,
|
|
238
|
+
captcha_paths: Optional[list[list[int]]] = None,
|
|
239
|
+
source_path: Optional[str] = None,
|
|
240
|
+
timeout: Optional[float] = None,
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""Run one batchexecute RPC through the extension.
|
|
243
|
+
|
|
244
|
+
Returns ``{"data": <parsed rpc result>, "raw": <envelope>}`` or
|
|
245
|
+
``{"error": <str>, "raw": <envelope>}``. ``captcha_action`` makes the
|
|
246
|
+
extension solve reCAPTCHA first and write the token at every path in
|
|
247
|
+
``captcha_paths`` (lists of array indices into ``payload``;
|
|
248
|
+
``captcha_path`` is the single-path shorthand).
|
|
249
|
+
"""
|
|
250
|
+
params: dict[str, Any] = {"rpcId": rpc_id, "payload": payload}
|
|
251
|
+
if captcha_action:
|
|
252
|
+
params["captchaAction"] = captcha_action
|
|
253
|
+
params["captchaPaths"] = captcha_paths or ([captcha_path] if captcha_path else [])
|
|
254
|
+
if source_path:
|
|
255
|
+
params["sourcePath"] = source_path
|
|
256
|
+
resp = await self._send("rpc_request", params, timeout=timeout)
|
|
257
|
+
if not isinstance(resp, dict):
|
|
258
|
+
return {"error": "invalid_bridge_response", "raw": resp}
|
|
259
|
+
if resp.get("error"):
|
|
260
|
+
return {"error": str(resp["error"]), "raw": resp}
|
|
261
|
+
if resp.get("rpcError"):
|
|
262
|
+
return {"error": f"rpc_{rpc_id}_failed: {resp['rpcError']}", "raw": resp}
|
|
263
|
+
status = resp.get("status")
|
|
264
|
+
if isinstance(status, int) and status >= 400:
|
|
265
|
+
return {"error": f"rpc_{rpc_id}_http_{status}", "raw": resp}
|
|
266
|
+
return {"data": resp.get("data"), "raw": resp}
|
|
267
|
+
|
|
268
|
+
async def upload_video(
|
|
269
|
+
self,
|
|
270
|
+
project_id: str,
|
|
271
|
+
file_name: str,
|
|
272
|
+
content_type: str,
|
|
273
|
+
data_base64: str,
|
|
274
|
+
timeout: Optional[float] = None,
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""Run Flow's resumable video upload through the extension (start +
|
|
277
|
+
upload/finalize with the user's cookies). Returns ``{"data": <finalize
|
|
278
|
+
JSON>, "raw"}`` or ``{"error", "raw"}``. Bytes travel as base64 since
|
|
279
|
+
the bridge is JSON."""
|
|
280
|
+
resp = await self._send(
|
|
281
|
+
"upload_request",
|
|
282
|
+
{
|
|
283
|
+
"projectId": project_id,
|
|
284
|
+
"fileName": file_name,
|
|
285
|
+
"contentType": content_type,
|
|
286
|
+
"dataBase64": data_base64,
|
|
287
|
+
},
|
|
288
|
+
timeout=timeout or self.DEFAULT_TIMEOUT,
|
|
289
|
+
)
|
|
290
|
+
if not isinstance(resp, dict):
|
|
291
|
+
return {"error": "invalid_bridge_response", "raw": resp}
|
|
292
|
+
if resp.get("error"):
|
|
293
|
+
return {"error": str(resp["error"]), "raw": resp}
|
|
294
|
+
status = resp.get("status")
|
|
295
|
+
if isinstance(status, int) and status >= 400:
|
|
296
|
+
return {"error": f"upload_http_{status}", "raw": resp}
|
|
297
|
+
return {"data": resp.get("data"), "raw": resp}
|
|
298
|
+
|
|
299
|
+
# ── observability ─────────────────────────────────────────────────────
|
|
300
|
+
@property
|
|
301
|
+
def ws_stats(self) -> dict:
|
|
302
|
+
age = (
|
|
303
|
+
int(time.time() - self._session_captured_at)
|
|
304
|
+
if self._session_captured_at is not None else None
|
|
305
|
+
)
|
|
306
|
+
return {
|
|
307
|
+
"connected": self.connected,
|
|
308
|
+
"session_present": self._session_present,
|
|
309
|
+
"session_age_s": age,
|
|
310
|
+
"pending": len(self._pending),
|
|
311
|
+
"request_count": self._request_count,
|
|
312
|
+
"success_count": self._success_count,
|
|
313
|
+
"failed_count": self._failed_count,
|
|
314
|
+
"last_error": self._last_error,
|
|
315
|
+
}
|