davinci-resolve-mcp 2.205.2 → 2.206.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
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.206.0 — agent execution traces
6
+
7
+ Adapted from the design contributed in PR #183.
8
+
9
+ ### Added
10
+
11
+ - **Execution traces answer "why did the editor do this?"** v2.205.0's
12
+ `_operation` envelope describes one call; a real editorial pass is a loop of
13
+ them. When an agent removes 17 pauses, seventeen individual returns each show
14
+ one deletion. A trace correlates them into a single execution carrying the
15
+ request that started it, the tools invoked and how often, cumulative
16
+ `duration_ms`, the summed semantic deltas (`items_deleted: 17`), and a
17
+ verification rollup that keeps a contradiction distinct.
18
+ - **Six actions on `resolve_control`** — `begin_execution`, `end_execution`,
19
+ `get_execution_trace`, `get_execution`, `list_recent_executions`,
20
+ `clear_executions` — with the compound tool count unchanged at 36. Any call
21
+ passing an explicit `execution_id` is correlated automatically; queries are
22
+ exempt from step recording, so observing a trace cannot alter it.
23
+ - **`duration_ms` on the `_operation` envelope**, measured with
24
+ `time.perf_counter()` around the call.
25
+
26
+ ### Notes on the adaptation
27
+
28
+ - **The trace log is anchored to the repo, not the working directory.** It was
29
+ derived from `os.getcwd()` and returned None when `./logs` did not exist —
30
+ and the generated client configs set no `cwd`, so on a standard install
31
+ persistence silently did nothing, with no signal either way. It now sits
32
+ beside `server.log`, the way `media-analysis-preferences.json` and
33
+ `server-preferences.json` already do, and the directory is created on first
34
+ write rather than being a precondition.
35
+ - **`list_recent_executions` reports where the log is and whether it is
36
+ writable.** The append is best-effort and must never fail a real edit, which
37
+ means a broken destination is otherwise invisible — "the file is empty" and
38
+ "nothing is being written" looked identical from the caller's side.
39
+ - **The log rotates at 8 MB, keeping one generation.** The in-memory ring was
40
+ capped at 100 executions; the file had no bound at all, at one append per
41
+ tool call, on machines that run for months.
42
+ - **The persistence is described accurately.** It is a synchronous buffered
43
+ append on the calling thread — measured at ~0.07ms per call, immaterial
44
+ beside any Resolve round-trip, but "non-blocking" was the wrong word for it.
45
+ It runs outside the lock, so a slow filesystem cannot serialize concurrent
46
+ tool calls.
47
+ - **What is recorded is now documented**: tool, action, timing, status,
48
+ semantic deltas, verification — no parameters, no file paths, no clip or
49
+ project names. The one free-text field is the `request` passed to
50
+ `begin_execution`, which on client work deserves the care of a commit
51
+ message.
52
+ - Verified through the real stdio JSON-RPC tool layer: 36 tools register, a
53
+ begin/call/end cycle produces one correlated trace, and the reported
54
+ persistence path is the one actually written.
55
+
5
56
  ## What's New in v2.205.2 — #184: background analysis actually starts
6
57
 
