davinci-resolve-mcp 2.204.0 → 2.205.1
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 +95 -0
- package/README.md +36 -1
- package/README.zh-CN.md +16 -2
- package/docs/SKILL.md +61 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/install_resolve_bridge.py +111 -13
- package/src/granular/common.py +1 -1
- package/src/server.py +178 -6
- package/src/utils/operation_result.py +377 -0
- package/src/utils/readback.py +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,101 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.205.1 — #182: the bridge preflight checks both halves of Resolve's Python lookup
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A `PYTHON3HOME` prefix with a dylib but no `bin/python3` is no longer
|
|
10
|
+
reported as usable.** fusionscript.so does two things with the prefix — runs
|
|
11
|
+
`<prefix>/bin/python3` and dlopens `<prefix>/lib/libpython3.X.dylib`, whose
|
|
12
|
+
strings sit adjacent in the binary — and the preflight validated only the
|
|
13
|
+
second. It answered `python3_home.usable: true` and
|
|
14
|
+
`resolve_will_list_python_scripts: true` while Resolve listed **zero** Python
|
|
15
|
+
scripts and logged nothing, which is the worst shape this failure can take:
|
|
16
|
+
the user has been told the thing is configured correctly, so the real cause
|
|
17
|
+
is the last place they look. Reported in #182, with the root cause and the
|
|
18
|
+
fix both correct as filed.
|
|
19
|
+
- **The interpreter has to be there under the unversioned name.** `python3` is
|
|
20
|
+
the literal name in the binary, and that is what makes this trap easy to hit:
|
|
21
|
+
a Homebrew framework prefix carries a perfectly good
|
|
22
|
+
`lib/libpython3.13.dylib` next to a `bin/` that has `python3.13` and no
|
|
23
|
+
`python3`. It is formula-dependent — `python@3.14` ships one, `python@3.11`
|
|
24
|
+
and `python@3.13` do not — so the same "Homebrew Python" advice works on one
|
|
25
|
+
machine and silently fails on the next. `framework_pythons()` has always
|
|
26
|
+
required `bin/python3`; this is the same rule applied to the route that
|
|
27
|
+
skipped it.
|
|
28
|
+
- **A set-but-unusable `PYTHON3HOME` is now called out even when another
|
|
29
|
+
discovery route exists.** Resolve reads it first, and whether it falls back
|
|
30
|
+
after choosing a prefix it cannot use is inferred from string adjacency
|
|
31
|
+
rather than established — so resting a clean bill of health on a route
|
|
32
|
+
Resolve may never reach is the same false all-clear in a new place. The
|
|
33
|
+
preflight names the mismatch, prints the exact `ln -s` that repairs it, and
|
|
34
|
+
suggests `launchctl unsetenv` as the alternative.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **The advice says that `launchctl setenv` does not survive a reboot.** A
|
|
39
|
+
bridge that listed for weeks and then stopped, with no error anywhere, is
|
|
40
|
+
usually that, and nobody connects it back to a step they ran a month
|
|
41
|
+
earlier. `sudo ln -s "$(command -v python3)" /usr/local/bin/python3` is
|
|
42
|
+
offered as the persistent alternative, with the `PATH` caveat — presented
|
|
43
|
+
alongside the sudo-free route rather than replacing it, since avoiding a
|
|
44
|
+
system-wide install is the whole point of the #143 fix.
|
|
45
|
+
|
|
46
|
+
## What's New in v2.205.0 — a standard operation envelope on every tool result
|
|
47
|
+
|
|
48
|
+
Adapted from the design contributed in PR #181.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`_operation` on every compound tool return.** Agents orchestrating
|
|
53
|
+
multi-turn edits had to answer the same three questions after every call —
|
|
54
|
+
did it happen, was it verified, what changed — in a different vocabulary per
|
|
55
|
+
tool (`readback.missing`, `succeeded`/`failed`, `partial`,
|
|
56
|
+
`status: "confirmation_required"`). Those are now normalized into one block:
|
|
57
|
+
`status` (`success` / `partial` / `blocked` / `failed`), `operation`,
|
|
58
|
+
`execution_id`, `verification`, `changes` and `warnings`.
|
|
59
|
+
- **A contradiction stays its own verification status.** "Resolve reported
|
|
60
|
+
success and the readback disagrees" is a different thing for a caller to act
|
|
61
|
+
on than "the call failed", and this repo's most valuable reliability signal;
|
|
62
|
+
it does not collapse into a failure. `readback.as_verification_dict` renders
|
|
63
|
+
a `verify_by_readback` result in the same shape.
|
|
64
|
+
- **`setup(action="set_defaults", params={"result_envelope": ...})`** — `dual`
|
|
65
|
+
(default), `pure`, or `legacy`, persisted to `logs/server-preferences.json`
|
|
66
|
+
and restored at startup. Override per call with `params={"envelope": ...}` or
|
|
67
|
+
per process with `RESOLVE_MCP_RESULT_ENVELOPE`.
|
|
68
|
+
|
|
69
|
+
### Notes on the adaptation
|
|
70
|
+
|
|
71
|
+
- **The envelope is namespaced, not flattened.** Five of its key names —
|
|
72
|
+
`status` (22 sites), `operation` (20), `warnings` (15), `result` (8),
|
|
73
|
+
`changes` (2) — are already domain keys on this server, so merging the
|
|
74
|
+
envelope into the top level silently rewrote them: `resolve_control`
|
|
75
|
+
`job_status` reported `"success"` instead of `"done"` (an agent polling a
|
|
76
|
+
job would never see it finish), a confirm gate's `"confirmation_required"`
|
|
77
|
+
became `"blocked"` — renaming the very signal the envelope exists to make
|
|
78
|
+
unambiguous — and a transcription's `"Transcribed"` was lost. The payload is
|
|
79
|
+
now passed through untouched and the envelope rides under `_operation`,
|
|
80
|
+
following the existing `_versioning` convention. A guard test fails the
|
|
81
|
+
suite if any module starts returning `_operation` as a domain key.
|
|
82
|
+
- **An unreported delta is absent, not zero.** `changes: {}` reads as "this
|
|
83
|
+
operation changed nothing", which is false about an edit that simply never
|
|
84
|
+
declared its deltas — the silent-lie class this codebase treats as a bug.
|
|
85
|
+
The key is omitted instead, and `verification: "unverified"` likewise means
|
|
86
|
+
"no evidence reported", not "checked and clean".
|
|
87
|
+
- **Status inference keys only on conventions this repo actually uses.**
|
|
88
|
+
`blocked` reads like a gate flag but is a domain key holding the *list of
|
|
89
|
+
targets that could not be resolved*; a successful `bulk_match_to_hero` dry
|
|
90
|
+
run carries a non-empty one. Reading it as a gate reported a confirmation
|
|
91
|
+
that was never requested.
|
|
92
|
+
- **Semantic deltas are declared by the action, not guessed from key names.**
|
|
93
|
+
A mapping like `properties_restored_items` → `properties_updated` turns a
|
|
94
|
+
ripple insert's internal bookkeeping into an edit the caller never made.
|
|
95
|
+
`timeline.ripple_insert` declares its own; the rest report none rather than
|
|
96
|
+
a fabricated zero.
|
|
97
|
+
- Verified through the real stdio JSON-RPC tool layer, not just at module
|
|
98
|
+
level: 36 tools register, the envelope arrives, the payload is intact.
|
|
99
|
+
|
|
5
100
|
## What's New in v2.204.0 — #179: the managed install can boot the advanced server
|
|
6
101
|
|
|
7
102
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
|
@@ -80,6 +80,20 @@ never sees your shell's environment. Restart Resolve afterwards. A Lua canary is
|
|
|
80
80
|
installed alongside so you can tell "Python not detected" apart from a wrong
|
|
81
81
|
folder.
|
|
82
82
|
|
|
83
|
+
Two things that bite (#182). The prefix must contain **both**
|
|
84
|
+
`lib/libpython3.X.dylib` and `bin/python3` under that exact **unversioned**
|
|
85
|
+
name — Homebrew's framework builds often ship only `bin/python3.13`, which is
|
|
86
|
+
half a Python as far as Resolve is concerned, and the installer's preflight now
|
|
87
|
+
says so instead of reporting a usable prefix. And `launchctl setenv` **does not
|
|
88
|
+
survive a reboot**; if scripts stop listing weeks later with no error, that is
|
|
89
|
+
why. For something persistent, put an interpreter where Resolve already looks
|
|
90
|
+
(this one needs `sudo`, and check that `/usr/local/bin` does not precede your
|
|
91
|
+
normal Python on `PATH`):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
sudo ln -s "$(command -v python3)" /usr/local/bin/python3
|
|
95
|
+
```
|
|
96
|
+
|
|
83
97
|
Validated on free 21.0.3.7 and Studio 19.1.3.7, both macOS. The Windows paths
|
|
84
98
|
added in v2.70.1 (issue #106) shipped unverified; reports on free 21.0.1.11
|
|
85
99
|
(issue #109) and free 21.0.3.7 (issue #112) have since shown the bridge
|
|
@@ -231,6 +245,27 @@ The open-source servers are complete and fully functional on their own.
|
|
|
231
245
|
| Extension authoring | Fuse, DCTL, ACES DCTL, and Resolve-page Lua/Python script lifecycle helpers with safe MCP-marked install/remove |
|
|
232
246
|
| Craft guidance | The bundled editorial, colour, audio, and workflow guidance served as prose over MCP — indexed, searchable, and readable by any client, not just ones with this repository on disk |
|
|
233
247
|
|
|
248
|
+
### Operation envelope
|
|
249
|
+
|
|
250
|
+
Every compound tool return carries an `_operation` block beside its payload, so
|
|
251
|
+
an agent reads one shape instead of a different key per tool: `status`
|
|
252
|
+
(`success` / `partial` / `blocked` / `failed`), `verification` (with
|
|
253
|
+
`contradiction` kept distinct — Resolve reported success and the readback
|
|
254
|
+
disagreed), `changes` (the semantic delta), `warnings`, and an `execution_id`.
|
|
255
|
+
|
|
256
|
+
Two absences are meaningful and deliberate. `verification.status: "unverified"`
|
|
257
|
+
means *no evidence was reported*, not "checked and clean". A missing `changes`
|
|
258
|
+
means the action did not report a delta, not that nothing changed — an empty
|
|
259
|
+
`{}` there would be a confident, wrong answer about an edit that simply never
|
|
260
|
+
declared one.
|
|
261
|
+
|
|
262
|
+
The envelope is namespaced rather than merged into the top level because
|
|
263
|
+
`status`, `operation`, `warnings`, `result` and `changes` are all already domain
|
|
264
|
+
keys here; flattening would rewrite a background job's `status: "done"` and a
|
|
265
|
+
confirm gate's `status: "confirmation_required"`. `setup(action="set_defaults",
|
|
266
|
+
params={"result_envelope": "pure" | "legacy"})` changes the shape, per call via
|
|
267
|
+
`params={"envelope": ...}`, per process via `RESOLVE_MCP_RESULT_ENVELOPE`.
|
|
268
|
+
|
|
234
269
|
## Optional Extras
|
|
235
270
|
|
|
236
271
|
The core install is deliberately small: Python, ffmpeg, and the Resolve scripting
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.205.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -61,6 +61,12 @@ launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"
|
|
|
61
61
|
|
|
62
62
|
必须用 `launchctl setenv` 而不是 `export`——Resolve 从 Dock 启动,看不到你 shell 的环境变量。之后重启 Resolve。安装时会顺带装一个 Lua 金丝雀脚本,帮你区分"Python 未被检测到"和"目录放错"。
|
|
63
63
|
|
|
64
|
+
有两个坑(#182)。前缀里必须**同时**有 `lib/libpython3.X.dylib` 和 `bin/python3`——而且是这个**不带版本号**的名字。Homebrew 的 framework 构建经常只提供 `bin/python3.13`,在 Resolve 眼里这只算半个 Python;安装器的预检现在会明说这一点,而不是报告前缀可用。另外,`launchctl setenv` **在重启后不会保留**;如果几周之后脚本又不列出来了且没有任何报错,通常就是这个原因。想要持久生效,就把解释器放到 Resolve 本来就会查的位置(这条需要 `sudo`,并且先确认 `/usr/local/bin` 没有排在你常用 Python 的 `PATH` 前面):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
sudo ln -s "$(command -v python3)" /usr/local/bin/python3
|
|
68
|
+
```
|
|
69
|
+
|
|
64
70
|
已在免费版 21.0.3.7 和 Studio 19.1.3.7 上验证(均为 macOS)。v2.70.1(issue #106)加入的 Windows 路径发布时未经验证;后续免费版 21.0.1.11(issue #109)和免费版 21.0.3.7(issue #112)的用户报告证实,Windows 11 上桥接在 `%PROGRAMDATA%` 和 `%APPDATA%` **两处**都能安装、列出并正常服务,这些路径现在是已证实而非假设。Linux 同样已获证实:免费版 20.3.2.9 的用户报告(issue #129,Fedora 43)显示桥接可安装到 `~/.local/share/DaVinciResolve/Fusion/Scripts/Utility`,用系统 Python 就能直接枚举脚本(Linux 完全没有这套查找问题),并能端到端正常服务。现在没有任何平台停留在假设上:macOS 为本项目直接验证,Windows 和 Linux 来自用户报告。
|
|
65
71
|
|
|
66
72
|
注意:桥接在服务期间会一直占用端口。v2.70.3 之前,Windows 上的桥接可能在 Resolve 退出后存活,挡住下一个会话的监听器;如果你用的是旧版本且桥接不响应了,检查是否有残留的 `fuscript.exe` 还占着端口。
|
|
@@ -152,6 +158,14 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
|
|
|
152
158
|
| 渲染与交付 | 格式/编解码矩阵探测、渲染设置校验、队列任务生命周期检查、带护栏的快速导出 |
|
|
153
159
|
| 扩展开发 | Fuse、DCTL、ACES DCTL 及 Resolve 页面 Lua/Python 脚本生命周期助手,带 MCP 标记的安全安装/移除 |
|
|
154
160
|
|
|
161
|
+
### 操作信封(operation envelope)
|
|
162
|
+
|
|
163
|
+
每个复合工具的返回值都会在原有 payload 旁边带一个 `_operation` 块,这样 agent 读的是同一种结构,而不是每个工具一套 key:`status`(`success` / `partial` / `blocked` / `failed`)、`verification`(其中 `contradiction` 单独成一档——Resolve 报告成功但回读结果不一致)、`changes`(语义增量)、`warnings`,以及一个 `execution_id`。
|
|
164
|
+
|
|
165
|
+
有两种"缺失"是刻意保留其含义的。`verification.status: "unverified"` 表示*没有报告任何证据*,不等于"已检查且没问题"。`changes` 缺失表示这个动作没有报告增量,不等于什么都没改——在那里放一个空的 `{}`,等于对一次并未声明增量的剪辑给出一个自信而错误的回答。
|
|
166
|
+
|
|
167
|
+
信封是带命名空间的,而不是平铺到顶层,因为 `status`、`operation`、`warnings`、`result` 和 `changes` 在这里本来就都是业务 key;平铺会改写后台任务的 `status: "done"` 和确认关卡的 `status: "confirmation_required"`。用 `setup(action="set_defaults", params={"result_envelope": "pure" | "legacy"})` 改变形态,单次调用用 `params={"envelope": ...}`,进程级用 `RESOLVE_MCP_RESULT_ENVELOPE`。
|
|
168
|
+
|
|
155
169
|
## 可选增强
|
|
156
170
|
|
|
157
171
|
核心安装刻意保持精简:Python、ffmpeg 和 Resolve 脚本 API。有些功能需要更多依赖,且**每一项都会诚实拒绝并给出自己的安装命令,而不是退化成瞎猜**——编造的节拍或虚构的电平会产出自信但错误的结果,比没有这个功能更糟。
|
package/docs/SKILL.md
CHANGED
|
@@ -41,6 +41,14 @@ Existing tool call sites work unchanged. Two things to know when diagnosing it:
|
|
|
41
41
|
canary, which always lists, so "Python not detected" is distinguishable from
|
|
42
42
|
"wrong folder". The preflight is macOS-only — off macOS Resolve finds Python
|
|
43
43
|
by other means, and running the check there was a false alarm (#106).
|
|
44
|
+
Two follow-ups from #182 worth having in hand when a user says the menu is
|
|
45
|
+
empty despite a set `PYTHON3HOME`: the prefix needs **both**
|
|
46
|
+
`lib/libpython3.X.dylib` and `bin/python3` under that **unversioned** name
|
|
47
|
+
(Homebrew framework builds often ship only `python3.X`, so half the check
|
|
48
|
+
passes on the very interpreter people reach for), and `launchctl setenv` does
|
|
49
|
+
not survive a reboot — a bridge that listed for weeks and then stopped, with
|
|
50
|
+
no error anywhere, is usually that. `sudo ln -s "$(command -v python3)"
|
|
51
|
+
/usr/local/bin/python3` is the persistent alternative.
|
|
44
52
|
- **Windows: both script folders confirmed.** `%PROGRAMDATA%` (#109) and
|
|
45
53
|
`%APPDATA%` (#112) have each been shown serving the bridge on Windows 11 free
|
|
46
54
|
builds. If a user reports the menu entry missing on Windows, ask whether the
|
|
@@ -158,6 +166,59 @@ before mutating Resolve state.
|
|
|
158
166
|
|
|
159
167
|
---
|
|
160
168
|
|
|
169
|
+
## Reading A Result: The Operation Envelope
|
|
170
|
+
|
|
171
|
+
Every compound tool return carries an `_operation` block alongside its normal
|
|
172
|
+
payload. It answers the three questions that otherwise need a different key per
|
|
173
|
+
tool — did it happen, was it verified, what changed:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"success": true,
|
|
178
|
+
"insert_frame_absolute": 86400,
|
|
179
|
+
"shift_frames": 48,
|
|
180
|
+
|
|
181
|
+
"_operation": {
|
|
182
|
+
"status": "success",
|
|
183
|
+
"operation": "timeline.ripple_insert",
|
|
184
|
+
"execution_id": "exec_d2c123817bee",
|
|
185
|
+
"verification": {
|
|
186
|
+
"status": "passed",
|
|
187
|
+
"checks": [{"check": "readback_verification", "passed": true, "missing_items": 0}],
|
|
188
|
+
"contradiction": false
|
|
189
|
+
},
|
|
190
|
+
"changes": {"items_added": 3, "items_moved": 17, "items_deleted": 0}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- **`status`** — `success` | `partial` | `blocked` | `failed`. `blocked` means a
|
|
196
|
+
confirm gate is waiting; the payload still carries the `confirm_token` and
|
|
197
|
+
`preview` to act on.
|
|
198
|
+
- **`verification.status`** — `passed` | `failed` | `partial` | `contradiction`
|
|
199
|
+
| `unverified`. **`contradiction` is the one to stop on**: Resolve reported
|
|
200
|
+
success and the readback disagrees. **`unverified` means no evidence was
|
|
201
|
+
reported, not that the operation was checked and found clean** — if you need
|
|
202
|
+
certainty there, go and read the state back.
|
|
203
|
+
- **`changes`** — the semantic delta, present only when the action declared or
|
|
204
|
+
reported one. **Absent means "not reported", never "nothing changed"**, so do
|
|
205
|
+
not read a missing `changes` as a no-op.
|
|
206
|
+
- **`warnings`** — present only when there are any.
|
|
207
|
+
- **`execution_id`** — correlates one call across logs and transcripts.
|
|
208
|
+
|
|
209
|
+
The envelope is namespaced under `_operation` rather than merged into the top
|
|
210
|
+
level because `status`, `operation`, `warnings`, `result` and `changes` are all
|
|
211
|
+
already domain keys on this server (a background job's `status` is `"done"`, a
|
|
212
|
+
confirm gate's is `"confirmation_required"`). The payload is passed through
|
|
213
|
+
untouched; read domain values where you always read them.
|
|
214
|
+
|
|
215
|
+
Change the shape with `setup(action="set_defaults", params={"result_envelope": "pure" | "legacy" | "dual"})`,
|
|
216
|
+
per call with `params={"envelope": "pure"}`, or per process with
|
|
217
|
+
`RESOLVE_MCP_RESULT_ENVELOPE`. `pure` returns only the envelope with the payload
|
|
218
|
+
nested under `result`; `legacy` adds nothing.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
161
222
|
## Two Server Modes
|
|
162
223
|
|
|
163
224
|
| Mode | Entry point | Tool count | Use when |
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.205.1"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -286,10 +286,14 @@ _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
|
|
|
286
286
|
-- Resolve is listing Lua and silently skipping Python: it cannot find a Python 3.
|
|
287
287
|
-- It looks at PYTHON3HOME, then /usr/local/bin/python3 -- and nowhere else, which
|
|
288
288
|
-- is why Homebrew, pyenv, uv and conda interpreters go unseen. Either point it at
|
|
289
|
-
-- the one you have (no sudo):
|
|
289
|
+
-- the one you have (no sudo, but does NOT survive a reboot):
|
|
290
290
|
-- launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"
|
|
291
|
-
-- (launchctl, not export -- Resolve never sees your shell
|
|
292
|
-
--
|
|
291
|
+
-- (launchctl, not export -- Resolve never sees your shell; and the prefix needs
|
|
292
|
+
-- BOTH lib/libpython3.X.dylib and bin/python3 under that unversioned name --
|
|
293
|
+
-- Homebrew framework builds often ship only python3.X), or put one where Resolve
|
|
294
|
+
-- already looks, which persists:
|
|
295
|
+
-- sudo ln -s "$(command -v python3)" /usr/local/bin/python3
|
|
296
|
+
-- A python.org build creates that symlink for you. Restart Resolve after.
|
|
293
297
|
print("Resolve is enumerating scripts. If the Python probe is missing, Resolve")
|
|
294
298
|
print("cannot find a Python 3: set PYTHON3HOME with launchctl setenv, or install")
|
|
295
299
|
print("a python.org build. Homebrew/pyenv/uv/conda are not looked at directly.")
|
|
@@ -335,12 +339,28 @@ def launchd_env(name: str) -> str | None:
|
|
|
335
339
|
|
|
336
340
|
|
|
337
341
|
def python3_home_prefix() -> dict:
|
|
338
|
-
"""Is PYTHON3HOME set for Resolve, and does it point at a
|
|
342
|
+
"""Is PYTHON3HOME set for Resolve, and does it point at a usable Python 3?
|
|
339
343
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
+
Usable means BOTH halves of what fusionscript.so does with the prefix, whose
|
|
345
|
+
strings sit adjacent in the binary:
|
|
346
|
+
|
|
347
|
+
python3 -c 'import sys; ...sys.prefix...' # run <prefix>/bin/python3
|
|
348
|
+
/libpython # dlopen <prefix>/lib/libpython3.X.dylib
|
|
349
|
+
|
|
350
|
+
Checking only the dylib reported `usable: true` for a prefix Resolve cannot
|
|
351
|
+
run, and then Resolve listed zero Python scripts — the exact false all-clear
|
|
352
|
+
this function's docstring already warned about, since "the silent
|
|
353
|
+
non-enumeration symptom is identical and the remedy is not" (issue #182).
|
|
354
|
+
|
|
355
|
+
The interpreter must be there under the **unversioned** name. That is the
|
|
356
|
+
literal name in the binary, and it is what makes this trap so easy to hit:
|
|
357
|
+
a Homebrew framework prefix has `bin/python3.13` but no `bin/python3`
|
|
358
|
+
(formula-dependent — python@3.14 ships one, python@3.11 and python@3.13 do
|
|
359
|
+
not), while carrying a perfectly good `lib/libpython3.13.dylib`. Half the
|
|
360
|
+
check passes on exactly the interpreter most likely to be tried.
|
|
361
|
+
|
|
362
|
+
`framework_pythons()` has always required `bin/python3`; this is the same
|
|
363
|
+
rule applied to the route that skipped it.
|
|
344
364
|
"""
|
|
345
365
|
value = launchd_env("PYTHON3HOME")
|
|
346
366
|
result = {
|
|
@@ -348,20 +368,70 @@ def python3_home_prefix() -> dict:
|
|
|
348
368
|
"in_launchd": value is not None,
|
|
349
369
|
"in_this_shell": os.environ.get("PYTHON3HOME") or None,
|
|
350
370
|
"dylib": None,
|
|
371
|
+
"interpreter": None,
|
|
372
|
+
"reason": None,
|
|
351
373
|
"usable": False,
|
|
352
374
|
}
|
|
353
375
|
if not value:
|
|
354
376
|
return result
|
|
377
|
+
|
|
378
|
+
prefix = Path(value)
|
|
355
379
|
try:
|
|
356
|
-
dylibs = sorted(
|
|
380
|
+
dylibs = sorted(prefix.glob("lib/libpython3.*.dylib"))
|
|
357
381
|
except OSError:
|
|
358
382
|
dylibs = []
|
|
383
|
+
interpreter = prefix / "bin" / "python3"
|
|
384
|
+
try:
|
|
385
|
+
# .exists() follows symlinks, so a dangling one reads as absent — which
|
|
386
|
+
# is what it is to a Resolve trying to execute it.
|
|
387
|
+
has_interpreter = interpreter.exists()
|
|
388
|
+
except OSError:
|
|
389
|
+
has_interpreter = False
|
|
390
|
+
|
|
359
391
|
if dylibs:
|
|
360
392
|
result["dylib"] = str(dylibs[0])
|
|
361
|
-
|
|
393
|
+
if has_interpreter:
|
|
394
|
+
result["interpreter"] = str(interpreter)
|
|
395
|
+
result["usable"] = bool(dylibs) and has_interpreter
|
|
396
|
+
|
|
397
|
+
if result["usable"]:
|
|
398
|
+
return result
|
|
399
|
+
if dylibs and not has_interpreter:
|
|
400
|
+
versioned = sorted(
|
|
401
|
+
child.name for child in _safe_iterdir(prefix / "bin")
|
|
402
|
+
if child.name.startswith("python3.")
|
|
403
|
+
and not child.name.endswith("-config")
|
|
404
|
+
)
|
|
405
|
+
result["reason"] = (
|
|
406
|
+
f"{prefix}/lib has a libpython dylib but {interpreter} does not "
|
|
407
|
+
f"exist. Resolve runs the UNVERSIONED name `python3`"
|
|
408
|
+
+ (f"; this prefix ships only {', '.join(versioned)}. " if versioned else ". ")
|
|
409
|
+
+ "Homebrew framework builds are the common case. Either symlink it "
|
|
410
|
+
f"inside the prefix (ln -s {versioned[0] if versioned else 'python3.X'} "
|
|
411
|
+
f"{prefix}/bin/python3), or point PYTHON3HOME at a prefix that has both."
|
|
412
|
+
)
|
|
413
|
+
elif has_interpreter and not dylibs:
|
|
414
|
+
result["reason"] = (
|
|
415
|
+
f"{interpreter} exists but {prefix}/lib has no libpython3.X.dylib "
|
|
416
|
+
"for Resolve to dlopen. A static or non-shared build cannot be "
|
|
417
|
+
"embedded; point PYTHON3HOME at a prefix built with a shared library."
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
result["reason"] = (
|
|
421
|
+
f"{prefix} has neither bin/python3 nor lib/libpython3.X.dylib. "
|
|
422
|
+
"Check the path — it should be a Python `sys.prefix`, which "
|
|
423
|
+
"`python3 -c 'import sys; print(sys.prefix)'` prints."
|
|
424
|
+
)
|
|
362
425
|
return result
|
|
363
426
|
|
|
364
427
|
|
|
428
|
+
def _safe_iterdir(directory: Path) -> list[Path]:
|
|
429
|
+
try:
|
|
430
|
+
return sorted(directory.iterdir())
|
|
431
|
+
except OSError:
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
|
|
365
435
|
def fallback_python3() -> dict:
|
|
366
436
|
"""`/usr/local/bin/python3` — the path baked into fusionscript.so.
|
|
367
437
|
|
|
@@ -414,7 +484,27 @@ def python_preflight() -> dict:
|
|
|
414
484
|
# the order the binary's strings imply, and it is the one the user chose.
|
|
415
485
|
found = home["usable"] or fallback["exists"] or bool(frameworks)
|
|
416
486
|
advice = None
|
|
417
|
-
if not
|
|
487
|
+
if home["in_launchd"] and not home["usable"]:
|
|
488
|
+
# A set-but-broken PYTHON3HOME has to be said out loud even when another
|
|
489
|
+
# route exists. PYTHON3HOME is read FIRST, and whether Resolve falls
|
|
490
|
+
# back after choosing a prefix it cannot use is not established — the
|
|
491
|
+
# ordering here is inferred from string adjacency in fusionscript.so,
|
|
492
|
+
# not from decompiled control flow. Reporting a clean bill of health on
|
|
493
|
+
# the strength of a route Resolve may never reach is the failure this
|
|
494
|
+
# whole check exists to prevent (issue #182).
|
|
495
|
+
advice = (
|
|
496
|
+
"PYTHON3HOME is set for Resolve but does not point at a Python 3 it "
|
|
497
|
+
f"can use.\n{home['reason']}\n"
|
|
498
|
+
+ ("Another discovery route is present on this machine, but Resolve "
|
|
499
|
+
"reads PYTHON3HOME first and it is NOT established that it falls "
|
|
500
|
+
"back after picking a prefix it cannot use. Fix the prefix or "
|
|
501
|
+
"unset it (launchctl unsetenv PYTHON3HOME) rather than relying "
|
|
502
|
+
"on the fallback.\n" if found else "")
|
|
503
|
+
+ "Restart Resolve after changing it. The Lua canary installed "
|
|
504
|
+
"alongside lists regardless, so 'Python not detected' stays "
|
|
505
|
+
"distinguishable from 'wrong folder'."
|
|
506
|
+
)
|
|
507
|
+
elif not found:
|
|
418
508
|
advice = (
|
|
419
509
|
"Resolve cannot find a Python 3, so it will silently ignore every "
|
|
420
510
|
".py script in its Scripts folders — they will simply not appear in "
|
|
@@ -428,8 +518,16 @@ def python_preflight() -> dict:
|
|
|
428
518
|
"print(sys.prefix)')\"\n"
|
|
429
519
|
" Use launchctl, NOT export — Resolve is launched from the Dock "
|
|
430
520
|
"and never sees your shell's environment. The prefix must contain "
|
|
431
|
-
"lib/libpython3.X.dylib
|
|
432
|
-
"
|
|
521
|
+
"BOTH lib/libpython3.X.dylib and bin/python3 under that exact "
|
|
522
|
+
"unversioned name. Note that launchctl setenv does not survive a "
|
|
523
|
+
"reboot: if scripts stop listing weeks later with no error, this is "
|
|
524
|
+
"why.\n"
|
|
525
|
+
" 2. Or symlink an interpreter where Resolve already looks, which "
|
|
526
|
+
"is a file on disk and does persist (needs sudo):\n"
|
|
527
|
+
" sudo ln -s \"$(command -v python3)\" /usr/local/bin/python3\n"
|
|
528
|
+
" Check that /usr/local/bin does not precede your normal Python "
|
|
529
|
+
"on PATH before doing this.\n"
|
|
530
|
+
" 3. Or install a python.org build, which creates "
|
|
433
531
|
"/usr/local/bin/python3 for you.\n"
|
|
434
532
|
"Restart Resolve either way, then re-check. The Lua canary installed "
|
|
435
533
|
"alongside will list regardless, so you can tell 'Python not "
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.205.1"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 353-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.205.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -60,6 +60,12 @@ from src.utils.page_lock import (
|
|
|
60
60
|
)
|
|
61
61
|
from src.utils.proc import safe_run
|
|
62
62
|
from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
|
|
63
|
+
from src.utils import operation_result as _operation_result
|
|
64
|
+
from src.utils.operation_result import (
|
|
65
|
+
build_operation_envelope as _build_operation_envelope,
|
|
66
|
+
get_envelope_mode as _get_envelope_mode,
|
|
67
|
+
set_envelope_mode as _set_envelope_mode,
|
|
68
|
+
)
|
|
63
69
|
from src.utils.render_ids import (
|
|
64
70
|
render_codec_id_from_codecs as _render_codec_id_from_codecs,
|
|
65
71
|
render_format_id_from_formats as _render_format_id_from_formats,
|
|
@@ -1472,6 +1478,15 @@ def _guarded_action_name(args, kwargs) -> str:
|
|
|
1472
1478
|
return str(args[0]) if args else "?"
|
|
1473
1479
|
|
|
1474
1480
|
|
|
1481
|
+
def _guarded_params(args, kwargs) -> Optional[Dict[str, Any]]:
|
|
1482
|
+
"""This call's `params` dict, wherever in the signature it was passed."""
|
|
1483
|
+
if isinstance(kwargs.get("params"), dict):
|
|
1484
|
+
return kwargs["params"]
|
|
1485
|
+
if len(args) > 1 and isinstance(args[1], dict):
|
|
1486
|
+
return args[1]
|
|
1487
|
+
return None
|
|
1488
|
+
|
|
1489
|
+
|
|
1475
1490
|
def _guard_missing_params(fn):
|
|
1476
1491
|
"""Tool decorator: report a missing parameter instead of leaking a KeyError.
|
|
1477
1492
|
|
|
@@ -1489,21 +1504,35 @@ def _guard_missing_params(fn):
|
|
|
1489
1504
|
- **The signature is not (action, params).** `media_analysis` also takes
|
|
1490
1505
|
`ctx: Optional[Context]`, so the wrapper forwards `*args, **kwargs` rather
|
|
1491
1506
|
than naming parameters it does not know about.
|
|
1507
|
+
|
|
1508
|
+
It is also where the operation envelope is attached, for the same reason it
|
|
1509
|
+
is where the missing-parameter guard lives: this is the one seam every tool
|
|
1510
|
+
return passes through. In the default `dual` mode the payload is untouched
|
|
1511
|
+
and the envelope rides under `_operation` — see `src/utils/operation_result`
|
|
1512
|
+
for why it is namespaced rather than flattened.
|
|
1492
1513
|
"""
|
|
1514
|
+
tool_name = getattr(fn, "__name__", "tool")
|
|
1515
|
+
|
|
1493
1516
|
if inspect.iscoroutinefunction(fn):
|
|
1494
1517
|
@functools.wraps(fn)
|
|
1495
1518
|
async def wrapper(*args, **kwargs):
|
|
1519
|
+
action = _guarded_action_name(args, kwargs)
|
|
1496
1520
|
try:
|
|
1497
|
-
|
|
1521
|
+
result = await fn(*args, **kwargs)
|
|
1498
1522
|
except _MissingParam as exc:
|
|
1499
|
-
|
|
1523
|
+
result = _missing_param_error(exc, action)
|
|
1524
|
+
return _build_operation_envelope(
|
|
1525
|
+
tool_name, action, _guarded_params(args, kwargs), result)
|
|
1500
1526
|
else:
|
|
1501
1527
|
@functools.wraps(fn)
|
|
1502
1528
|
def wrapper(*args, **kwargs):
|
|
1529
|
+
action = _guarded_action_name(args, kwargs)
|
|
1503
1530
|
try:
|
|
1504
|
-
|
|
1531
|
+
result = fn(*args, **kwargs)
|
|
1505
1532
|
except _MissingParam as exc:
|
|
1506
|
-
|
|
1533
|
+
result = _missing_param_error(exc, action)
|
|
1534
|
+
return _build_operation_envelope(
|
|
1535
|
+
tool_name, action, _guarded_params(args, kwargs), result)
|
|
1507
1536
|
|
|
1508
1537
|
wrapper.__wrapped_by_missing_param_guard__ = True
|
|
1509
1538
|
return wrapper
|
|
@@ -5238,6 +5267,15 @@ def _timeline_ripple_insert_impl(proj, tl, p: Dict[str, Any], *, resolve=None) -
|
|
|
5238
5267
|
"readback": {"after_counts": after_counts, "missing": missing},
|
|
5239
5268
|
"gap_frames_by_track": gap_by_track,
|
|
5240
5269
|
"warnings": warnings,
|
|
5270
|
+
# Semantic delta for the operation envelope. Declared rather than
|
|
5271
|
+
# inferred: this action deletes and re-appends the tail to move it, so
|
|
5272
|
+
# a reader counting raw API calls would see a deletion that the edit
|
|
5273
|
+
# did not make.
|
|
5274
|
+
"_changes": {
|
|
5275
|
+
"items_added": len(built_inserts),
|
|
5276
|
+
"items_moved": len(tail_rows),
|
|
5277
|
+
"items_deleted": 0,
|
|
5278
|
+
},
|
|
5241
5279
|
}
|
|
5242
5280
|
if failures:
|
|
5243
5281
|
result["failures"] = failures
|
|
@@ -10215,6 +10253,63 @@ def _read_json_strict(path: str) -> Dict[str, Any]:
|
|
|
10215
10253
|
return payload if isinstance(payload, dict) else {}
|
|
10216
10254
|
|
|
10217
10255
|
|
|
10256
|
+
_SERVER_PREFS_ENV = "RESOLVE_MCP_SERVER_PREFS"
|
|
10257
|
+
|
|
10258
|
+
|
|
10259
|
+
def _server_preferences_path() -> str:
|
|
10260
|
+
"""Where server-general defaults live.
|
|
10261
|
+
|
|
10262
|
+
Separate from the media-analysis preferences file: these are settings about
|
|
10263
|
+
how the server answers, not about how it analyses media, and mixing them
|
|
10264
|
+
would make either file's name a lie.
|
|
10265
|
+
"""
|
|
10266
|
+
override = os.environ.get(_SERVER_PREFS_ENV)
|
|
10267
|
+
if override:
|
|
10268
|
+
return os.path.realpath(os.path.abspath(os.path.expanduser(override)))
|
|
10269
|
+
return os.path.join(project_dir, "logs", "server-preferences.json")
|
|
10270
|
+
|
|
10271
|
+
|
|
10272
|
+
def _read_server_preferences() -> Dict[str, Any]:
|
|
10273
|
+
try:
|
|
10274
|
+
return _read_json_strict(_server_preferences_path())
|
|
10275
|
+
except ConfigParseError:
|
|
10276
|
+
return {}
|
|
10277
|
+
|
|
10278
|
+
|
|
10279
|
+
def _write_server_preferences(preferences: Dict[str, Any]) -> None:
|
|
10280
|
+
# Same atomic replace as the other preference stores: a crash mid-write must
|
|
10281
|
+
# not truncate a file whose reader falls back to {}, since the next save
|
|
10282
|
+
# would then persist that empty state over the user's settings.
|
|
10283
|
+
path = _server_preferences_path()
|
|
10284
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
10285
|
+
tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
|
|
10286
|
+
try:
|
|
10287
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
10288
|
+
json.dump(preferences, handle, indent=2, sort_keys=True)
|
|
10289
|
+
handle.write("\n")
|
|
10290
|
+
os.replace(tmp_path, path)
|
|
10291
|
+
finally:
|
|
10292
|
+
try:
|
|
10293
|
+
os.remove(tmp_path)
|
|
10294
|
+
except OSError:
|
|
10295
|
+
pass
|
|
10296
|
+
|
|
10297
|
+
|
|
10298
|
+
def _apply_persisted_envelope_mode() -> str:
|
|
10299
|
+
"""Restore the saved result_envelope default at startup.
|
|
10300
|
+
|
|
10301
|
+
An explicit RESOLVE_MCP_RESULT_ENVELOPE wins: a process-level override is a
|
|
10302
|
+
deliberate act for this run, and a saved preference should not silently
|
|
10303
|
+
outrank it.
|
|
10304
|
+
"""
|
|
10305
|
+
if os.environ.get("RESOLVE_MCP_RESULT_ENVELOPE"):
|
|
10306
|
+
return _get_envelope_mode()
|
|
10307
|
+
saved = _read_server_preferences().get("result_envelope")
|
|
10308
|
+
if isinstance(saved, str):
|
|
10309
|
+
_set_envelope_mode(saved)
|
|
10310
|
+
return _get_envelope_mode()
|
|
10311
|
+
|
|
10312
|
+
|
|
10218
10313
|
def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
10219
10314
|
path = _media_analysis_preferences_path()
|
|
10220
10315
|
try:
|
|
@@ -15091,13 +15186,59 @@ def _setup_updates_defaults() -> Dict[str, Any]:
|
|
|
15091
15186
|
}
|
|
15092
15187
|
|
|
15093
15188
|
|
|
15189
|
+
def _setup_general_defaults() -> Dict[str, Any]:
|
|
15190
|
+
return {
|
|
15191
|
+
"result_envelope": _get_envelope_mode(),
|
|
15192
|
+
"options": {"result_envelope": list(_operation_result.MODES)},
|
|
15193
|
+
"preferences_path": _server_preferences_path(),
|
|
15194
|
+
}
|
|
15195
|
+
|
|
15196
|
+
|
|
15094
15197
|
def _setup_defaults_snapshot() -> Dict[str, Any]:
|
|
15095
15198
|
return {
|
|
15199
|
+
"general": _setup_general_defaults(),
|
|
15096
15200
|
"media_analysis": _setup_media_analysis_defaults(),
|
|
15097
15201
|
"updates": _setup_updates_defaults(),
|
|
15098
15202
|
}
|
|
15099
15203
|
|
|
15100
15204
|
|
|
15205
|
+
def _setup_set_general_defaults(general: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
|
|
15206
|
+
"""Persist server-general defaults. Currently just the result envelope."""
|
|
15207
|
+
if not general:
|
|
15208
|
+
return {"changed": False, "recognized": False}
|
|
15209
|
+
|
|
15210
|
+
mode = _first_param(
|
|
15211
|
+
general, "result_envelope", "resultEnvelope", "envelope_mode", "envelopeMode",
|
|
15212
|
+
default=None)
|
|
15213
|
+
if mode is None:
|
|
15214
|
+
return {"changed": False, "recognized": False}
|
|
15215
|
+
|
|
15216
|
+
cleaned = str(mode).strip().lower()
|
|
15217
|
+
if cleaned not in _operation_result.MODES:
|
|
15218
|
+
return _err(
|
|
15219
|
+
f"result_envelope must be one of {', '.join(_operation_result.MODES)}",
|
|
15220
|
+
code="INVALID_ENUM_VALUE", category="validation",
|
|
15221
|
+
state={"result_envelope": mode})
|
|
15222
|
+
|
|
15223
|
+
if dry_run:
|
|
15224
|
+
return {"changed": True, "recognized": True, "result_envelope": cleaned,
|
|
15225
|
+
"current": _get_envelope_mode()}
|
|
15226
|
+
|
|
15227
|
+
# Persist before applying: a saved setting that does not survive a restart
|
|
15228
|
+
# is a setting the caller was told they changed and did not.
|
|
15229
|
+
try:
|
|
15230
|
+
preferences = _read_json_strict(_server_preferences_path())
|
|
15231
|
+
except ConfigParseError as exc:
|
|
15232
|
+
return _err(
|
|
15233
|
+
f"server preferences file is unreadable: {exc}",
|
|
15234
|
+
code="CONFIG_UNREADABLE", category="state",
|
|
15235
|
+
remediation=f"Repair or delete {_server_preferences_path()}, then retry.")
|
|
15236
|
+
preferences["result_envelope"] = cleaned
|
|
15237
|
+
_write_server_preferences(preferences)
|
|
15238
|
+
_set_envelope_mode(cleaned)
|
|
15239
|
+
return {"changed": True, "recognized": True, "result_envelope": _get_envelope_mode()}
|
|
15240
|
+
|
|
15241
|
+
|
|
15101
15242
|
def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
|
|
15102
15243
|
if not media_defaults:
|
|
15103
15244
|
return {"changed": False, "recognized": False}
|
|
@@ -15691,6 +15832,17 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15691
15832
|
},
|
|
15692
15833
|
"updates.check_interval_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))},
|
|
15693
15834
|
"updates.snooze_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))},
|
|
15835
|
+
"general.result_envelope": {
|
|
15836
|
+
"description": (
|
|
15837
|
+
"Where the operation envelope goes. 'dual' (default) leaves the "
|
|
15838
|
+
"payload untouched and adds the envelope under '_operation'; "
|
|
15839
|
+
"'pure' returns only the envelope with the payload under 'result'; "
|
|
15840
|
+
"'legacy' adds nothing. Override per call with params={'envelope': ...}."
|
|
15841
|
+
),
|
|
15842
|
+
"values": list(_operation_result.MODES),
|
|
15843
|
+
"current": _get_envelope_mode(),
|
|
15844
|
+
"storage": _server_preferences_path(),
|
|
15845
|
+
},
|
|
15694
15846
|
},
|
|
15695
15847
|
}
|
|
15696
15848
|
|
|
@@ -15702,12 +15854,23 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15702
15854
|
if action in {"set_defaults", "set", "configure"}:
|
|
15703
15855
|
defaults = p.get("defaults") if isinstance(p.get("defaults"), dict) else {}
|
|
15704
15856
|
merged = {**defaults, **{k: v for k, v in p.items() if k != "defaults"}}
|
|
15857
|
+
# media_analysis owns every unclaimed key, so anything another setter
|
|
15858
|
+
# owns has to be named here or it lands in the wrong store.
|
|
15859
|
+
_general_keys = {
|
|
15860
|
+
"general", "result_envelope", "resultEnvelope",
|
|
15861
|
+
"envelope_mode", "envelopeMode",
|
|
15862
|
+
}
|
|
15863
|
+
general_defaults = {
|
|
15864
|
+
**_setup_nested(merged, "general"),
|
|
15865
|
+
**{k: v for k, v in merged.items() if k in _general_keys and k != "general"},
|
|
15866
|
+
}
|
|
15705
15867
|
media_defaults = {
|
|
15706
15868
|
**_setup_nested(merged, "media_analysis", "mediaAnalysis"),
|
|
15707
15869
|
**{
|
|
15708
15870
|
key: value
|
|
15709
15871
|
for key, value in merged.items()
|
|
15710
15872
|
if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"}
|
|
15873
|
+
and key not in _general_keys
|
|
15711
15874
|
},
|
|
15712
15875
|
**({
|
|
15713
15876
|
"timed_markers_default": _first_param(
|
|
@@ -15758,19 +15921,25 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15758
15921
|
} if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}),
|
|
15759
15922
|
}
|
|
15760
15923
|
|
|
15924
|
+
general_result = _setup_set_general_defaults(general_defaults, dry_run)
|
|
15925
|
+
if general_result.get("error"):
|
|
15926
|
+
return general_result
|
|
15761
15927
|
media_result = _setup_set_media_analysis_defaults(media_defaults, dry_run)
|
|
15762
15928
|
if media_result.get("error"):
|
|
15763
15929
|
return media_result
|
|
15764
15930
|
update_result = _setup_set_updates_defaults(update_defaults, dry_run)
|
|
15765
15931
|
if update_result.get("error"):
|
|
15766
15932
|
return update_result
|
|
15767
|
-
recognized =
|
|
15933
|
+
recognized = (bool(general_result.get("recognized"))
|
|
15934
|
+
or bool(media_result.get("recognized"))
|
|
15935
|
+
or bool(update_result.get("recognized")))
|
|
15768
15936
|
if not recognized:
|
|
15769
15937
|
return _err("set_defaults did not receive a recognized default to set")
|
|
15770
15938
|
|
|
15771
15939
|
return _ok(
|
|
15772
15940
|
dry_run=dry_run,
|
|
15773
15941
|
changes={
|
|
15942
|
+
"general": general_result,
|
|
15774
15943
|
"media_analysis": media_result,
|
|
15775
15944
|
"updates": update_result,
|
|
15776
15945
|
},
|
|
@@ -31310,6 +31479,9 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
|
|
|
31310
31479
|
|
|
31311
31480
|
|
|
31312
31481
|
if __name__ == "__main__":
|
|
31482
|
+
# Before any tool can answer: a saved result_envelope default has to be in
|
|
31483
|
+
# force on the first call, not the second.
|
|
31484
|
+
logger.info(f"Result envelope mode: {_apply_persisted_envelope_mode()}")
|
|
31313
31485
|
start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
|
|
31314
31486
|
_install_threaded_tool_dispatch(mcp)
|
|
31315
31487
|
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""A standard operation envelope over every compound tool's return value.
|
|
2
|
+
|
|
3
|
+
Agents orchestrating multi-turn edits have to answer the same three questions
|
|
4
|
+
after every call — did it actually happen, was it verified, and what changed —
|
|
5
|
+
and today each tool answers them in its own vocabulary: ``readback.missing``,
|
|
6
|
+
``succeeded``/``failed``, ``partial``, ``status: "confirmation_required"``.
|
|
7
|
+
This module normalizes those into one shape so the answer is in the same place
|
|
8
|
+
every time.
|
|
9
|
+
|
|
10
|
+
Adapted from the design contributed in PR #181.
|
|
11
|
+
|
|
12
|
+
**Where the envelope lives.** Its keys are namespaced under a single reserved
|
|
13
|
+
key rather than merged into the top level, because half of them are already
|
|
14
|
+
domain keys here and merging silently destroys them:
|
|
15
|
+
|
|
16
|
+
status 22 sites — a background job's "done", a transcription's
|
|
17
|
+
"Transcribed", a confirm gate's "confirmation_required"
|
|
18
|
+
operation 20 sites
|
|
19
|
+
warnings 15 sites
|
|
20
|
+
result 8 sites
|
|
21
|
+
changes 2 sites
|
|
22
|
+
|
|
23
|
+
Flattening the envelope over those rewrites `job_status`'s "done" to "success"
|
|
24
|
+
(an agent polling a job then never sees it finish) and a confirm gate's
|
|
25
|
+
"confirmation_required" to "blocked" — renaming the very signal the envelope
|
|
26
|
+
exists to make unambiguous. So in the default ``dual`` mode the raw payload is
|
|
27
|
+
passed through **untouched** and the envelope is added under ``_operation``,
|
|
28
|
+
following the existing ``_versioning`` private-key convention. Nothing is
|
|
29
|
+
shadowed, nothing is dropped, and there is still exactly one place to look.
|
|
30
|
+
|
|
31
|
+
Modes:
|
|
32
|
+
``dual`` (default) raw payload verbatim + ``_operation``.
|
|
33
|
+
``pure`` only the envelope, domain payload nested under ``result``. Opt in
|
|
34
|
+
per call with ``params={"envelope": "pure"}``, per session with
|
|
35
|
+
``setup(action="set_defaults", params={"result_envelope": "pure"})``,
|
|
36
|
+
or per process with ``RESOLVE_MCP_RESULT_ENVELOPE=pure``.
|
|
37
|
+
``legacy`` no envelope at all.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import uuid
|
|
45
|
+
from typing import Any, Dict, List, Optional
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger("resolve-mcp.operation-result")
|
|
48
|
+
|
|
49
|
+
#: The reserved key the envelope hangs off in ``dual`` mode. Verified unused as
|
|
50
|
+
#: a domain key across ``src/`` — ``test_envelope_key_stays_reserved`` keeps it
|
|
51
|
+
#: that way, since the day a tool returns its own ``_operation`` is the day this
|
|
52
|
+
#: mode starts destroying payloads the way the flat one did.
|
|
53
|
+
ENVELOPE_KEY = "_operation"
|
|
54
|
+
|
|
55
|
+
MODES = ("dual", "pure", "legacy")
|
|
56
|
+
DEFAULT_MODE = "dual"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean_mode(mode: Any) -> Optional[str]:
|
|
60
|
+
if not isinstance(mode, str):
|
|
61
|
+
return None
|
|
62
|
+
candidate = mode.strip().lower()
|
|
63
|
+
return candidate if candidate in MODES else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_CURRENT_ENVELOPE_MODE = (
|
|
67
|
+
_clean_mode(os.environ.get("RESOLVE_MCP_RESULT_ENVELOPE")) or DEFAULT_MODE
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_envelope_mode() -> str:
|
|
72
|
+
"""The envelope mode applied when a call does not name one."""
|
|
73
|
+
return _CURRENT_ENVELOPE_MODE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_envelope_mode(mode: str) -> str:
|
|
77
|
+
"""Set the default envelope mode. Unknown values leave it unchanged."""
|
|
78
|
+
global _CURRENT_ENVELOPE_MODE
|
|
79
|
+
cleaned = _clean_mode(mode)
|
|
80
|
+
if cleaned:
|
|
81
|
+
_CURRENT_ENVELOPE_MODE = cleaned
|
|
82
|
+
return _CURRENT_ENVELOPE_MODE
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def new_execution_id() -> str:
|
|
86
|
+
"""A compact id for correlating one tool call across logs and transcripts."""
|
|
87
|
+
return f"exec_{uuid.uuid4().hex[:12]}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ─── Status ──────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def normalize_status(raw: Any) -> str:
|
|
93
|
+
"""Reduce a result to 'success' | 'partial' | 'blocked' | 'failed'.
|
|
94
|
+
|
|
95
|
+
Deliberately narrow. Every rule below keys on a convention this repo
|
|
96
|
+
actually uses, checked against the call sites — a heuristic that guesses
|
|
97
|
+
from a plausible-looking key name produces exactly the confident-and-wrong
|
|
98
|
+
status the envelope is supposed to eliminate. The clearest example is
|
|
99
|
+
``blocked``: it reads like a gate flag, but here it is a domain key holding
|
|
100
|
+
the *list of targets that could not be resolved* (`timeline` range delete,
|
|
101
|
+
`timeline_item_color.bulk_match_to_hero`), and a successful dry-run carries
|
|
102
|
+
a non-empty one. Keying on it would report a gate that never happened.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
return "success" if bool(raw) else "failed"
|
|
106
|
+
|
|
107
|
+
# The confirm gate. `_issue_confirm_token` is the single producer of these
|
|
108
|
+
# responses and always stamps this exact status, so the check is exact
|
|
109
|
+
# rather than inferred.
|
|
110
|
+
if raw.get("status") == "confirmation_required" or raw.get("confirmation_required") is True:
|
|
111
|
+
return "blocked"
|
|
112
|
+
|
|
113
|
+
if raw.get("error"):
|
|
114
|
+
return "failed"
|
|
115
|
+
if raw.get("success") is False:
|
|
116
|
+
return "failed"
|
|
117
|
+
|
|
118
|
+
# Bulk operations set this explicitly when some units failed.
|
|
119
|
+
if raw.get("partial") is True:
|
|
120
|
+
return "partial"
|
|
121
|
+
succeeded, failed = raw.get("succeeded"), raw.get("failed")
|
|
122
|
+
if isinstance(succeeded, int) and isinstance(failed, int) and succeeded > 0 and failed > 0:
|
|
123
|
+
return "partial"
|
|
124
|
+
|
|
125
|
+
return "success"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ─── Warnings ────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def extract_warnings(raw: Any) -> List[str]:
|
|
131
|
+
"""Every advisory the payload carries, as a flat list of strings."""
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
out: List[str] = []
|
|
136
|
+
|
|
137
|
+
def add(value: Any) -> None:
|
|
138
|
+
if isinstance(value, str) and value.strip() and value.strip() not in out:
|
|
139
|
+
out.append(value.strip())
|
|
140
|
+
|
|
141
|
+
plural = raw.get("warnings")
|
|
142
|
+
if isinstance(plural, list):
|
|
143
|
+
for item in plural:
|
|
144
|
+
add(item if isinstance(item, str) else str(item) if item else None)
|
|
145
|
+
else:
|
|
146
|
+
add(plural)
|
|
147
|
+
add(raw.get("warning"))
|
|
148
|
+
|
|
149
|
+
ignored = raw.get("ignored_options")
|
|
150
|
+
if ignored:
|
|
151
|
+
add(f"Ignored unsupported options: {ignored}")
|
|
152
|
+
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ─── Verification ────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def extract_verification(raw: Any) -> Dict[str, Any]:
|
|
159
|
+
"""Normalize whatever verification evidence the payload carries.
|
|
160
|
+
|
|
161
|
+
'unverified' means *no evidence was reported*, which is not the same as
|
|
162
|
+
'checked and fine' — a caller that needs certainty should treat it as an
|
|
163
|
+
open question rather than a pass.
|
|
164
|
+
"""
|
|
165
|
+
unverified = {"status": "unverified", "checks": [], "contradiction": False}
|
|
166
|
+
if not isinstance(raw, dict):
|
|
167
|
+
return unverified
|
|
168
|
+
|
|
169
|
+
# An impl that already speaks this shape wins outright.
|
|
170
|
+
existing = raw.get("verification")
|
|
171
|
+
if isinstance(existing, dict) and existing:
|
|
172
|
+
merged = dict(existing)
|
|
173
|
+
merged.setdefault(
|
|
174
|
+
"status", "passed" if existing.get("verified") else "unverified")
|
|
175
|
+
merged.setdefault("checks", [])
|
|
176
|
+
merged.setdefault("contradiction", False)
|
|
177
|
+
return merged
|
|
178
|
+
|
|
179
|
+
checks: List[Dict[str, Any]] = []
|
|
180
|
+
status = "unverified"
|
|
181
|
+
contradiction = False
|
|
182
|
+
|
|
183
|
+
readback = raw.get("readback")
|
|
184
|
+
if isinstance(readback, dict):
|
|
185
|
+
missing = readback.get("missing")
|
|
186
|
+
if isinstance(missing, list):
|
|
187
|
+
checks.append({
|
|
188
|
+
"check": "readback_verification",
|
|
189
|
+
"passed": not missing,
|
|
190
|
+
"missing_items": len(missing),
|
|
191
|
+
})
|
|
192
|
+
status = "passed" if not missing else "failed"
|
|
193
|
+
|
|
194
|
+
# verify_by_readback's own shape: a mutation that reported success while the
|
|
195
|
+
# post-state disagrees is a contradiction, this repo's single most valuable
|
|
196
|
+
# reliability signal — it must not be flattened into a plain failure.
|
|
197
|
+
if "verified" in raw:
|
|
198
|
+
verified = bool(raw["verified"])
|
|
199
|
+
contradiction = bool(raw.get("contradiction"))
|
|
200
|
+
checks.append({
|
|
201
|
+
"check": "readback_post_state",
|
|
202
|
+
"passed": verified,
|
|
203
|
+
"contradiction": contradiction,
|
|
204
|
+
"observed": raw.get("observed"),
|
|
205
|
+
})
|
|
206
|
+
status = "contradiction" if contradiction else ("passed" if verified else "failed")
|
|
207
|
+
|
|
208
|
+
if "property_restore_failures" in raw:
|
|
209
|
+
failures = _as_int(raw.get("property_restore_failures"))
|
|
210
|
+
checks.append({
|
|
211
|
+
"check": "property_restore",
|
|
212
|
+
"passed": failures == 0,
|
|
213
|
+
"restored_items": _as_int(raw.get("properties_restored_items")),
|
|
214
|
+
"failures": failures,
|
|
215
|
+
})
|
|
216
|
+
if failures and status in ("passed", "unverified"):
|
|
217
|
+
status = "partial"
|
|
218
|
+
|
|
219
|
+
succeeded, failed = raw.get("succeeded"), raw.get("failed")
|
|
220
|
+
if isinstance(succeeded, int) and isinstance(failed, int):
|
|
221
|
+
checks.append({
|
|
222
|
+
"check": "bulk_operations",
|
|
223
|
+
"passed": failed == 0,
|
|
224
|
+
"succeeded": succeeded,
|
|
225
|
+
"failed": failed,
|
|
226
|
+
})
|
|
227
|
+
if status == "unverified":
|
|
228
|
+
if failed == 0 and succeeded > 0:
|
|
229
|
+
status = "passed"
|
|
230
|
+
elif succeeded > 0:
|
|
231
|
+
status = "partial"
|
|
232
|
+
elif failed > 0:
|
|
233
|
+
status = "failed"
|
|
234
|
+
|
|
235
|
+
if not checks:
|
|
236
|
+
return unverified
|
|
237
|
+
return {"status": status, "checks": checks, "contradiction": contradiction}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ─── Changes ─────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def _as_int(value: Any) -> int:
|
|
243
|
+
try:
|
|
244
|
+
return int(value)
|
|
245
|
+
except (TypeError, ValueError):
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
#: Domain key → semantic delta. Every entry is a key that exists in `src/` and
|
|
250
|
+
#: means what the delta says it means; nothing is mapped on the strength of a
|
|
251
|
+
#: suggestive name. `properties_restored_items`, for instance, is deliberately
|
|
252
|
+
#: absent: a ripple insert restores clip properties it had to re-apply after
|
|
253
|
+
#: moving items, which is bookkeeping, not an edit the user made.
|
|
254
|
+
_COUNT_ALIASES = {
|
|
255
|
+
"items_added": ("inserted_clips",),
|
|
256
|
+
"items_moved": ("tail_items_shifted",),
|
|
257
|
+
"items_deleted": (),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def extract_changes(raw: Any) -> Optional[Dict[str, Any]]:
|
|
262
|
+
"""The operation's semantic delta, or None when it did not report one.
|
|
263
|
+
|
|
264
|
+
None, not ``{}``. An empty dict reads as "this operation changed nothing",
|
|
265
|
+
which is a false statement about a ripple insert that simply never declared
|
|
266
|
+
its deltas — the silent-lie failure this codebase treats as a bug class. A
|
|
267
|
+
caller that needs a number and gets None knows to go and count.
|
|
268
|
+
"""
|
|
269
|
+
if not isinstance(raw, dict):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
changes: Dict[str, Any] = {}
|
|
273
|
+
|
|
274
|
+
# An impl that declares its own deltas is authoritative.
|
|
275
|
+
declared = raw.get("_changes")
|
|
276
|
+
if isinstance(declared, dict):
|
|
277
|
+
changes.update(declared)
|
|
278
|
+
|
|
279
|
+
for canonical, aliases in _COUNT_ALIASES.items():
|
|
280
|
+
if canonical in raw:
|
|
281
|
+
changes[canonical] = _as_int(raw[canonical])
|
|
282
|
+
continue
|
|
283
|
+
for alias in aliases:
|
|
284
|
+
if alias in raw:
|
|
285
|
+
changes[canonical] = _as_int(raw[alias])
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
if "shift_frames" in raw:
|
|
289
|
+
changes["shift_frames"] = raw["shift_frames"]
|
|
290
|
+
|
|
291
|
+
versioning = raw.get("_versioning")
|
|
292
|
+
if isinstance(versioning, dict):
|
|
293
|
+
metric = versioning.get("metric")
|
|
294
|
+
before, after = versioning.get("before_value"), versioning.get("after_value")
|
|
295
|
+
if metric and before is not None and after is not None:
|
|
296
|
+
changes["metric"] = metric
|
|
297
|
+
changes["before_value"] = before
|
|
298
|
+
changes["after_value"] = after
|
|
299
|
+
try:
|
|
300
|
+
changes["delta"] = round(float(after) - float(before), 4)
|
|
301
|
+
except (TypeError, ValueError):
|
|
302
|
+
pass
|
|
303
|
+
if versioning.get("archived"):
|
|
304
|
+
changes["timeline_archived"] = True
|
|
305
|
+
if versioning.get("archived_version"):
|
|
306
|
+
changes["archived_version"] = versioning["archived_version"]
|
|
307
|
+
|
|
308
|
+
return changes or None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ─── Assembly ────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
def is_passthrough(obj: Any) -> bool:
|
|
314
|
+
"""MCP content objects (Image, TextContent, EmbeddedResource) go through as-is."""
|
|
315
|
+
return getattr(obj.__class__, "__name__", "") in {
|
|
316
|
+
"Image", "TextContent", "EmbeddedResource"
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _requested_mode(params: Optional[Dict[str, Any]], fallback: Optional[str]) -> str:
|
|
321
|
+
if isinstance(params, dict):
|
|
322
|
+
for key in ("envelope", "_envelope"):
|
|
323
|
+
if key in params:
|
|
324
|
+
named = _clean_mode(params[key])
|
|
325
|
+
if named:
|
|
326
|
+
return named
|
|
327
|
+
if isinstance(params[key], bool):
|
|
328
|
+
return "pure" if params[key] else "legacy"
|
|
329
|
+
return _clean_mode(fallback) or get_envelope_mode()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_operation_envelope(
|
|
333
|
+
tool_name: str,
|
|
334
|
+
action: str,
|
|
335
|
+
params: Optional[Dict[str, Any]],
|
|
336
|
+
raw_result: Any,
|
|
337
|
+
*,
|
|
338
|
+
execution_id: Optional[str] = None,
|
|
339
|
+
mode: Optional[str] = None,
|
|
340
|
+
) -> Any:
|
|
341
|
+
"""Attach the operation envelope to one action's return value."""
|
|
342
|
+
if is_passthrough(raw_result):
|
|
343
|
+
return raw_result
|
|
344
|
+
|
|
345
|
+
selected = _requested_mode(params, mode)
|
|
346
|
+
if selected == "legacy":
|
|
347
|
+
return raw_result
|
|
348
|
+
|
|
349
|
+
envelope: Dict[str, Any] = {
|
|
350
|
+
"status": normalize_status(raw_result),
|
|
351
|
+
"operation": f"{tool_name}.{action}",
|
|
352
|
+
"execution_id": execution_id or new_execution_id(),
|
|
353
|
+
"verification": extract_verification(raw_result),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
changes = extract_changes(raw_result)
|
|
357
|
+
if changes is not None:
|
|
358
|
+
envelope["changes"] = changes
|
|
359
|
+
|
|
360
|
+
warnings = extract_warnings(raw_result)
|
|
361
|
+
if warnings:
|
|
362
|
+
envelope["warnings"] = warnings
|
|
363
|
+
|
|
364
|
+
versioning = raw_result.get("_versioning") if isinstance(raw_result, dict) else None
|
|
365
|
+
if isinstance(versioning, dict) and versioning.get("analysis_run_id"):
|
|
366
|
+
envelope["run_id"] = versioning["analysis_run_id"]
|
|
367
|
+
|
|
368
|
+
if selected == "pure":
|
|
369
|
+
return {**envelope, "result": raw_result}
|
|
370
|
+
|
|
371
|
+
# dual: the payload is passed through byte-for-byte and the envelope rides
|
|
372
|
+
# alongside it. A non-dict return has nowhere to carry the key, so it is
|
|
373
|
+
# handed back unchanged rather than being boxed into a shape callers of
|
|
374
|
+
# that action have never seen.
|
|
375
|
+
if not isinstance(raw_result, dict):
|
|
376
|
+
return raw_result
|
|
377
|
+
return {**raw_result, ENVELOPE_KEY: envelope}
|
package/src/utils/readback.py
CHANGED
|
@@ -89,3 +89,32 @@ def verify_by_readback(
|
|
|
89
89
|
_STATS["unverified"] += 1
|
|
90
90
|
|
|
91
91
|
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def as_verification_dict(
|
|
95
|
+
readback_result: Dict[str, Any],
|
|
96
|
+
*,
|
|
97
|
+
check_name: str = "readback_verification",
|
|
98
|
+
) -> Dict[str, Any]:
|
|
99
|
+
"""Render a ``verify_by_readback`` result in the operation envelope's shape.
|
|
100
|
+
|
|
101
|
+
A contradiction stays a distinct status rather than collapsing into a plain
|
|
102
|
+
failure: "the API said yes and the post-state says no" is a different thing
|
|
103
|
+
for a caller to act on than "the call failed".
|
|
104
|
+
"""
|
|
105
|
+
verified = bool(readback_result.get("verified"))
|
|
106
|
+
contradiction = bool(readback_result.get("contradiction"))
|
|
107
|
+
check: Dict[str, Any] = {
|
|
108
|
+
"check": check_name,
|
|
109
|
+
"passed": verified,
|
|
110
|
+
"contradiction": contradiction,
|
|
111
|
+
"success_raw": readback_result.get("success_raw"),
|
|
112
|
+
"observed": readback_result.get("observed"),
|
|
113
|
+
}
|
|
114
|
+
if "intent" in readback_result:
|
|
115
|
+
check["intent"] = readback_result["intent"]
|
|
116
|
+
return {
|
|
117
|
+
"status": "contradiction" if contradiction else ("passed" if verified else "failed"),
|
|
118
|
+
"checks": [check],
|
|
119
|
+
"contradiction": contradiction,
|
|
120
|
+
}
|