davinci-resolve-mcp 2.69.3 → 2.70.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.
@@ -0,0 +1,338 @@
1
+ """The probe catalogue for GUI-vs-headless differencing, and its verdicts.
2
+
3
+ This is the second attempt. The first (`headless_differential`) compared 238
4
+ observations and found zero mode-dependent failures — a result that was true as
5
+ far as it went and badly incomplete, because it could not observe the failure
6
+ that actually matters. `ProjectManager.SaveProject()` on the never-saved
7
+ `Untitled Project` does not *fail* headless; it **never returns**. A harness with
8
+ no timeouts cannot record that. It just stops, and its report is whatever it had
9
+ managed to write down before it died.
10
+
11
+ So this module adds the verdict the first one lacked:
12
+
13
+ hang — the call did not return within its timeout
14
+
15
+ and the catalogue is written so every probe carries its own timeout and its own
16
+ preconditions. `SaveProject` is not inherently dangerous; it is dangerous *on a
17
+ project that has never been saved*. State is part of the probe, not background.
18
+
19
+ ## Why probes are data rather than a script
20
+
21
+ Each probe is a named unit with a category, a timeout and a body. That shape is
22
+ what lets the runner isolate them: results stream out one at a time, so when a
23
+ worker dies mid-probe the supervisor knows exactly which one was in flight, marks
24
+ it `hang`, and resumes after it. A single linear script cannot be resumed at the
25
+ call that killed it.
26
+
27
+ ## Verdicts
28
+
29
+ Beyond the first module's set:
30
+
31
+ - ``hang`` — no return within the timeout, in at least one mode.
32
+ - ``hang_headless`` — returned in the GUI, hung headless. The SaveProject
33
+ class of bug, and the reason this file exists.
34
+ - ``hang_gui`` — the reverse.
35
+
36
+ A hang is deliberately NOT folded into "failed". A call that returns False is
37
+ survivable and usually recoverable; a call that never returns takes the session
38
+ with it and, as measured, degrades the instance afterwards. An agent must treat
39
+ them differently, so the report must distinguish them.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from typing import Any, Callable, Dict, List, Optional
45
+
46
+ #: Probe outcomes recorded by the worker, before any cross-mode comparison.
47
+ OUTCOMES = ("ok", "false", "none", "empty", "raised", "hang", "skipped")
48
+
49
+ #: How a single probe result maps to "did this work". `false`/`none` are the
50
+ #: Resolve API's two ways of saying no; `empty` is a container where the point of
51
+ #: the call was to return items.
52
+ FAILED_OUTCOMES = frozenset({"false", "none", "raised"})
53
+
54
+
55
+ class Probe:
56
+ """One named, timed, isolated observation.
57
+
58
+ `needs` names the fixture attributes the body requires (``project``,
59
+ ``timeline``, ``item``, ``clip``). A probe whose needs are unmet is recorded
60
+ `skipped` with the reason, never silently dropped — an absent row in a
61
+ differential reads as agreement.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ name: str,
67
+ category: str,
68
+ body: Callable[[Any], Any],
69
+ *,
70
+ timeout: float = 30.0,
71
+ needs: tuple = (),
72
+ state: str = "scratch",
73
+ destructive: bool = False,
74
+ blocking: bool = False,
75
+ note: str = "",
76
+ ) -> None:
77
+ self.name = name
78
+ self.category = category
79
+ self.body = body
80
+ self.timeout = timeout
81
+ self.needs = needs
82
+ #: Which project must be current: "scratch" (the disposable one) or
83
+ #: "untitled" (Resolve's never-saved default). The second exists because
84
+ #: the only mode-dependent hang found so far only happens there.
85
+ self.state = state
86
+ self.destructive = destructive
87
+ #: Blocks the ENTIRE application, not just the calling client — a modal
88
+ #: with no one to dismiss it. Excluded from sweeps unless asked for by
89
+ #: name, because one of these wedges the machine and costs a force-kill
90
+ #: and a slow restart. Measured: `pm.save_untitled_project` does this in
91
+ #: BOTH modes, so it is not something a mode sweep can route around.
92
+ self.blocking = blocking
93
+ self.note = note
94
+
95
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
96
+ return f"<Probe {self.name} ({self.category}) t={self.timeout}s state={self.state}>"
97
+
98
+
99
+ def classify_outcome(value: Any) -> str:
100
+ """Map a returned value onto an outcome name.
101
+
102
+ `empty` is separated from `false`/`none` because an empty list is a real
103
+ answer for some calls (a timeline with no markers) and a failure for others
104
+ (an enumeration that should never be empty). Recording which it was lets the
105
+ comparison stay strict without inventing failures.
106
+ """
107
+ if value is False:
108
+ return "false"
109
+ if value is None:
110
+ return "none"
111
+ if isinstance(value, (list, dict, tuple, str)) and len(value) == 0:
112
+ return "empty"
113
+ return "ok"
114
+
115
+
116
+ VERDICTS = (
117
+ "parity",
118
+ "flaky",
119
+ "hang_headless",
120
+ "hang_gui",
121
+ "headless_degraded",
122
+ "gui_degraded",
123
+ "both_hang",
124
+ "both_failed",
125
+ "divergent",
126
+ "untested",
127
+ )
128
+
129
+ #: Probes whose value legitimately differs between two sequential sweeps because
130
+ #: the sweep itself moved app-global state. Compared by outcome only.
131
+ #: `app.current_page` is the case: the page is one global setting and the `pages`
132
+ #: probes walk all seven of it, so whichever page the sweep happened to end on is
133
+ #: read back later. Reporting that every run trains the reader to skim the
134
+ #: findings table, which is the one table that must stay worth reading.
135
+ ORDER_SENSITIVE = frozenset({"app.current_page"})
136
+
137
+ #: Verdicts that describe a real mode difference an agent must act on.
138
+ ACTIONABLE = ("hang_headless", "hang_gui", "headless_degraded", "gui_degraded", "divergent")
139
+
140
+
141
+ def compare_repeated(name: str, gui_runs: List[Dict[str, Any]],
142
+ headless_runs: List[Dict[str, Any]]) -> Dict[str, Any]:
143
+ """Compare N runs per mode, and refuse to call a flaky probe a difference.
144
+
145
+ This exists because a single pass per mode reports noise as findings, and it
146
+ was measured doing exactly that. Across four sweeps, `app.keyframe_mode`
147
+ returned `0` and `None` in BOTH modes depending on the run — a single pair
148
+ would have reported it as `headless_degraded` or `gui_degraded` with equal
149
+ confidence, and the direction it picked was decided by which sweeps happened
150
+ to be compared. `gallery.export_stills` succeeded in the GUI in two sweeps
151
+ and failed in a third.
152
+
153
+ So: a probe whose outcomes are not internally consistent *within* a mode
154
+ cannot support any claim about the difference *between* modes. It is reported
155
+ `flaky`, with the observed values, and kept out of the findings table. That
156
+ is a weaker result and the only honest one — and it is information in its own
157
+ right, since a call that is unreliable run to run is one an agent must verify
158
+ rather than trust.
159
+ """
160
+ if not gui_runs or not headless_runs:
161
+ return {"probe": name, "verdict": "untested",
162
+ "reason": f"gui runs={len(gui_runs)}, headless runs={len(headless_runs)}",
163
+ "gui": gui_runs[0] if gui_runs else None,
164
+ "headless": headless_runs[0] if headless_runs else None}
165
+
166
+ def signature(runs):
167
+ return {f"{r.get('outcome')}:{r.get('value')}" for r in runs}
168
+
169
+ gui_sig, headless_sig = signature(gui_runs), signature(headless_runs)
170
+ unstable = [m for m, sig in (("gui", gui_sig), ("headless", headless_sig)) if len(sig) > 1]
171
+ if unstable:
172
+ return {
173
+ "probe": name, "verdict": "flaky",
174
+ "reason": f"not reproducible within {'/'.join(unstable)} across "
175
+ f"{len(gui_runs)} GUI and {len(headless_runs)} headless run(s)",
176
+ "gui": gui_runs[-1], "headless": headless_runs[-1],
177
+ "gui_observed": sorted(gui_sig), "headless_observed": sorted(headless_sig),
178
+ }
179
+
180
+ verdict = compare_probe(name, gui_runs[-1], headless_runs[-1])
181
+ verdict["runs"] = {"gui": len(gui_runs), "headless": len(headless_runs)}
182
+ return verdict
183
+
184
+
185
+ def build_repeated_matrix(gui_stores: List[Dict[str, Dict[str, Any]]],
186
+ headless_stores: List[Dict[str, Dict[str, Any]]]) -> Dict[str, Any]:
187
+ names = sorted({n for store in gui_stores + headless_stores for n in store})
188
+ findings = [
189
+ compare_repeated(n,
190
+ [s[n] for s in gui_stores if n in s],
191
+ [s[n] for s in headless_stores if n in s])
192
+ for n in names
193
+ ]
194
+ counts = {v: 0 for v in VERDICTS}
195
+ for f in findings:
196
+ counts[f["verdict"]] += 1
197
+ return {"counts": counts, "findings": findings, "probe_count": len(names),
198
+ "runs": {"gui": len(gui_stores), "headless": len(headless_stores)}}
199
+
200
+
201
+ def compare_probe(name: str, gui: Optional[Dict[str, Any]], headless: Optional[Dict[str, Any]]) -> Dict[str, Any]:
202
+ """Two recorded probe results, one verdict."""
203
+ if gui is None or headless is None:
204
+ return {
205
+ "probe": name,
206
+ "verdict": "untested",
207
+ "reason": "recorded in one mode only",
208
+ "gui": gui,
209
+ "headless": headless,
210
+ }
211
+
212
+ g_out, h_out = gui.get("outcome"), headless.get("outcome")
213
+ if "skipped" in (g_out, h_out):
214
+ return {"probe": name, "verdict": "untested",
215
+ "reason": gui.get("detail") or headless.get("detail") or "skipped",
216
+ "gui": gui, "headless": headless}
217
+
218
+ # Hangs first and separately: a call that never returns is a different
219
+ # problem from one that returns False, and collapsing them would hide the
220
+ # only class of bug this harness was built to find.
221
+ if g_out == "hang" and h_out == "hang":
222
+ return {"probe": name, "verdict": "both_hang", "gui": gui, "headless": headless}
223
+ if h_out == "hang":
224
+ return {"probe": name, "verdict": "hang_headless", "gui": gui, "headless": headless,
225
+ "reason": "returned with a UI, never returned without one"}
226
+ if g_out == "hang":
227
+ return {"probe": name, "verdict": "hang_gui", "gui": gui, "headless": headless,
228
+ "reason": "returned headless, never returned with a UI"}
229
+
230
+ g_failed, h_failed = g_out in FAILED_OUTCOMES, h_out in FAILED_OUTCOMES
231
+ if g_failed and h_failed:
232
+ return {"probe": name, "verdict": "both_failed", "gui": gui, "headless": headless}
233
+ if h_failed:
234
+ return {"probe": name, "verdict": "headless_degraded", "gui": gui, "headless": headless,
235
+ "reason": "succeeded with a UI, failed without one"}
236
+ if g_failed:
237
+ return {"probe": name, "verdict": "gui_degraded", "gui": gui, "headless": headless,
238
+ "reason": "succeeded headless, failed with a UI"}
239
+
240
+ if name in ORDER_SENSITIVE:
241
+ return {"probe": name, "verdict": "parity", "gui": gui, "headless": headless,
242
+ "reason": "app-global state the sweep itself moves; compared by outcome only"}
243
+ if gui.get("value") == headless.get("value"):
244
+ return {"probe": name, "verdict": "parity", "gui": gui, "headless": headless}
245
+ return {"probe": name, "verdict": "divergent", "gui": gui, "headless": headless,
246
+ "reason": "both returned, values differ"}
247
+
248
+
249
+ def build_matrix(gui: Dict[str, Dict[str, Any]], headless: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
250
+ names = sorted(set(gui) | set(headless))
251
+ findings = [compare_probe(n, gui.get(n), headless.get(n)) for n in names]
252
+ counts = {v: 0 for v in VERDICTS}
253
+ for f in findings:
254
+ counts[f["verdict"]] += 1
255
+ return {"counts": counts, "findings": findings, "probe_count": len(names)}
256
+
257
+
258
+ def actionable(matrix: Dict[str, Any]) -> List[Dict[str, Any]]:
259
+ order = {v: i for i, v in enumerate(ACTIONABLE)}
260
+ return sorted(
261
+ (f for f in matrix["findings"] if f["verdict"] in order),
262
+ key=lambda f: (order[f["verdict"]], f["probe"]),
263
+ )
264
+
265
+
266
+ def render_markdown(matrix: Dict[str, Any], *, title: str = "GUI vs headless probe matrix") -> str:
267
+ counts = matrix["counts"]
268
+ runs = matrix.get("runs")
269
+ header = f"{matrix['probe_count']} probes"
270
+ if runs:
271
+ header += f", {runs['gui']} GUI run(s) x {runs['headless']} headless run(s)"
272
+ lines = [f"# {title}", "", header + ".", "",
273
+ "| verdict | count |", "| --- | --- |"]
274
+ for v in VERDICTS:
275
+ lines.append(f"| {v} | {counts.get(v, 0)} |")
276
+
277
+ by_cat: Dict[str, Dict[str, int]] = {}
278
+ for f in matrix["findings"]:
279
+ cat = (f.get("gui") or f.get("headless") or {}).get("category", "?")
280
+ by_cat.setdefault(cat, {v: 0 for v in VERDICTS})[f["verdict"]] += 1
281
+ lines += ["", "## Coverage by category", "",
282
+ "| category | probes | parity | hang_headless | headless_degraded | gui_degraded | divergent | untested |",
283
+ "| --- | --- | --- | --- | --- | --- | --- | --- |"]
284
+ for cat, c in sorted(by_cat.items()):
285
+ lines.append(
286
+ f"| {cat} | {sum(c.values())} | {c['parity']} | {c['hang_headless']} | "
287
+ f"{c['headless_degraded']} | {c['gui_degraded']} | {c['divergent']} | {c['untested']} |"
288
+ )
289
+
290
+ flaky = [f for f in matrix["findings"] if f["verdict"] == "flaky"]
291
+ if flaky:
292
+ lines += ["", "## Not reproducible (excluded from findings)", "",
293
+ "These returned different things run-to-run *within* a single mode, so they "
294
+ "cannot support a claim about the difference between modes. Listed because "
295
+ "an unreliable call is itself something an agent must verify rather than trust.",
296
+ "", "| probe | observed in GUI | observed headless |", "| --- | --- | --- |"]
297
+ for f in flaky:
298
+ lines.append("| `{p}` | {g} | {h} |".format(
299
+ p=f["probe"],
300
+ g="; ".join(str(x)[:40] for x in f.get("gui_observed", [])),
301
+ h="; ".join(str(x)[:40] for x in f.get("headless_observed", []))))
302
+
303
+ acted = actionable(matrix)
304
+ lines += ["", "## Mode-dependent findings", ""]
305
+ if not acted:
306
+ lines.append("None: every probe that ran in both modes agreed.")
307
+ return "\n".join(lines) + "\n"
308
+ lines += [
309
+ "> **Re-verify every row below in isolation before believing it.** Probes run in "
310
+ "catalogue order against one shared fixture, so a probe near the end has ~90 "
311
+ "destructive probes' worth of accumulated state behind it, and the two modes can "
312
+ "drift apart for reasons that have nothing to do with the mode. Measured: five "
313
+ "findings here — nested timelines, clip linking, take selectors, multi-job queues "
314
+ "and render presets — all reproduced as mode differences in a full sweep and all "
315
+ "came out **identical** when re-run with `--only`. Isolation is the test:",
316
+ "",
317
+ "> ```",
318
+ "> python scripts/mode_matrix.py run --mode gui --out iso_gui.jsonl --only <probe>",
319
+ "> python scripts/mode_matrix.py run --mode headless --out iso_hl.jsonl --only <probe>",
320
+ "> ```",
321
+ "",
322
+ ]
323
+ lines += ["| probe | verdict | GUI | headless | note |", "| --- | --- | --- | --- | --- |"]
324
+ for f in acted:
325
+ lines.append("| `{p}` | {v} | {g} | {h} | {r} |".format(
326
+ p=f["probe"], v=f["verdict"],
327
+ g=_cell(f.get("gui")), h=_cell(f.get("headless")), r=f.get("reason", "")))
328
+ return "\n".join(lines) + "\n"
329
+
330
+
331
+ def _cell(result: Optional[Dict[str, Any]], limit: int = 46) -> str:
332
+ if not result:
333
+ return "—"
334
+ out = result.get("outcome")
335
+ value = result.get("value")
336
+ text = out if value in (None, "") else f"{out}: {value}"
337
+ text = str(text).replace("|", "\\|").replace("\n", " ")
338
+ return text if len(text) <= limit else text[: limit - 1] + "…"