7
58
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.205.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.206.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
@@ -266,6 +266,25 @@ confirm gate's `status: "confirmation_required"`. `setup(action="set_defaults",
266
266
  params={"result_envelope": "pure" | "legacy"})` changes the shape, per call via
267
267
  `params={"envelope": ...}`, per process via `RESOLVE_MCP_RESULT_ENVELOPE`.
268
268
 
269
+ ### Agent execution traces ("Why did the editor do this?")
270
+
271
+ Multi-step AI operations correlate across tool calls into unified execution
272
+ traces. Each trace aggregates tool durations (`duration_ms`), call counts, cumulative
273
+ semantic deltas (`items_deleted`, `items_added`), and readback verifications.
274
+ Agents and editors can inspect workflows via `resolve_control`:
275
+ `get_execution_trace(execution_id?)`, `list_recent_executions()`, or open a
276
+ scoped execution with `begin_execution(request="...")` / `end_execution()`.
277
+
278
+ Traces live in a 100-entry in-memory ring and are appended to
279
+ `logs/execution-traces.jsonl` beside `server.log` — `RESOLVE_MCP_TRACE_FILE`
280
+ moves it. `list_recent_executions` reports that path and whether it is
281
+ writable, so "the log is empty" and "nothing is being written" are
282
+ distinguishable without reading the source. What is recorded is tool name,
283
+ action, timing, status, semantic deltas and verification — no parameters and no
284
+ file paths. The one free-text field is the `request` you pass to
285
+ `begin_execution`, so treat it the way you would a commit message on a client
286
+ project.
287
+
269
288
  ## Optional Extras
270
289
 
271
290
  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
- [![Version](https://img.shields.io/badge/version-2.205.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.206.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.205.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.206.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -166,6 +166,12 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
166
166
 
167
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
168
 
169
+ ### Agent 执行轨迹("编辑器为什么这么做?")
170
+
171
+ 多步 AI 操作会跨工具调用关联成统一的执行轨迹,聚合各工具耗时(`duration_ms`)、调用次数、累计语义增量(`items_deleted`、`items_added`)以及回读校验结果。通过 `resolve_control` 查看:`get_execution_trace(execution_id?)`、`list_recent_executions()`,或用 `begin_execution(request="...")` / `end_execution()` 圈定一段执行。
172
+
173
+ 轨迹保存在一个容量 100 条的内存环形缓冲里,并追加写入 `logs/execution-traces.jsonl`(就在 `server.log` 旁边,可用 `RESOLVE_MCP_TRACE_FILE` 改位置),文件到 8 MB 会轮转并保留一份上一代。`list_recent_executions` 会返回该路径以及是否可写,这样"日志是空的"和"根本没在写"就能区分开。记录的内容是工具名、动作、耗时、状态、语义增量和校验结果——不记录参数,也不记录文件路径。唯一的自由文本是你传给 `begin_execution` 的 `request`,在客户项目上请把它当成提交信息来写。
174
+
169
175
  ## 可选增强
170
176
 
171
177
  核心安装刻意保持精简:Python、ffmpeg 和 Resolve 脚本 API。有些功能需要更多依赖,且**每一项都会诚实拒绝并给出自己的安装命令,而不是退化成瞎猜**——编造的节拍或虚构的电平会产出自信但错误的结果,比没有这个功能更糟。
package/docs/SKILL.md CHANGED
@@ -219,6 +219,80 @@ nested under `result`; `legacy` adds nothing.
219
219
 
220
220
  ---
221
221
 
222
+ ## Agent Observability: Execution Traces ("Why did the editor do this?")
223
+
224
+ Multi-step AI operations (such as detecting pauses, deleting multiple timeline
225
+ items, and verifying the result) correlate across calls into unified execution
226
+ traces. Each trace captures the user request or prompt, tool execution timing,
227
+ cumulative semantic deltas, and verification outcomes.
228
+
229
+ Example trace shape returned by `resolve_control(action="get_execution_trace")`:
230
+
231
+ ```json
232
+ {
233
+ "execution_id": "exec_8f91c7a210bc",
234
+ "request": "Remove all pauses longer than 800ms",
235
+ "status": "success",
236
+ "started_at": "2026-09-04T07:30:00Z",
237
+ "ended_at": "2026-09-04T07:30:02Z",
238
+ "duration_ms": 2845,
239
+ "tools": [
240
+ {
241
+ "tool": "media_analysis.analyze_timeline",
242
+ "count": 1,
243
+ "duration_ms": 821
244
+ },
245
+ {
246
+ "tool": "timeline.delete_item",
247
+ "count": 17,
248
+ "duration_ms": 1420
249
+ }
250
+ ],
251
+ "changes": {
252
+ "items_deleted": 17
253
+ },
254
+ "verification": {
255
+ "status": "passed",
256
+ "passed": true,
257
+ "checks": [{"check": "readback_verification", "passed": true}]
258
+ },
259
+ "warnings": []
260
+ }
261
+ ```
262
+
263
+ ### Trace Actions on `resolve_control`
264
+
265
+ - **`begin_execution(request?, execution_id?, initiator?)`**: Opens a scoped
266
+ multi-step execution. Subsequent tool calls in the session automatically thread
267
+ under this `execution_id` until ended.
268
+ - **`end_execution(execution_id?, verification?, status?, notes?)`**: Closes the
269
+ active execution, finalizes timestamps and aggregated metrics.
270
+ - **`get_execution_trace(execution_id?)`** / **`get_execution(execution_id)`**:
271
+ Fetches the trace for a specific ID, or the most recent execution if omitted.
272
+ - **`list_recent_executions(limit?)`**: Returns the recent execution traces
273
+ (newest first, default limit 20).
274
+ - **`clear_executions(dry_run?)`**: Clears the in-memory execution trace buffer.
275
+
276
+ Explicit correlation is also supported per-call: pass `params={"execution_id": ...}`
277
+ or `params={"trace_id": ...}` in any tool call to associate it with a specific trace.
278
+
279
+ Two things to know when a trace is not where you expect it. The buffer holds the
280
+ **100 most recent** executions and is in memory only — a server restart empties
281
+ it, and the on-disk `logs/execution-traces.jsonl` is the durable record.
282
+ `list_recent_executions` returns a `persistence` block naming that file and
283
+ whether it is writable; check it before concluding that nothing was traced,
284
+ since the append is best-effort and will never fail a real edit to report a
285
+ logging problem.
286
+
287
+ Recorded per step: tool, action, `duration_ms`, status, semantic deltas and
288
+ verification. **Not** recorded: parameters, file paths, clip or project names.
289
+ The only free text is the `request` string passed to `begin_execution`.
290
+
291
+ The file rotates at 8 MB, keeping one previous generation as
292
+ `execution-traces.jsonl.1`. Both are gitignored along with the rest of `logs/`.
293
+
294
+ ---
295
+
222
296
  ## Two Server Modes
223
297
 
224
298
  | 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.205.2"
40
+ VERSION = "2.206.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.205.2",
3
+ "version": "2.206.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.205.2"
90
+ VERSION = "2.206.0"
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.205.2"
14
+ VERSION = "2.206.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -66,6 +66,15 @@ from src.utils.operation_result import (
66
66
  get_envelope_mode as _get_envelope_mode,
67
67
  set_envelope_mode as _set_envelope_mode,
68
68
  )
69
+ from src.utils import execution_trace as _execution_trace
70
+ from src.utils.execution_trace import (
71
+ get_execution_trace,
72
+ get_execution,
73
+ list_recent_executions,
74
+ begin_execution,
75
+ end_execution,
76
+ clear_executions,
77
+ )
69
78
  from src.utils.render_ids import (
70
79
  render_codec_id_from_codecs as _render_codec_id_from_codecs,
71
80
  render_format_id_from_formats as _render_format_id_from_formats,
@@ -1487,6 +1496,50 @@ def _guarded_params(args, kwargs) -> Optional[Dict[str, Any]]:
1487
1496
  return None
1488
1497
 
1489
1498
 
1499
+ _TRACE_OBSERVER_ACTIONS = {
1500
+ "get_execution_trace", "get_execution", "list_recent_executions",
1501
+ "clear_executions", "begin_execution", "end_execution",
1502
+ }
1503
+
1504
+
1505
+ def _record_execution_step(
1506
+ tool_name: str,
1507
+ action: str,
1508
+ params: Optional[Dict[str, Any]],
1509
+ raw_result: Any,
1510
+ enveloped: Any,
1511
+ duration_ms: int,
1512
+ ) -> None:
1513
+ if tool_name == "resolve_control" and action in _TRACE_OBSERVER_ACTIONS:
1514
+ return
1515
+ try:
1516
+ env = None
1517
+ if isinstance(enveloped, dict):
1518
+ env = enveloped.get(_operation_result.ENVELOPE_KEY)
1519
+ if not env and "execution_id" in enveloped:
1520
+ env = enveloped
1521
+ exec_id = env.get("execution_id") if isinstance(env, dict) else None
1522
+ status = env.get("status") if isinstance(env, dict) else None
1523
+ verification = env.get("verification") if isinstance(env, dict) else None
1524
+ changes = env.get("changes") if isinstance(env, dict) else None
1525
+ warnings = env.get("warnings") if isinstance(env, dict) else None
1526
+
1527
+ _execution_trace.record_step(
1528
+ tool=tool_name,
1529
+ action=action,
1530
+ params=params if isinstance(params, dict) else None,
1531
+ raw_result=raw_result,
1532
+ duration_ms=duration_ms,
1533
+ execution_id=exec_id,
1534
+ status=status,
1535
+ verification=verification,
1536
+ changes=changes,
1537
+ warnings=warnings,
1538
+ )
1539
+ except Exception as exc: # pragma: no cover
1540
+ logger.debug("Failed to record execution step: %s", exc)
1541
+
1542
+
1490
1543
  def _guard_missing_params(fn):
1491
1544
  """Tool decorator: report a missing parameter instead of leaking a KeyError.
1492
1545
 
@@ -1517,22 +1570,32 @@ def _guard_missing_params(fn):
1517
1570
  @functools.wraps(fn)
1518
1571
  async def wrapper(*args, **kwargs):
1519
1572
  action = _guarded_action_name(args, kwargs)
1573
+ params = _guarded_params(args, kwargs)
1574
+ t0 = time.perf_counter()
1520
1575
  try:
1521
1576
  result = await fn(*args, **kwargs)
1522
1577
  except _MissingParam as exc:
1523
1578
  result = _missing_param_error(exc, action)
1524
- return _build_operation_envelope(
1525
- tool_name, action, _guarded_params(args, kwargs), result)
1579
+ duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
1580
+ enveloped = _build_operation_envelope(
1581
+ tool_name, action, params, result, duration_ms=duration_ms)
1582
+ _record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
1583
+ return enveloped
1526
1584
  else:
1527
1585
  @functools.wraps(fn)
1528
1586
  def wrapper(*args, **kwargs):
1529
1587
  action = _guarded_action_name(args, kwargs)
1588
+ params = _guarded_params(args, kwargs)
1589
+ t0 = time.perf_counter()
1530
1590
  try:
1531
1591
  result = fn(*args, **kwargs)
1532
1592
  except _MissingParam as exc:
1533
1593
  result = _missing_param_error(exc, action)
1534
- return _build_operation_envelope(
1535
- tool_name, action, _guarded_params(args, kwargs), result)
1594
+ duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
1595
+ enveloped = _build_operation_envelope(
1596
+ tool_name, action, params, result, duration_ms=duration_ms)
1597
+ _record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
1598
+ return enveloped
1536
1599
 
1537
1600
  wrapper.__wrapped_by_missing_param_guard__ = True
1538
1601
  return wrapper
@@ -16047,6 +16110,18 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16047
16110
  — Captures the current Resolve UI state so it can be restored after a preview.
16048
16111
  restore_state(state_token) -> {success, restored: {...}}
16049
16112
  — Returns Resolve to a previously-saved state.
16113
+ get_execution_trace(execution_id?) -> {success, trace}
16114
+ — Correlated agent execution trace by execution_id, or most recent if omitted (no connection needed).
16115
+ get_execution(execution_id) -> {success, trace}
16116
+ — Alias for get_execution_trace.
16117
+ list_recent_executions(limit?) -> {success, executions, count}
16118
+ — List recent execution traces with aggregated tool calls, durations, and verifications (no connection needed).
16119
+ begin_execution(request?, execution_id?, initiator?) -> {success, execution_id, started_at, request}
16120
+ — Open a multi-step execution trace so subsequent tool calls thread under this execution ID.
16121
+ end_execution(execution_id?, verification?, status?, notes?) -> {success, trace}
16122
+ — Conclude an execution trace and compute final rollups.
16123
+ clear_executions(dry_run?) -> {success, cleared}
16124
+ — Clear the in-memory execution trace buffer.
16050
16125
  """
16051
16126
  p = _params(params)
16052
16127
 
@@ -16134,6 +16209,53 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16134
16209
  if action == "list_jobs":
16135
16210
  return {"jobs": background_jobs.list_jobs()}
16136
16211
 
16212
+ if action in {"get_execution_trace", "get_execution"}:
16213
+ exec_id = p.get("execution_id") or p.get("id")
16214
+ trace = _execution_trace.get_execution_trace(exec_id)
16215
+ if not trace:
16216
+ return _err(f"No execution trace found for id: {exec_id or 'latest'}", code="NOT_FOUND", category="state")
16217
+ return {"success": True, "trace": trace}
16218
+ if action == "list_recent_executions":
16219
+ limit = _safe_int(p.get("limit"), 20, minimum=1, maximum=100)
16220
+ executions = _execution_trace.list_recent_executions(limit=limit)
16221
+ # Say where the on-disk log is and whether it is actually writable.
16222
+ # The traces themselves live in a 100-entry in-memory ring, so "the
16223
+ # list is short" and "the log is not being written" are different
16224
+ # facts, and a caller should not have to guess which one they have.
16225
+ return {
16226
+ "success": True,
16227
+ "executions": executions,
16228
+ "count": len(executions),
16229
+ "buffer_capacity": _execution_trace.MAX_RECENT_EXECUTIONS,
16230
+ "persistence": _execution_trace.persistence_status(),
16231
+ }
16232
+ if action == "begin_execution":
16233
+ req = p.get("request") or p.get("prompt") or p.get("reason")
16234
+ exec_id = p.get("execution_id") or p.get("id")
16235
+ res = _execution_trace.begin_execution(
16236
+ request=req,
16237
+ execution_id=exec_id,
16238
+ initiator=p.get("initiator") or "agent",
16239
+ )
16240
+ return res
16241
+ if action == "end_execution":
16242
+ exec_id = p.get("execution_id") or p.get("id")
16243
+ trace = _execution_trace.end_execution(
16244
+ execution_id=exec_id,
16245
+ verification=p.get("verification"),
16246
+ status=p.get("status"),
16247
+ notes=p.get("notes"),
16248
+ )
16249
+ if not trace:
16250
+ return _err("No active or matching execution to end", code="NOT_FOUND", category="state")
16251
+ return {"success": True, "trace": trace}
16252
+ if action == "clear_executions":
16253
+ dry_run = _setup_bool(p.get("dry_run", p.get("dryRun")), False)
16254
+ if dry_run:
16255
+ return {"success": True, "dry_run": True, "count": len(_execution_trace.list_recent_executions(100))}
16256
+ res = _execution_trace.clear_executions()
16257
+ return res
16258
+
16137
16259
  # Control-panel actions don't require Resolve to be running.
16138
16260
  if action == "open_control_panel":
16139
16261
  return _open_control_panel(p)
@@ -16348,7 +16470,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16348
16470
  if err:
16349
16471
  return _err(err)
16350
16472
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
16351
- return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
16473
+ return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","clear_executions","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
16352
16474
 
16353
16475
 
16354
16476
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -0,0 +1,546 @@
1
+ """Agent execution tracing and observability ("Why did the editor do this?").
2
+
3
+ Translates agent execution tracing concepts to DaVinci Resolve MCP.
4
+ Connects multi-step tool calls, timing (duration_ms), semantic changes, and
5
+ readback verifications under unified execution traces so human editors and AI
6
+ agents can inspect, debug, and understand AI editorial workflows.
7
+
8
+ Key capabilities:
9
+ 1. Correlated execution traces across multi-step agent actions.
10
+ 2. Per-tool timing (duration_ms) and invocation counts.
11
+ 3. Semantic change aggregation (e.g. items_deleted, items_added).
12
+ 4. Cumulative verification rollup (passed, checks, contradictions).
13
+ 5. In-memory thread-safe ring buffer with fast queries:
14
+ - get_execution_trace(execution_id?) / get_execution(id)
15
+ - list_recent_executions(limit?)
16
+ - begin_execution(request?, execution_id?)
17
+ - end_execution(execution_id?, verification?)
18
+ 6. Best-effort append-only persistence beside the server's own log.
19
+
20
+ Adapted from the design contributed in PR #183.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import collections
26
+ import json
27
+ import logging
28
+ import os
29
+ import threading
30
+ import time
31
+ import uuid
32
+ from pathlib import Path
33
+ from typing import Any, Deque, Dict, List, Optional
34
+
35
+ logger = logging.getLogger("resolve-mcp.execution-trace")
36
+
37
+ MAX_RECENT_EXECUTIONS = 100
38
+
39
+ #: Where traces are written, unless RESOLVE_MCP_TRACE_FILE overrides it.
40
+ #:
41
+ #: Anchored to the repository root, the way `server.log`,
42
+ #: `media-analysis-preferences.json` and `server-preferences.json` all are.
43
+ #: Deriving it from `os.getcwd()` instead put the file wherever the MCP client
44
+ #: happened to launch the server from — and since the generated client configs
45
+ #: set no `cwd`, that is usually a directory with no `logs/` in it, where the
46
+ #: original code returned None and wrote nothing at all. Silently. A feature
47
+ #: whose whole purpose is answering "why did the editor do this?" is worth
48
+ #: rather more than a file that may or may not exist depending on the launcher.
49
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
50
+
51
+ #: Size at which the trace log is rotated, and how many old files are kept.
52
+ #:
53
+ #: The in-memory ring is capped at 100 executions; the file had no bound at all,
54
+ #: and it takes one append per tool call. A default-on log that grows without
55
+ #: limit on a working editorial machine is a slow leak, so it rolls over to
56
+ #: `.1` and starts fresh — one generation back is enough for "what did the
57
+ #: agent just do", which is the whole question this feature answers.
58
+ MAX_TRACE_LOG_BYTES = 8 * 1024 * 1024
59
+ TRACE_LOG_GENERATIONS = 1
60
+
61
+ _LOCK = threading.Lock()
62
+ _EXECUTIONS_BY_ID: Dict[str, Dict[str, Any]] = {}
63
+ _RECENT_ORDER: Deque[str] = collections.deque(maxlen=MAX_RECENT_EXECUTIONS)
64
+ _ACTIVE_EXECUTION_ID: Optional[str] = None
65
+
66
+
67
+ def _now_iso() -> str:
68
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
69
+
70
+
71
+ def new_execution_id() -> str:
72
+ """Generate a correlated execution ID with prefix 'exec_'."""
73
+ return f"exec_{uuid.uuid4().hex[:12]}"
74
+
75
+
76
+ def current_execution_id() -> Optional[str]:
77
+ """Return the active multi-turn execution ID for this session, if any."""
78
+ with _LOCK:
79
+ return _ACTIVE_EXECUTION_ID
80
+
81
+
82
+ def _clean_trace_dict(trace: Dict[str, Any]) -> Dict[str, Any]:
83
+ """Return a deep copy of the execution trace for safe serialization."""
84
+ return json.loads(json.dumps(trace))
85
+
86
+
87
+ def _aggregate_changes(
88
+ cumulative: Optional[Dict[str, Any]],
89
+ delta: Optional[Dict[str, Any]],
90
+ ) -> Optional[Dict[str, Any]]:
91
+ if not delta:
92
+ return cumulative
93
+ if cumulative is None:
94
+ return dict(delta)
95
+
96
+ out = dict(cumulative)
97
+ for k, v in delta.items():
98
+ if isinstance(v, (int, float)):
99
+ existing = out.get(k, 0)
100
+ if isinstance(existing, (int, float)):
101
+ out[k] = existing + v
102
+ else:
103
+ out[k] = v
104
+ else:
105
+ out[k] = v
106
+ return out
107
+
108
+
109
+ def _merge_verification(
110
+ current: Dict[str, Any],
111
+ step_verif: Optional[Dict[str, Any]],
112
+ ) -> Dict[str, Any]:
113
+ if not isinstance(step_verif, dict):
114
+ return current
115
+
116
+ checks = list(current.get("checks", []))
117
+ new_checks = step_verif.get("checks", [])
118
+ if isinstance(new_checks, list):
119
+ checks.extend(new_checks)
120
+
121
+ contradiction = bool(current.get("contradiction")) or bool(step_verif.get("contradiction"))
122
+
123
+ # Determine status priority: contradiction > failed > partial > passed > unverified
124
+ statuses = {current.get("status", "unverified"), step_verif.get("status", "unverified")}
125
+ if contradiction or "contradiction" in statuses:
126
+ status = "contradiction"
127
+ passed = False
128
+ elif "failed" in statuses:
129
+ status = "failed"
130
+ passed = False
131
+ elif "partial" in statuses:
132
+ status = "partial"
133
+ passed = False
134
+ elif "passed" in statuses:
135
+ status = "passed"
136
+ passed = True
137
+ else:
138
+ status = "unverified"
139
+ passed = True
140
+
141
+ return {
142
+ "status": status,
143
+ "passed": passed,
144
+ "contradiction": contradiction,
145
+ "checks": checks,
146
+ }
147
+
148
+
149
+ def begin_execution(
150
+ request: Optional[str] = None,
151
+ *,
152
+ execution_id: Optional[str] = None,
153
+ initiator: Optional[str] = None,
154
+ ) -> Dict[str, Any]:
155
+ """Start an active multi-step execution trace for the session.
156
+
157
+ Subsequent tool calls will automatically thread under this execution ID
158
+ until end_execution() is called.
159
+ """
160
+ global _ACTIVE_EXECUTION_ID
161
+
162
+ exec_id = execution_id or new_execution_id()
163
+ now = _now_iso()
164
+
165
+ trace: Dict[str, Any] = {
166
+ "execution_id": exec_id,
167
+ "request": str(request).strip() if request else None,
168
+ "status": "running",
169
+ "started_at": now,
170
+ "ended_at": None,
171
+ "duration_ms": 0,
172
+ "tools": [],
173
+ "steps": [],
174
+ "changes": None,
175
+ "verification": {
176
+ "status": "unverified",
177
+ "passed": True,
178
+ "contradiction": False,
179
+ "checks": [],
180
+ },
181
+ "warnings": [],
182
+ "initiator": initiator or "agent",
183
+ "is_active": True,
184
+ }
185
+
186
+ with _LOCK:
187
+ _EXECUTIONS_BY_ID[exec_id] = trace
188
+ if exec_id in _RECENT_ORDER:
189
+ _RECENT_ORDER.remove(exec_id)
190
+ _RECENT_ORDER.appendleft(exec_id)
191
+ _ACTIVE_EXECUTION_ID = exec_id
192
+
193
+ _persist_trace_event("begin", trace)
194
+ return {
195
+ "success": True,
196
+ "execution_id": exec_id,
197
+ "started_at": now,
198
+ "request": trace["request"],
199
+ }
200
+
201
+
202
+ def end_execution(
203
+ execution_id: Optional[str] = None,
204
+ *,
205
+ verification: Optional[Dict[str, Any]] = None,
206
+ status: Optional[str] = None,
207
+ notes: Optional[str] = None,
208
+ ) -> Optional[Dict[str, Any]]:
209
+ """End an active execution trace and calculate final rollups."""
210
+ global _ACTIVE_EXECUTION_ID
211
+
212
+ target_id = execution_id or _ACTIVE_EXECUTION_ID
213
+ if not target_id:
214
+ return None
215
+
216
+ now = _now_iso()
217
+ with _LOCK:
218
+ trace = _EXECUTIONS_BY_ID.get(target_id)
219
+ if not trace:
220
+ return None
221
+
222
+ trace["ended_at"] = now
223
+ trace["is_active"] = False
224
+
225
+ if verification:
226
+ trace["verification"] = _merge_verification(trace["verification"], verification)
227
+
228
+ if notes:
229
+ trace["notes"] = str(notes).strip()
230
+
231
+ # Deduce overall status if not explicitly passed
232
+ if status:
233
+ trace["status"] = status
234
+ else:
235
+ if trace["verification"].get("contradiction"):
236
+ trace["status"] = "failed"
237
+ elif any(s.get("status") == "failed" for s in trace.get("steps", [])):
238
+ trace["status"] = "failed"
239
+ elif any(s.get("status") == "partial" for s in trace.get("steps", [])):
240
+ trace["status"] = "partial"
241
+ elif any(s.get("status") == "blocked" for s in trace.get("steps", [])):
242
+ trace["status"] = "blocked"
243
+ else:
244
+ trace["status"] = "success"
245
+
246
+ # If this was the active session execution, clear it
247
+ if _ACTIVE_EXECUTION_ID == target_id:
248
+ _ACTIVE_EXECUTION_ID = None
249
+
250
+ copy_trace = _clean_trace_dict(trace)
251
+
252
+ _persist_trace_event("end", copy_trace)
253
+ return copy_trace
254
+
255
+
256
+ def record_step(
257
+ tool: str,
258
+ action: str,
259
+ params: Optional[Dict[str, Any]],
260
+ raw_result: Any,
261
+ duration_ms: int,
262
+ *,
263
+ execution_id: Optional[str] = None,
264
+ status: Optional[str] = None,
265
+ verification: Optional[Dict[str, Any]] = None,
266
+ changes: Optional[Dict[str, Any]] = None,
267
+ warnings: Optional[List[str]] = None,
268
+ ) -> Dict[str, Any]:
269
+ """Record a single tool call step into an execution trace.
270
+
271
+ If execution_id is provided or an active execution exists, appends to it.
272
+ Otherwise, creates a self-contained single-step execution trace.
273
+ """
274
+ op_name = f"{tool}.{action}"
275
+ step_status = status or ("failed" if isinstance(raw_result, dict) and raw_result.get("error") else "success")
276
+ now = _now_iso()
277
+
278
+ step_record: Dict[str, Any] = {
279
+ "seq": 1,
280
+ "tool": tool,
281
+ "action": action,
282
+ "operation": op_name,
283
+ "duration_ms": max(0, int(duration_ms)),
284
+ "status": step_status,
285
+ "timestamp": now,
286
+ }
287
+ if verification:
288
+ step_record["verification"] = verification
289
+ if changes:
290
+ step_record["changes"] = changes
291
+ if warnings:
292
+ step_record["warnings"] = warnings
293
+
294
+ with _LOCK:
295
+ target_id = execution_id or _ACTIVE_EXECUTION_ID
296
+ is_single_step = False
297
+
298
+ if not target_id:
299
+ target_id = new_execution_id()
300
+ is_single_step = True
301
+ request_text = None
302
+ if isinstance(params, dict):
303
+ request_text = params.get("request") or params.get("prompt") or params.get("reason")
304
+ trace: Dict[str, Any] = {
305
+ "execution_id": target_id,
306
+ "request": str(request_text).strip() if request_text else None,
307
+ "status": step_status,
308
+ "started_at": now,
309
+ "ended_at": now,
310
+ "duration_ms": 0,
311
+ "tools": [],
312
+ "steps": [],
313
+ "changes": None,
314
+ "verification": {
315
+ "status": "unverified",
316
+ "passed": True,
317
+ "contradiction": False,
318
+ "checks": [],
319
+ },
320
+ "warnings": [],
321
+ "initiator": "tool_call",
322
+ "is_active": False,
323
+ }
324
+ _EXECUTIONS_BY_ID[target_id] = trace
325
+ _RECENT_ORDER.appendleft(target_id)
326
+ else:
327
+ trace = _EXECUTIONS_BY_ID.get(target_id)
328
+ if not trace:
329
+ trace = {
330
+ "execution_id": target_id,
331
+ "request": None,
332
+ "status": "running",
333
+ "started_at": now,
334
+ "ended_at": None,
335
+ "duration_ms": 0,
336
+ "tools": [],
337
+ "steps": [],
338
+ "changes": None,
339
+ "verification": {
340
+ "status": "unverified",
341
+ "passed": True,
342
+ "contradiction": False,
343
+ "checks": [],
344
+ },
345
+ "warnings": [],
346
+ "initiator": "agent",
347
+ "is_active": True,
348
+ }
349
+ _EXECUTIONS_BY_ID[target_id] = trace
350
+ _RECENT_ORDER.appendleft(target_id)
351
+
352
+ # Update trace request if provided in params and currently empty
353
+ if not trace.get("request") and isinstance(params, dict):
354
+ req = params.get("request") or params.get("prompt") or params.get("reason")
355
+ if req:
356
+ trace["request"] = str(req).strip()
357
+
358
+ step_record["seq"] = len(trace["steps"]) + 1
359
+ trace["steps"].append(step_record)
360
+ trace["duration_ms"] = int(trace.get("duration_ms", 0)) + max(0, int(duration_ms))
361
+
362
+ # Update aggregated tools entry (matching user format)
363
+ found_tool = None
364
+ for t in trace["tools"]:
365
+ if t.get("tool") == op_name or t.get("tool") == action:
366
+ found_tool = t
367
+ break
368
+
369
+ if found_tool:
370
+ found_tool["count"] = int(found_tool.get("count", 1)) + 1
371
+ found_tool["duration_ms"] = int(found_tool.get("duration_ms", 0)) + max(0, int(duration_ms))
372
+ else:
373
+ trace["tools"].append({
374
+ "tool": op_name,
375
+ "count": 1,
376
+ "duration_ms": max(0, int(duration_ms)),
377
+ })
378
+
379
+ # Aggregate changes
380
+ if changes:
381
+ trace["changes"] = _aggregate_changes(trace.get("changes"), changes)
382
+
383
+ # Merge verification
384
+ if verification:
385
+ trace["verification"] = _merge_verification(trace["verification"], verification)
386
+
387
+ # Merge warnings
388
+ if warnings:
389
+ existing_warnings = set(trace.get("warnings", []))
390
+ for w in warnings:
391
+ w_str = str(w).strip()
392
+ if w_str and w_str not in existing_warnings:
393
+ trace["warnings"].append(w_str)
394
+ existing_warnings.add(w_str)
395
+
396
+ if is_single_step:
397
+ trace["status"] = step_status
398
+ trace["ended_at"] = now
399
+
400
+ result_copy = _clean_trace_dict(trace)
401
+
402
+ _persist_trace_event("step", {"execution_id": target_id, "step": step_record})
403
+ return result_copy
404
+
405
+
406
+ def get_execution_trace(execution_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
407
+ """Look up an execution trace by ID.
408
+
409
+ If execution_id is omitted, returns the most recent execution trace.
410
+ """
411
+ with _LOCK:
412
+ if not execution_id:
413
+ if not _RECENT_ORDER:
414
+ return None
415
+ target_id = _RECENT_ORDER[0]
416
+ else:
417
+ target_id = execution_id
418
+
419
+ trace = _EXECUTIONS_BY_ID.get(target_id)
420
+ if not trace:
421
+ return None
422
+ return _clean_trace_dict(trace)
423
+
424
+
425
+ def get_execution(execution_id: str) -> Optional[Dict[str, Any]]:
426
+ """Alias for get_execution_trace(execution_id)."""
427
+ return get_execution_trace(execution_id)
428
+
429
+
430
+ def list_recent_executions(limit: int = 20) -> List[Dict[str, Any]]:
431
+ """List recent executions (newest first) with summary information."""
432
+ max_count = max(1, min(int(limit), MAX_RECENT_EXECUTIONS))
433
+ out: List[Dict[str, Any]] = []
434
+
435
+ with _LOCK:
436
+ for exec_id in list(_RECENT_ORDER)[:max_count]:
437
+ trace = _EXECUTIONS_BY_ID.get(exec_id)
438
+ if not trace:
439
+ continue
440
+ summary = {
441
+ "execution_id": trace["execution_id"],
442
+ "request": trace.get("request"),
443
+ "status": trace.get("status"),
444
+ "started_at": trace.get("started_at"),
445
+ "ended_at": trace.get("ended_at"),
446
+ "duration_ms": trace.get("duration_ms", 0),
447
+ "tool_count": len(trace.get("tools", [])),
448
+ "step_count": len(trace.get("steps", [])),
449
+ "tools": trace.get("tools", []),
450
+ "verification": trace.get("verification", {}),
451
+ "changes": trace.get("changes"),
452
+ }
453
+ out.append(summary)
454
+
455
+ return out
456
+
457
+
458
+ def clear_executions() -> Dict[str, Any]:
459
+ """Clear in-memory execution traces and active execution ID."""
460
+ global _ACTIVE_EXECUTION_ID
461
+ with _LOCK:
462
+ count = len(_EXECUTIONS_BY_ID)
463
+ _EXECUTIONS_BY_ID.clear()
464
+ _RECENT_ORDER.clear()
465
+ _ACTIVE_EXECUTION_ID = None
466
+ return {"success": True, "cleared": count}
467
+
468
+
469
+ # ── Persistence (Best-Effort) ────────────────────────────────────────────────
470
+
471
+ def trace_log_path() -> str:
472
+ """The file traces are appended to. Always a path, never None.
473
+
474
+ Returning None when a directory did not happen to exist made persistence
475
+ an invisible coin flip; the directory is created on first write instead.
476
+ """
477
+ override = os.environ.get("RESOLVE_MCP_TRACE_FILE")
478
+ if override:
479
+ return os.path.realpath(os.path.abspath(os.path.expanduser(override)))
480
+ return str(_REPO_ROOT / "logs" / "execution-traces.jsonl")
481
+
482
+
483
+ def persistence_status() -> Dict[str, Any]:
484
+ """Whether traces are reaching disk, and where.
485
+
486
+ Reported alongside every query so "no traces in the file" is answerable
487
+ without reading this module: an unwritable path says so here rather than
488
+ being swallowed by the best-effort append.
489
+ """
490
+ path = trace_log_path()
491
+ status: Dict[str, Any] = {"path": path, "writable": False, "exists": False,
492
+ "reason": None}
493
+ try:
494
+ status["exists"] = os.path.isfile(path)
495
+ directory = os.path.dirname(path)
496
+ os.makedirs(directory, exist_ok=True)
497
+ status["writable"] = os.access(directory, os.W_OK)
498
+ if not status["writable"]:
499
+ status["reason"] = f"{directory} is not writable"
500
+ except OSError as exc:
501
+ status["reason"] = str(exc)
502
+ return status
503
+
504
+
505
+ # Kept for callers that used the private name; the public one is the path itself.
506
+ _trace_log_path = trace_log_path
507
+
508
+
509
+ def _rotate_if_oversized(path: str) -> None:
510
+ """Roll the trace log over once it passes the size cap. Never raises."""
511
+ try:
512
+ if os.path.getsize(path) < MAX_TRACE_LOG_BYTES:
513
+ return
514
+ except OSError:
515
+ return
516
+ try:
517
+ previous = f"{path}.{TRACE_LOG_GENERATIONS}"
518
+ if os.path.exists(previous):
519
+ os.remove(previous)
520
+ os.replace(path, previous)
521
+ except OSError as exc:
522
+ logger.debug("Could not rotate the execution trace log: %s", exc)
523
+
524
+
525
+ def _persist_trace_event(event_type: str, data: Any) -> None:
526
+ """Best-effort append to the trace log. Never raises.
527
+
528
+ Synchronous, on the calling thread — not "non-blocking", as this was
529
+ originally described. It is a buffered append of a few hundred bytes and
530
+ measures at ~0.07ms per tool call on local disk, which is immaterial next
531
+ to any Resolve round-trip, but the accurate word for it is *cheap*. It runs
532
+ outside `_LOCK` so a slow filesystem cannot serialize concurrent tool calls.
533
+ """
534
+ try:
535
+ path = trace_log_path()
536
+ os.makedirs(os.path.dirname(path), exist_ok=True)
537
+ _rotate_if_oversized(path)
538
+ line = json.dumps({
539
+ "event": event_type,
540
+ "timestamp": _now_iso(),
541
+ "data": data,
542
+ }) + "\n"
543
+ with open(path, "a", encoding="utf-8") as fh:
544
+ fh.write(line)
545
+ except Exception as exc: # pragma: no cover
546
+ logger.debug("Failed to persist execution trace event: %s", exc)
@@ -337,6 +337,7 @@ def build_operation_envelope(
337
337
  *,
338
338
  execution_id: Optional[str] = None,
339
339
  mode: Optional[str] = None,
340
+ duration_ms: Optional[int] = None,
340
341
  ) -> Any:
341
342
  """Attach the operation envelope to one action's return value."""
342
343
  if is_passthrough(raw_result):
@@ -346,6 +347,20 @@ def build_operation_envelope(
346
347
  if selected == "legacy":
347
348
  return raw_result
348
349
 
350
+ if not execution_id and isinstance(params, dict):
351
+ candidate = params.get("execution_id") or params.get("_execution_id") or params.get("trace_id")
352
+ if isinstance(candidate, str) and candidate.strip():
353
+ execution_id = candidate.strip()
354
+
355
+ if not execution_id:
356
+ try:
357
+ from src.utils import execution_trace
358
+ active_id = execution_trace.current_execution_id()
359
+ if active_id:
360
+ execution_id = active_id
361
+ except ImportError:
362
+ pass
363
+
349
364
  envelope: Dict[str, Any] = {
350
365
  "status": normalize_status(raw_result),
351
366
  "operation": f"{tool_name}.{action}",
@@ -353,6 +368,9 @@ def build_operation_envelope(
353
368
  "verification": extract_verification(raw_result),
354
369
  }
355
370
 
371
+ if duration_ms is not None:
372
+ envelope["duration_ms"] = max(0, int(duration_ms))
373
+
356
374
  changes = extract_changes(raw_result)
357
375
  if changes is not None:
358
376
  envelope["changes"] = changes