davinci-resolve-mcp 2.33.2 → 2.33.4
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 +27 -0
- package/README.md +1 -1
- package/docs/SKILL.md +5 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +126 -15
- package/src/utils/readback.py +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
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.33.4
|
|
6
|
+
|
|
7
|
+
Internal reliability framework.
|
|
8
|
+
|
|
9
|
+
- **Added** `verify_by_readback` (`src/utils/readback.py`) — a primitive for
|
|
10
|
+
mutating Resolve ops that verifies an action by reading the real post-state
|
|
11
|
+
back instead of trusting the API's frequently-unreliable return value. A
|
|
12
|
+
contradiction (reported success but a failing readback) is logged as a
|
|
13
|
+
reliability signal.
|
|
14
|
+
- **Changed** Auto-sync audio now runs through `verify_by_readback` as its first
|
|
15
|
+
user and reports a `verified` field alongside `linked`/`newly_linked`. Behavior
|
|
16
|
+
is unchanged; the bespoke readback loop is replaced by the shared primitive.
|
|
17
|
+
|
|
18
|
+
## What's New in v2.33.3
|
|
19
|
+
|
|
20
|
+
Two read tools that surface existing project state.
|
|
21
|
+
|
|
22
|
+
- **Added** `project_settings(action="project_summary")` returns a live
|
|
23
|
+
structural readout — current page, timeline count and current timeline, and a
|
|
24
|
+
media-pool inventory (folder/clip counts, clips by type, optional clip
|
|
25
|
+
sample). A cheap "what's in this project right now" snapshot that needs no
|
|
26
|
+
prior analysis.
|
|
27
|
+
- **Added** `timeline(action="get_transcript")` reads the current timeline's
|
|
28
|
+
subtitle track(s) as transcript text `{text, cue_count, has_subtitles, cues}`,
|
|
29
|
+
with optional per-cue timecodes. Complements the clip-level
|
|
30
|
+
`get_transcription`.
|
|
31
|
+
|
|
5
32
|
## What's New in v2.33.2
|
|
6
33
|
|
|
7
34
|
Documentation: a guide for hand-authoring DaVinci Resolve `.setting` template
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -402,7 +402,9 @@ Key actions: `get_name`, `set_name(name)`, `get_setting(name?)`,
|
|
|
402
402
|
`set_setting(name, value)`, `get_color_groups`, `add_color_group(name)`,
|
|
403
403
|
`delete_color_group(name)`, `export_frame_as_still(path)`,
|
|
404
404
|
`load_burnin_preset(name)`, `insert_audio(media_path, ...)`,
|
|
405
|
-
`apply_fairlight_preset(preset_name)
|
|
405
|
+
`apply_fairlight_preset(preset_name)`,
|
|
406
|
+
`project_summary(include_clips?, clip_limit?)` — live structural readout
|
|
407
|
+
(current page, timeline count, media-pool inventory by type)
|
|
406
408
|
|
|
407
409
|
---
|
|
408
410
|
|
|
@@ -723,6 +725,8 @@ Key actions:
|
|
|
723
725
|
- `get_current` — current timeline info
|
|
724
726
|
- `set_current(index)` — switch timeline by 1-based index
|
|
725
727
|
- `get_track_count(track_type)` — track_type: `"video"`, `"audio"`, `"subtitle"`
|
|
728
|
+
- `get_transcript(with_timecodes?)` — read the subtitle track(s) as transcript
|
|
729
|
+
text `{text, cue_count, has_subtitles, cues}`
|
|
726
730
|
- `add_track(track_type, sub_type?)` / `delete_track(track_type, index)`
|
|
727
731
|
- `get_items(track_type, index)` — items on a track
|
|
728
732
|
- `clip_where(track_type?, track_index?, name_contains?, duration_lt?, duration_gt?)` —
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.33.
|
|
38
|
+
VERSION = "2.33.4"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.33.
|
|
83
|
+
VERSION = "2.33.4"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
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 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.33.
|
|
14
|
+
VERSION = "2.33.4"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -43,6 +43,7 @@ for p in [current_dir, project_dir]:
|
|
|
43
43
|
# Platform-specific Resolve paths
|
|
44
44
|
from src.utils.cdl import normalize_cdl_payload
|
|
45
45
|
from src.utils.mcp_stdio import run_fastmcp_stdio
|
|
46
|
+
from src.utils.readback import verify_by_readback
|
|
46
47
|
from src.utils.update_check import (
|
|
47
48
|
check_for_updates,
|
|
48
49
|
clear_update_prompt_preferences,
|
|
@@ -1885,6 +1886,89 @@ def _navigate_folder(mp, path):
|
|
|
1885
1886
|
return current
|
|
1886
1887
|
|
|
1887
1888
|
|
|
1889
|
+
def _project_summary(proj, *, include_clips=False, clip_limit=50):
|
|
1890
|
+
"""Live structural readout of a project: page, timelines, media-pool inventory.
|
|
1891
|
+
|
|
1892
|
+
Distinct from the analysis-derived bin summary — this is a cheap "what's in
|
|
1893
|
+
this project right now" snapshot that needs no prior analysis.
|
|
1894
|
+
"""
|
|
1895
|
+
by_type: Dict[str, int] = {}
|
|
1896
|
+
sample_clips: List[Dict[str, Any]] = []
|
|
1897
|
+
folder_count = 0
|
|
1898
|
+
clip_count = 0
|
|
1899
|
+
|
|
1900
|
+
mp = proj.GetMediaPool()
|
|
1901
|
+
root = mp.GetRootFolder() if mp else None
|
|
1902
|
+
|
|
1903
|
+
def walk(folder):
|
|
1904
|
+
nonlocal folder_count, clip_count
|
|
1905
|
+
folder_count += 1
|
|
1906
|
+
for clip in (folder.GetClipList() or []):
|
|
1907
|
+
clip_count += 1
|
|
1908
|
+
try:
|
|
1909
|
+
ctype = clip.GetClipProperty("Type") or "Unknown"
|
|
1910
|
+
except Exception:
|
|
1911
|
+
ctype = "Unknown"
|
|
1912
|
+
by_type[ctype] = by_type.get(ctype, 0) + 1
|
|
1913
|
+
if include_clips and len(sample_clips) < clip_limit:
|
|
1914
|
+
sample_clips.append({
|
|
1915
|
+
"name": _clip_name(clip),
|
|
1916
|
+
"type": ctype,
|
|
1917
|
+
"id": clip.GetUniqueId(),
|
|
1918
|
+
})
|
|
1919
|
+
for sub in (folder.GetSubFolderList() or []):
|
|
1920
|
+
walk(sub)
|
|
1921
|
+
|
|
1922
|
+
if root:
|
|
1923
|
+
walk(root)
|
|
1924
|
+
|
|
1925
|
+
cur_tl = proj.GetCurrentTimeline()
|
|
1926
|
+
r = get_resolve()
|
|
1927
|
+
return {
|
|
1928
|
+
"project": proj.GetName(),
|
|
1929
|
+
"current_page": r.GetCurrentPage() if r else None,
|
|
1930
|
+
"timeline_count": proj.GetTimelineCount(),
|
|
1931
|
+
"current_timeline": cur_tl.GetName() if cur_tl else None,
|
|
1932
|
+
"media_pool": {
|
|
1933
|
+
"folder_count": folder_count,
|
|
1934
|
+
"clip_count": clip_count,
|
|
1935
|
+
"by_type": by_type,
|
|
1936
|
+
},
|
|
1937
|
+
"clips": sample_clips if include_clips else None,
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def _timeline_transcript(tl, *, with_timecodes=False):
|
|
1942
|
+
"""Read a timeline's subtitle track(s) as transcript text."""
|
|
1943
|
+
try:
|
|
1944
|
+
track_count = tl.GetTrackCount("subtitle")
|
|
1945
|
+
except Exception:
|
|
1946
|
+
track_count = 0
|
|
1947
|
+
cues: List[Dict[str, Any]] = []
|
|
1948
|
+
for ti in range(1, (track_count or 0) + 1):
|
|
1949
|
+
for item in (tl.GetItemListInTrack("subtitle", ti) or []):
|
|
1950
|
+
text = ""
|
|
1951
|
+
try:
|
|
1952
|
+
text = item.GetName() or ""
|
|
1953
|
+
except Exception:
|
|
1954
|
+
text = ""
|
|
1955
|
+
cue = {"text": text}
|
|
1956
|
+
if with_timecodes:
|
|
1957
|
+
try:
|
|
1958
|
+
cue["start"] = item.GetStart()
|
|
1959
|
+
cue["end"] = item.GetEnd()
|
|
1960
|
+
except Exception:
|
|
1961
|
+
pass
|
|
1962
|
+
cues.append(cue)
|
|
1963
|
+
full = " ".join(c["text"] for c in cues if c.get("text"))
|
|
1964
|
+
return {
|
|
1965
|
+
"text": full,
|
|
1966
|
+
"cue_count": len(cues),
|
|
1967
|
+
"has_subtitles": bool(cues),
|
|
1968
|
+
"cues": cues,
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
|
|
1888
1972
|
def _normalize_record_frame(
|
|
1889
1973
|
ci: Dict[str, Any],
|
|
1890
1974
|
index: int,
|
|
@@ -5452,19 +5536,34 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
|
|
|
5452
5536
|
# Read-back verification: AutoSyncAudio's boolean return is unreliable, so
|
|
5453
5537
|
# capture each clip's "Synced Audio" linkage before and after and report the
|
|
5454
5538
|
# delta. Trust `linked`/`newly_linked`, not `success`.
|
|
5455
|
-
before
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5539
|
+
def _categorize(before, observed):
|
|
5540
|
+
linked, newly, already = [], [], []
|
|
5541
|
+
for c, was, now in zip(clips, before, observed):
|
|
5542
|
+
if now:
|
|
5543
|
+
name = _clip_name(c)
|
|
5544
|
+
linked.append(name)
|
|
5545
|
+
(already if was else newly).append(name)
|
|
5546
|
+
return {
|
|
5547
|
+
"linked": linked,
|
|
5548
|
+
"newly_linked": newly,
|
|
5549
|
+
"already_linked": already,
|
|
5550
|
+
"verified": bool(linked),
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
res = verify_by_readback(
|
|
5554
|
+
mutate=lambda: mp.AutoSyncAudio(clips, settings),
|
|
5555
|
+
observe=lambda: [_synced_audio(c) for c in clips],
|
|
5556
|
+
snapshot=lambda: [_synced_audio(c) for c in clips],
|
|
5557
|
+
compare=_categorize,
|
|
5558
|
+
label="auto_sync_audio",
|
|
5559
|
+
intent={"clip_count": len(clips)},
|
|
5560
|
+
)
|
|
5463
5561
|
return {
|
|
5464
|
-
"success":
|
|
5465
|
-
"
|
|
5466
|
-
"
|
|
5467
|
-
"
|
|
5562
|
+
"success": res["success_raw"],
|
|
5563
|
+
"verified": res["verified"],
|
|
5564
|
+
"linked": res["linked"],
|
|
5565
|
+
"newly_linked": res["newly_linked"],
|
|
5566
|
+
"already_linked": res["already_linked"],
|
|
5468
5567
|
"count": len(clips),
|
|
5469
5568
|
"missing": missing,
|
|
5470
5569
|
"settings": settings,
|
|
@@ -12484,6 +12583,8 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12484
12583
|
refresh_luts() -> {success}
|
|
12485
12584
|
get_gallery() -> {available}
|
|
12486
12585
|
export_frame_as_still(path) -> {success}
|
|
12586
|
+
project_summary(include_clips?, clip_limit?) -> {project, current_page, timeline_count, current_timeline, media_pool}
|
|
12587
|
+
Live structural readout — what's in this project right now (no analysis needed).
|
|
12487
12588
|
load_burnin_preset(name) -> {success}
|
|
12488
12589
|
insert_audio(media_path, start_offset?, duration?) -> {success}
|
|
12489
12590
|
get_color_groups() -> {groups}
|
|
@@ -12524,6 +12625,12 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12524
12625
|
if parent and not os.path.isdir(parent):
|
|
12525
12626
|
return _err(f"export_frame_as_still target directory does not exist: {parent}")
|
|
12526
12627
|
return {"success": bool(proj.ExportCurrentFrameAsStill(still_path))}
|
|
12628
|
+
elif action == "project_summary":
|
|
12629
|
+
return _project_summary(
|
|
12630
|
+
proj,
|
|
12631
|
+
include_clips=bool(p.get("include_clips")),
|
|
12632
|
+
clip_limit=int(p.get("clip_limit", 50)),
|
|
12633
|
+
)
|
|
12527
12634
|
elif action == "load_burnin_preset":
|
|
12528
12635
|
return {"success": bool(proj.LoadBurnInPreset(p["name"]))}
|
|
12529
12636
|
elif action == "insert_audio":
|
|
@@ -12584,7 +12691,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12584
12691
|
return {"success": False}
|
|
12585
12692
|
return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId(),
|
|
12586
12693
|
"output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
|
|
12587
|
-
return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
|
|
12694
|
+
return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","project_summary","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
|
|
12588
12695
|
|
|
12589
12696
|
|
|
12590
12697
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -15078,6 +15185,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15078
15185
|
get_unique_id() -> {id}
|
|
15079
15186
|
get_node_graph() -> {available}
|
|
15080
15187
|
get_media_pool_item() -> {name, id}
|
|
15188
|
+
get_transcript(with_timecodes?) -> {text, cue_count, has_subtitles, cues}
|
|
15189
|
+
Read the timeline's subtitle track(s) as transcript text.
|
|
15081
15190
|
get_mark_in_out() -> {mark}
|
|
15082
15191
|
set_mark_in_out(mark_in, mark_out, type?) -> {success}
|
|
15083
15192
|
clear_mark_in_out(type?) -> {success}
|
|
@@ -15363,6 +15472,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15363
15472
|
elif action == "get_media_pool_item":
|
|
15364
15473
|
mpi = tl.GetMediaPoolItem()
|
|
15365
15474
|
return {"name": mpi.GetName(), "id": mpi.GetUniqueId()} if mpi else {"name": None, "id": None}
|
|
15475
|
+
elif action == "get_transcript":
|
|
15476
|
+
return _timeline_transcript(tl, with_timecodes=bool(p.get("with_timecodes")))
|
|
15366
15477
|
elif action == "get_mark_in_out":
|
|
15367
15478
|
return _ser(tl.GetMarkInOut())
|
|
15368
15479
|
elif action == "set_mark_in_out":
|
|
@@ -15566,7 +15677,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15566
15677
|
if mp_err:
|
|
15567
15678
|
return mp_err
|
|
15568
15679
|
return _fairlight_boundary_report(proj, mp, tl, p)
|
|
15569
|
-
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
15680
|
+
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
15570
15681
|
|
|
15571
15682
|
|
|
15572
15683
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Readback verification for unreliable Resolve API mutations.
|
|
2
|
+
|
|
3
|
+
The Resolve scripting API frequently returns a success value that does not
|
|
4
|
+
reflect what actually happened: ``AutoSyncAudio`` returns a boolean unrelated to
|
|
5
|
+
whether clips linked, ``AppendToTimeline`` may not hand back the new item id,
|
|
6
|
+
many setters return ``True`` regardless of effect, and Fusion ``Paste()`` can
|
|
7
|
+
report nothing while creating nothing. The defense is to **verify by reading the
|
|
8
|
+
real post-state back** instead of trusting the return value.
|
|
9
|
+
|
|
10
|
+
``verify_by_readback`` is the small primitive every mutating op can use. A
|
|
11
|
+
mutation that reports success while the readback disagrees is a *contradiction*
|
|
12
|
+
— the single most valuable reliability signal — and is logged.
|
|
13
|
+
"""
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Callable, Dict, Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("davinci-resolve-mcp")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_by_readback(
|
|
21
|
+
mutate: Callable[[], Any],
|
|
22
|
+
observe: Callable[[], Any],
|
|
23
|
+
*,
|
|
24
|
+
snapshot: Optional[Callable[[], Any]] = None,
|
|
25
|
+
compare: Optional[Callable[[Any, Any], Dict[str, Any]]] = None,
|
|
26
|
+
intent: Optional[Dict[str, Any]] = None,
|
|
27
|
+
label: Optional[str] = None,
|
|
28
|
+
) -> Dict[str, Any]:
|
|
29
|
+
"""Run a mutation and verify it by reading actual state back.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
mutate: performs the Resolve call; its return value is the unreliable
|
|
33
|
+
``success_raw``.
|
|
34
|
+
observe: reads the relevant state AFTER the mutation.
|
|
35
|
+
snapshot: optional — captures pre-state for delta comparisons (passed to
|
|
36
|
+
``compare`` as ``before``).
|
|
37
|
+
compare: ``(before, observed) -> dict`` merged into the result. It should
|
|
38
|
+
set ``verified: bool``. Defaults to ``verified = truthy(observed)``.
|
|
39
|
+
intent: optional description of what we meant to do (for the ledger).
|
|
40
|
+
label: optional op name, used in the contradiction log line.
|
|
41
|
+
|
|
42
|
+
Returns a dict with at least ``success_raw`` and ``verified``, plus whatever
|
|
43
|
+
``compare`` contributed and (if given) ``intent``.
|
|
44
|
+
"""
|
|
45
|
+
before = snapshot() if snapshot is not None else None
|
|
46
|
+
raw = mutate()
|
|
47
|
+
observed = observe()
|
|
48
|
+
|
|
49
|
+
result: Dict[str, Any] = {"success_raw": bool(raw), "observed": observed}
|
|
50
|
+
if compare is not None:
|
|
51
|
+
result.update(compare(before, observed))
|
|
52
|
+
else:
|
|
53
|
+
result["verified"] = bool(observed)
|
|
54
|
+
if intent is not None:
|
|
55
|
+
result["intent"] = intent
|
|
56
|
+
|
|
57
|
+
# A contradiction — reported success but the readback disagrees — is the
|
|
58
|
+
# signal worth surfacing loudly.
|
|
59
|
+
if result.get("success_raw") and not result.get("verified"):
|
|
60
|
+
logger.warning(
|
|
61
|
+
"readback contradiction%s: API reported success but post-state "
|
|
62
|
+
"verification failed%s",
|
|
63
|
+
f" [{label}]" if label else "",
|
|
64
|
+
f" (intent={intent})" if intent else "",
|
|
65
|
+
)
|
|
66
|
+
result["contradiction"] = True
|
|
67
|
+
else:
|
|
68
|
+
result["contradiction"] = False
|
|
69
|
+
|
|
70
|
+
return result
|