davinci-resolve-mcp 2.67.1 → 2.68.0
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.
- package/CHANGELOG.md +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Client side of the in-app bridge: a proxy that looks exactly like Resolve.
|
|
2
|
+
|
|
3
|
+
The point is that **existing call sites do not change**. Code written against the
|
|
4
|
+
native API —
|
|
5
|
+
|
|
6
|
+
resolve.GetProjectManager().GetCurrentProject().GetMediaPool()
|
|
7
|
+
|
|
8
|
+
— works verbatim against a `BridgeProxy`. Each attribute access returns a
|
|
9
|
+
callable that signs a request, sends it over loopback to the script running
|
|
10
|
+
inside Resolve, and rehydrates the answer. Live Resolve objects come back as
|
|
11
|
+
opaque handles wrapped in further proxies, so chains of arbitrary depth work.
|
|
12
|
+
|
|
13
|
+
## Why a proxy rather than a mapping layer
|
|
14
|
+
|
|
15
|
+
The MCP server already holds the thorough layer: confirm tokens, path
|
|
16
|
+
allowlists, timeline versioning, dry-run/confirm gates, the destructive-action
|
|
17
|
+
registry. All of it sits *above* this and keeps working unchanged. A mapping
|
|
18
|
+
layer would mean a second validation surface at the bridge boundary, and two
|
|
19
|
+
safety layers that must agree are a drift risk rather than extra safety.
|
|
20
|
+
|
|
21
|
+
## Transparency includes the ugly parts
|
|
22
|
+
|
|
23
|
+
`False` is returned as `False`, not raised. Resolve signals failure that way far
|
|
24
|
+
more often than it raises, and every existing call site was written knowing it.
|
|
25
|
+
Converting it here would silently change the meaning of code that already handles
|
|
26
|
+
it correctly. The bridge's *named* operations do apply `false_is_error`; the
|
|
27
|
+
proxy deliberately does not.
|
|
28
|
+
|
|
29
|
+
## What it does not hide
|
|
30
|
+
|
|
31
|
+
- **Latency.** Every call is a loopback round trip, measured at ~0.35 ms. Fine
|
|
32
|
+
for hundreds of calls, noticeable for tens of thousands — enumerating a large
|
|
33
|
+
timeline item-by-item is the shape that bites.
|
|
34
|
+
- **Bridge absence.** If the in-Resolve script is not running, construction fails
|
|
35
|
+
with a clear message rather than pretending; there is nothing to fall back to.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
import secrets
|
|
44
|
+
import socket
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
import uuid
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Dict, List, Optional
|
|
50
|
+
|
|
51
|
+
from src.utils import resolve_bridge as _bridge
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("resolve-mcp.bridge-client")
|
|
54
|
+
|
|
55
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config/davinci-resolve-mcp/bridge.json"
|
|
56
|
+
#: Env override, so a caller can point at a non-default bridge without editing code.
|
|
57
|
+
ENV_CONFIG_PATH = "DAVINCI_RESOLVE_BRIDGE_CONFIG"
|
|
58
|
+
#: Opt-in. Absent means "do not try the bridge", so nothing changes for existing
|
|
59
|
+
#: installs until someone asks for it.
|
|
60
|
+
ENV_ENABLE = "DAVINCI_RESOLVE_BRIDGE"
|
|
61
|
+
|
|
62
|
+
#: Operations this client cannot work without. The in-Resolve script is a *copy*
|
|
63
|
+
#: taken at install time, so it can be older than the client — and a stale bridge
|
|
64
|
+
#: must fail at connect with an actionable message, not mid-session on every
|
|
65
|
+
#: attribute access. `list_methods` is required because without it the proxy
|
|
66
|
+
#: cannot answer `hasattr` truthfully, which silently breaks capability detection.
|
|
67
|
+
REQUIRED_BRIDGE_OPERATIONS = ("call", "list_methods", "release_handles", "health")
|
|
68
|
+
|
|
69
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BridgeUnavailable(RuntimeError):
|
|
73
|
+
"""The in-Resolve bridge could not be reached. Not a fallback condition."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BridgeCallError(RuntimeError):
|
|
77
|
+
"""The bridge refused or failed a call, carrying its stable code."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None) -> None:
|
|
80
|
+
super().__init__(f"{code}: {message}")
|
|
81
|
+
self.code = code
|
|
82
|
+
self.message = message
|
|
83
|
+
self.details = details or {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def config_path() -> Path:
|
|
87
|
+
override = os.environ.get(ENV_CONFIG_PATH)
|
|
88
|
+
return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def bridge_enabled() -> bool:
|
|
92
|
+
"""Opt-in only — an unset env var must not change existing behaviour."""
|
|
93
|
+
return str(os.environ.get(ENV_ENABLE, "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BridgeTransport:
|
|
97
|
+
"""One authenticated loopback connection per request, serialised."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, config: Dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
|
100
|
+
self.host = config["host"]
|
|
101
|
+
self.port = int(config["port"])
|
|
102
|
+
self._token = config["token"]
|
|
103
|
+
self.timeout = float(timeout)
|
|
104
|
+
#: Propagated to proxies decoded from this transport's replies.
|
|
105
|
+
self.strict_attributes = True
|
|
106
|
+
#: type name -> frozenset of method names, scoped to **this** transport.
|
|
107
|
+
#: Per-transport rather than per-class because the method set is the
|
|
108
|
+
#: answer to "what does this Resolve build have", and two builds can be
|
|
109
|
+
#: bridged from one process — the differential harness does exactly that.
|
|
110
|
+
#: A shared cache would let free 21's surface answer for Studio 19, which
|
|
111
|
+
#: is the precise question `list_methods` exists to get right.
|
|
112
|
+
self.method_cache: Dict[str, frozenset] = {}
|
|
113
|
+
self.cache_lock = threading.Lock()
|
|
114
|
+
# The in-Resolve side serialises native calls anyway; serialising here too
|
|
115
|
+
# keeps request/response pairing simple and matches the _bridge_lock
|
|
116
|
+
# discipline the rest of the server already follows.
|
|
117
|
+
self._lock = threading.RLock()
|
|
118
|
+
|
|
119
|
+
def request(self, operation: str, arguments: Dict[str, Any]) -> Any:
|
|
120
|
+
payload = {
|
|
121
|
+
"protocol": _bridge.PROTOCOL_VERSION,
|
|
122
|
+
"id": str(uuid.uuid4()),
|
|
123
|
+
"timestamp": int(time.time()),
|
|
124
|
+
"nonce": secrets.token_urlsafe(24),
|
|
125
|
+
"operation": operation,
|
|
126
|
+
"arguments": arguments,
|
|
127
|
+
}
|
|
128
|
+
payload["signature"] = _bridge.sign_request(self._token, payload)
|
|
129
|
+
line = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
|
|
130
|
+
if len(line) > _bridge.MAX_REQUEST_BYTES:
|
|
131
|
+
raise BridgeCallError("request_too_large", "request exceeds the bridge limit")
|
|
132
|
+
|
|
133
|
+
with self._lock:
|
|
134
|
+
try:
|
|
135
|
+
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
|
|
136
|
+
sock.settimeout(self.timeout)
|
|
137
|
+
sock.sendall(line)
|
|
138
|
+
raw = sock.makefile("rb").readline(_bridge.MAX_REQUEST_BYTES + 1)
|
|
139
|
+
except (TimeoutError, socket.timeout) as exc:
|
|
140
|
+
raise BridgeCallError(
|
|
141
|
+
"bridge_timeout",
|
|
142
|
+
"Resolve did not answer in time — check for an open modal dialog, "
|
|
143
|
+
"which blocks its scripting API entirely",
|
|
144
|
+
) from exc
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
raise BridgeUnavailable(
|
|
147
|
+
"Cannot reach the in-Resolve bridge on "
|
|
148
|
+
f"{self.host}:{self.port} ({exc}). Start it from "
|
|
149
|
+
"Workspace > Scripts > resolve_bridge."
|
|
150
|
+
) from exc
|
|
151
|
+
|
|
152
|
+
if not raw:
|
|
153
|
+
raise BridgeCallError("bridge_protocol_error", "bridge closed without replying")
|
|
154
|
+
try:
|
|
155
|
+
response = json.loads(raw.decode("utf-8"))
|
|
156
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
157
|
+
raise BridgeCallError("bridge_protocol_error", "bridge returned invalid JSON") from exc
|
|
158
|
+
if not isinstance(response, dict):
|
|
159
|
+
raise BridgeCallError("bridge_protocol_error", "bridge returned a non-object")
|
|
160
|
+
if response.get("id") != payload["id"]:
|
|
161
|
+
# Mismatched ids mean interleaved responses; failing loudly beats
|
|
162
|
+
# handing a caller another request's answer.
|
|
163
|
+
raise BridgeCallError("bridge_protocol_error", "bridge response id does not match")
|
|
164
|
+
if response.get("ok") is not True:
|
|
165
|
+
error = response.get("error") or {}
|
|
166
|
+
raise BridgeCallError(
|
|
167
|
+
str(error.get("code", "bridge_error")),
|
|
168
|
+
str(error.get("message", "the bridge refused the call")),
|
|
169
|
+
error.get("details") if isinstance(error.get("details"), dict) else {},
|
|
170
|
+
)
|
|
171
|
+
return response.get("result")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _encode_argument(value: Any) -> Any:
|
|
175
|
+
"""Send a proxy back as the handle it stands for."""
|
|
176
|
+
if isinstance(value, BridgeProxy):
|
|
177
|
+
return {"__handle__": value._handle}
|
|
178
|
+
if isinstance(value, dict):
|
|
179
|
+
return {k: _encode_argument(v) for k, v in value.items()}
|
|
180
|
+
if isinstance(value, (list, tuple)):
|
|
181
|
+
return [_encode_argument(v) for v in value]
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode_value(transport: BridgeTransport, value: Any) -> Any:
|
|
186
|
+
"""Rehydrate a reply: handles become proxies, recursively."""
|
|
187
|
+
if isinstance(value, dict):
|
|
188
|
+
handle = value.get("__handle__")
|
|
189
|
+
if handle is not None:
|
|
190
|
+
return BridgeProxy(transport, handle, type_name=value.get("__type__"),
|
|
191
|
+
shape=value.get("__shape__"),
|
|
192
|
+
strict_attributes=getattr(transport, "strict_attributes", True))
|
|
193
|
+
return {k: _decode_value(transport, v) for k, v in value.items()}
|
|
194
|
+
if isinstance(value, list):
|
|
195
|
+
return [_decode_value(transport, v) for v in value]
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _BoundMethod:
|
|
200
|
+
"""One callable method on a proxied object."""
|
|
201
|
+
|
|
202
|
+
__slots__ = ("_transport", "_handle", "_name")
|
|
203
|
+
|
|
204
|
+
def __init__(self, transport: BridgeTransport, handle: str, name: str) -> None:
|
|
205
|
+
self._transport = transport
|
|
206
|
+
self._handle = handle
|
|
207
|
+
self._name = name
|
|
208
|
+
|
|
209
|
+
def __call__(self, *args: Any) -> Any:
|
|
210
|
+
result = self._transport.request(
|
|
211
|
+
"call",
|
|
212
|
+
{"target": self._handle, "method": self._name,
|
|
213
|
+
"args": [_encode_argument(a) for a in args]},
|
|
214
|
+
)
|
|
215
|
+
return _decode_value(self._transport, (result or {}).get("value"))
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
218
|
+
return f"<bridge method {self._handle}.{self._name}>"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BridgeProxy:
|
|
222
|
+
"""Stands in for a live Resolve object reached over the bridge.
|
|
223
|
+
|
|
224
|
+
Attribute access yields a callable, so any chain the existing code uses
|
|
225
|
+
behaves as it would against the native object. Return values are never
|
|
226
|
+
cached: Resolve's own proxies are views onto mutable state, and caching
|
|
227
|
+
would hand callers a stale answer.
|
|
228
|
+
|
|
229
|
+
**Attribute existence IS checked**, and that is not optional. A naive
|
|
230
|
+
`__getattr__` proxy answers `hasattr(obj, anything)` with True, so
|
|
231
|
+
capability detection — `getattr(timeline, "CreateMagicMask", None)`, of which
|
|
232
|
+
this codebase has ~50 instances — silently passes for methods the running
|
|
233
|
+
Resolve build does not have. The call then fails somewhere else with an
|
|
234
|
+
unrelated error instead of refusing cleanly at the check.
|
|
235
|
+
|
|
236
|
+
So an unknown name raises `AttributeError`, exactly as the native object
|
|
237
|
+
would.
|
|
238
|
+
|
|
239
|
+
**Method sets are cached by provenance, never by type name.** Every live
|
|
240
|
+
Resolve object reports `type(obj).__name__ == "PyRemoteObject"` — measured on
|
|
241
|
+
21.0.3.7, where the Resolve root carries 34 methods, Project 49 and
|
|
242
|
+
TimelineItem 88, all under that single name. Caching on it let the first
|
|
243
|
+
object touched define `hasattr` for every object after it: `GetProjectManager()`
|
|
244
|
+
came back holding the *Resolve root's* method set, so `.GetCurrentProject()`
|
|
245
|
+
raised AttributeError and any chain longer than one call was broken. Nothing
|
|
246
|
+
caught it in unit tests because the fakes have distinct class names.
|
|
247
|
+
|
|
248
|
+
Provenance — "whatever `resolve.GetProjectManager` returns" — discriminates
|
|
249
|
+
properly and costs the bridge nothing. It is a *hint* (it assumes one method
|
|
250
|
+
always returns one shape), so **absence is re-verified against the object
|
|
251
|
+
itself** before raising. A wrong or stale hint therefore cannot manufacture a
|
|
252
|
+
missing method, which is the only direction that breaks capability detection;
|
|
253
|
+
in the other direction it yields a method that fails when called, which is
|
|
254
|
+
what native Resolve does for every name anyway.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
__slots__ = ("_transport", "_handle", "_type_name", "_shape", "_strict")
|
|
258
|
+
|
|
259
|
+
def __init__(self, transport: BridgeTransport, handle: str, *, type_name: Optional[str] = None,
|
|
260
|
+
shape: Optional[str] = None, strict_attributes: bool = True) -> None:
|
|
261
|
+
object.__setattr__(self, "_transport", transport)
|
|
262
|
+
object.__setattr__(self, "_handle", handle)
|
|
263
|
+
object.__setattr__(self, "_type_name", type_name)
|
|
264
|
+
object.__setattr__(self, "_shape", shape)
|
|
265
|
+
# `strict_attributes=False` permits any name, i.e. the naive-proxy
|
|
266
|
+
# behaviour where hasattr is always True. Only for talking to a bridge
|
|
267
|
+
# too old to support `list_methods`; `connect()` refuses those outright,
|
|
268
|
+
# so this is an explicit opt-in for diagnostics, never the default.
|
|
269
|
+
object.__setattr__(self, "_strict", strict_attributes)
|
|
270
|
+
|
|
271
|
+
def _cache_key(self) -> str:
|
|
272
|
+
# Never `_type_name`: it is `PyRemoteObject` for every live object.
|
|
273
|
+
return self._shape or self._handle
|
|
274
|
+
|
|
275
|
+
def _methods(self, *, refresh: bool = False) -> frozenset:
|
|
276
|
+
# Cached on the transport, so sibling objects from one bridge share a
|
|
277
|
+
# single fetch while a second bridge keeps its own answer.
|
|
278
|
+
cache = self._transport.method_cache
|
|
279
|
+
key = self._cache_key()
|
|
280
|
+
if not refresh:
|
|
281
|
+
cached = cache.get(key)
|
|
282
|
+
if cached is not None:
|
|
283
|
+
return cached
|
|
284
|
+
result = self._transport.request("list_methods", {"target": self._handle}) or {}
|
|
285
|
+
names = frozenset(result.get("methods") or ())
|
|
286
|
+
with self._transport.cache_lock:
|
|
287
|
+
cache[result.get("shape") or key] = names
|
|
288
|
+
cache[key] = names
|
|
289
|
+
return names
|
|
290
|
+
|
|
291
|
+
def __getattr__(self, name: str) -> Any:
|
|
292
|
+
if name.startswith("__") and name.endswith("__"):
|
|
293
|
+
# Let normal dunder lookup fail rather than sending it over the wire.
|
|
294
|
+
raise AttributeError(name)
|
|
295
|
+
if self._strict and name not in self._methods():
|
|
296
|
+
# Only now pay for a fresh look. Absence is the answer that has to be
|
|
297
|
+
# right — it is what `getattr(tl, "CreateMagicMask", None)` acts on —
|
|
298
|
+
# and the cached set is keyed on a provenance hint, so it is not
|
|
299
|
+
# allowed to be the last word on it.
|
|
300
|
+
if name not in self._methods(refresh=True):
|
|
301
|
+
# Not a method. It may still be an API *constant*: `EXPORT_AAF`,
|
|
302
|
+
# `AUDIO_SYNC_*` and friends are plain attributes that `dir()`
|
|
303
|
+
# does not list, and returning them is the difference between a
|
|
304
|
+
# working conform export and one that silently posts the string
|
|
305
|
+
# "EXPORT_AAF" to Resolve.
|
|
306
|
+
probe = self._transport.request("get_attribute",
|
|
307
|
+
{"target": self._handle, "name": name}) or {}
|
|
308
|
+
if probe.get("kind") == "value":
|
|
309
|
+
return _decode_value(self._transport, probe.get("value"))
|
|
310
|
+
# Matches native semantics: hasattr() is False, getattr(..., None)
|
|
311
|
+
# is None, and a capability check refuses instead of guessing.
|
|
312
|
+
# A `callable` answer lands here too — Resolve fabricates one for
|
|
313
|
+
# any name, so it is not evidence the attribute exists.
|
|
314
|
+
raise AttributeError(
|
|
315
|
+
f"{self._shape or self._type_name or 'Resolve object'} has no attribute "
|
|
316
|
+
f"{name!r} in this Resolve build"
|
|
317
|
+
)
|
|
318
|
+
return _BoundMethod(self._transport, self._handle, name)
|
|
319
|
+
|
|
320
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
321
|
+
return f"<BridgeProxy {self._handle}>"
|
|
322
|
+
|
|
323
|
+
# -- lifecycle helpers, not part of the Resolve API -------------------
|
|
324
|
+
|
|
325
|
+
def bridge_health(self) -> Dict[str, Any]:
|
|
326
|
+
"""The bridge's own health payload — not a Resolve method."""
|
|
327
|
+
return self._transport.request("health", {})
|
|
328
|
+
|
|
329
|
+
def bridge_release_handles(self, handles: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
330
|
+
"""Drop handles the bridge is holding. Long sessions should call this."""
|
|
331
|
+
return self._transport.request(
|
|
332
|
+
"release_handles", {} if handles is None else {"handles": handles}
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def bridge_shutdown(self) -> Dict[str, Any]:
|
|
336
|
+
"""Stop the in-Resolve listener so Workspace ▸ Scripts can start it again.
|
|
337
|
+
|
|
338
|
+
Does not wait: the reply is written before the listener goes away, and
|
|
339
|
+
there is nothing to come back to.
|
|
340
|
+
"""
|
|
341
|
+
return self._transport.request("shutdown", {})
|
|
342
|
+
|
|
343
|
+
def bridge_reload(self, *, wait: bool = True, timeout: float = 30.0,
|
|
344
|
+
poll_seconds: float = 0.1) -> Dict[str, Any]:
|
|
345
|
+
"""Re-import the in-Resolve runtime from disk, in place.
|
|
346
|
+
|
|
347
|
+
This is what makes the bridge iterable: re-run the installer, call this,
|
|
348
|
+
and the new code is live without touching Resolve's UI.
|
|
349
|
+
|
|
350
|
+
Waiting is keyed on the surface's `session` id rather than on health
|
|
351
|
+
merely answering, because for a short window after the reply the *old*
|
|
352
|
+
listener is still accepting connections — a naive "wait until health
|
|
353
|
+
works" returns immediately, against the code being replaced.
|
|
354
|
+
|
|
355
|
+
**Every handle from before is dead.** The new session mints new ids, so
|
|
356
|
+
existing proxies raise `stale_handle` and must be re-fetched from a root.
|
|
357
|
+
"""
|
|
358
|
+
before = (self.bridge_health() or {}).get("session")
|
|
359
|
+
result = dict(self._transport.request("reload", {}) or {})
|
|
360
|
+
if not wait:
|
|
361
|
+
return result
|
|
362
|
+
deadline = time.monotonic() + timeout
|
|
363
|
+
last_error: Optional[Exception] = None
|
|
364
|
+
while time.monotonic() < deadline:
|
|
365
|
+
time.sleep(poll_seconds)
|
|
366
|
+
try:
|
|
367
|
+
health = self.bridge_health() or {}
|
|
368
|
+
except (BridgeUnavailable, BridgeCallError) as exc:
|
|
369
|
+
last_error = exc # expected while the listener is down
|
|
370
|
+
continue
|
|
371
|
+
session = health.get("session")
|
|
372
|
+
if session and session != before:
|
|
373
|
+
self._transport.method_cache.clear()
|
|
374
|
+
return {**result, "session": session, "reloaded": True,
|
|
375
|
+
"operations": health.get("operations")}
|
|
376
|
+
raise BridgeCallError(
|
|
377
|
+
"reload_timeout",
|
|
378
|
+
f"the bridge did not come back within {timeout:g}s"
|
|
379
|
+
+ (f" (last error: {last_error})" if last_error else "")
|
|
380
|
+
+ ". Check the script's output inside Resolve — a reload that fails to "
|
|
381
|
+
"import keeps serving the previous runtime.",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def connect(*, timeout: float = DEFAULT_TIMEOUT_SECONDS, require_enabled: bool = True) -> BridgeProxy:
|
|
386
|
+
"""Return a Resolve-shaped proxy backed by the in-app bridge.
|
|
387
|
+
|
|
388
|
+
Raises `BridgeUnavailable` rather than returning None so a caller cannot
|
|
389
|
+
accidentally treat "no bridge" as "no Resolve" and carry on.
|
|
390
|
+
"""
|
|
391
|
+
if require_enabled and not bridge_enabled():
|
|
392
|
+
raise BridgeUnavailable(
|
|
393
|
+
f"The in-app bridge is opt-in: set {ENV_ENABLE}=1 to use it."
|
|
394
|
+
)
|
|
395
|
+
path = config_path()
|
|
396
|
+
try:
|
|
397
|
+
config = _bridge.load_config(str(path))
|
|
398
|
+
except _bridge.BridgeConfigError as exc:
|
|
399
|
+
raise BridgeUnavailable(
|
|
400
|
+
f"Bridge configuration unusable ({exc}). Run "
|
|
401
|
+
"`python scripts/install_resolve_bridge.py` first."
|
|
402
|
+
) from exc
|
|
403
|
+
|
|
404
|
+
transport = BridgeTransport(config, timeout=timeout)
|
|
405
|
+
proxy = BridgeProxy(transport, "resolve", type_name="Resolve", shape="resolve")
|
|
406
|
+
# Prove the far end is alive now, rather than failing on the first real call
|
|
407
|
+
# somewhere deep in a tool.
|
|
408
|
+
health = transport.request("health", {})
|
|
409
|
+
available = set((health or {}).get("operations") or ())
|
|
410
|
+
missing = [op for op in REQUIRED_BRIDGE_OPERATIONS if op not in available]
|
|
411
|
+
if missing:
|
|
412
|
+
raise BridgeUnavailable(
|
|
413
|
+
"The running in-Resolve bridge is older than this client and is "
|
|
414
|
+
f"missing {missing}. The script inside Resolve is a copy taken at "
|
|
415
|
+
"install time, so it does not update when the repository does. Fix: "
|
|
416
|
+
"re-run `python scripts/install_resolve_bridge.py`, then stop and "
|
|
417
|
+
"restart Workspace > Scripts > resolve_bridge."
|
|
418
|
+
)
|
|
419
|
+
logger.info(
|
|
420
|
+
"connected to the in-Resolve bridge: %s %s (%s)",
|
|
421
|
+
(health or {}).get("product"), (health or {}).get("version"), (health or {}).get("edition"),
|
|
422
|
+
)
|
|
423
|
+
return proxy
|