davinci-resolve-mcp 2.207.0 → 2.208.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 +97 -0
- package/README.md +12 -1
- package/README.zh-CN.md +5 -3
- package/docs/SKILL.md +18 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +171 -38
- package/src/utils/execution_lifecycle.py +496 -0
- package/src/utils/resolve_bridge_client.py +39 -0
- package/src/utils/resolve_bridge_ops.py +72 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,103 @@
|
|
|
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.208.1 — #188: variant item counts come from the timeline
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A silence ripple under-reported what it built, by exactly half.**
|
|
10
|
+
`execute_silence_ripple` returned `variant_video_items: 250` and
|
|
11
|
+
`variant_audio_items: 250` for a variant that really held 432 of each. The
|
|
12
|
+
bridge's `ResolveOperations._encode` truncated every proxied container to
|
|
13
|
+
`max_items` (500) with no signal anywhere, and `plan_silence_ripple`
|
|
14
|
+
interleaves video and audio — so a 432-range plan became 864 clipInfos in one
|
|
15
|
+
`AppendToTimeline`, Resolve placed and returned all 864, and the first 500
|
|
16
|
+
encoded are precisely 250 video plus 250 audio. The same response's
|
|
17
|
+
`readback.after.clip_count` said 864 and was right the whole time, because it
|
|
18
|
+
re-reads per track: two numbers from two sources in one payload, one of them
|
|
19
|
+
silently short. "Planned 432, got 250" reads exactly like 182 ranges failing
|
|
20
|
+
to land, which on a silence ripple is the operator's central fear, and
|
|
21
|
+
establishing that it was benign cost a full review cycle of hand-auditing
|
|
22
|
+
both tracks. Reported and fixed in #188 by @mart0vip.
|
|
23
|
+
- **Dropped elements are now reported, never silent.** `op_call` and
|
|
24
|
+
`op_get_attribute` carry a `truncated` block naming the count, limit and
|
|
25
|
+
containers; the client records it on `transport.truncations` and logs the
|
|
26
|
+
method. It warns rather than raises deliberately — the native call has
|
|
27
|
+
already run by the time the reply is encoded, so raising would turn a
|
|
28
|
+
completed 864-item assembly into an error and orphan the timeline. A short
|
|
29
|
+
list that looks complete was the failure mode; the bound itself is
|
|
30
|
+
legitimate.
|
|
31
|
+
- **The item ceiling no longer exceeds the handle table.** `max_items` was
|
|
32
|
+
clamped to 5000 against a 4096-entry `MAX_HANDLES`, so a long enough list
|
|
33
|
+
evicted its own earliest handles while it was still being minted and handed
|
|
34
|
+
the client ids that were already `stale_handle`. It now clamps to
|
|
35
|
+
`MAX_HANDLES`, with the default raised 500 → 2000.
|
|
36
|
+
- **Counts come from the timeline, not the append reply.**
|
|
37
|
+
`create_variant_from_ranges` reports `placed_item_counts` from the
|
|
38
|
+
post-assembly per-track re-read it was already taking for gap detection — no
|
|
39
|
+
extra Resolve calls — and `execute_silence_ripple` and `execute_tighten` now
|
|
40
|
+
share one accounting helper, tighten having carried the identical bug. A
|
|
41
|
+
planned-vs-placed disagreement is stated outright instead of left to a hand
|
|
42
|
+
audit.
|
|
43
|
+
- Beyond reporting: under the old ceiling a `cdl` applied to a large variant
|
|
44
|
+
only reached the first 250 video items.
|
|
45
|
+
|
|
46
|
+
## What's New in v2.208.0 — agent execution lifecycle & pre-flight risk inspection
|
|
47
|
+
|
|
48
|
+
Adapted from the design contributed in PR #187.
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **Agent execution lifecycle pipeline & hooks:**
|
|
53
|
+
Tools passing through `_guard_missing_params` now execute within a structured
|
|
54
|
+
lifecycle pipeline, supporting pre-flight inspection (`before_tool_call`),
|
|
55
|
+
post-execution enrichment (`after_tool_call`), and failure handling (`on_error`).
|
|
56
|
+
- **Pre-flight operation risk & blast radius assessment:**
|
|
57
|
+
`resolve_control(action="inspect_operation")` evaluates any tool and action
|
|
58
|
+
prior to execution, returning risk levels (`low`, `medium`, `high`, `critical`),
|
|
59
|
+
destructive flags, confirmation requirements, and blast radius scopes (`item`,
|
|
60
|
+
`track`, `timeline`, `project`, `system`).
|
|
61
|
+
- **Lifecycle hooks introspection:**
|
|
62
|
+
`resolve_control(action="list_lifecycle_hooks")` exposes registered pipeline
|
|
63
|
+
hooks and their active states.
|
|
64
|
+
|
|
65
|
+
### Notes on the adaptation
|
|
66
|
+
|
|
67
|
+
- **The dry-run simulation interceptor is not included.** As contributed, any
|
|
68
|
+
call carrying `dry_run: true` outside a hardcoded four-entry allowlist was
|
|
69
|
+
short-circuited and answered with a synthesised `{"success": true,
|
|
70
|
+
"simulated": true}`. `src/server.py` has 273 `dry_run` references, so the
|
|
71
|
+
allowlist was not close: `setup.set_defaults` and
|
|
72
|
+
`resolve_control.clear_executions` both have real, tested dry-run paths and
|
|
73
|
+
were hijacked. It also answered `success: true` to
|
|
74
|
+
`set_defaults(result_envelope="banana")` — a dry run of an operation that
|
|
75
|
+
cannot succeed — and to adding a marker with no timeline in existence.
|
|
76
|
+
`dry_run` is the call an editor makes *because* they do not trust the next
|
|
77
|
+
one; a version of it that always succeeds is worse than none, because it is
|
|
78
|
+
believed. Nothing about dry-run behaviour changes in this release: every
|
|
79
|
+
`dry_run` reaches the handler that owns it.
|
|
80
|
+
- **The pipeline can gate a call, but nothing shipped does.**
|
|
81
|
+
`HookDecision(proceed=False)` and the public `register_hook` remain, so a
|
|
82
|
+
deliberately registered hook can intercept. Every default hook only observes,
|
|
83
|
+
and `test_no_default_hook_short_circuits` keeps it that way.
|
|
84
|
+
- **`inspect_operation` no longer contradicts itself about rollback.** It
|
|
85
|
+
reported `snapshot_available` two ways in one response — `false` inside
|
|
86
|
+
`risk`, and `true` at the top level whenever any pre-state could be read.
|
|
87
|
+
Reading a project name is not a restorable snapshot. It is now a single
|
|
88
|
+
`null`, meaning "not determined", with `pre_state_available` reporting
|
|
89
|
+
separately whether live state was read at all.
|
|
90
|
+
- **An unrecognised operation is no longer assessed as safe.** Any action
|
|
91
|
+
matching no rule fell into a general-mutation bucket and returned `medium` /
|
|
92
|
+
`destructive: false` / `confirmation_required: false` — a confident answer
|
|
93
|
+
about an operation the classifier had never heard of, including ones that do
|
|
94
|
+
not exist. Responses now carry `recognised: false` and say in `reasons` that
|
|
95
|
+
the levels are name-based defaults rather than a finding. The guard exists
|
|
96
|
+
for hallucinated calls; answering one with reassurance was the failure it was
|
|
97
|
+
built to prevent.
|
|
98
|
+
- The docs now state plainly that `inspect_operation` is a heuristic over
|
|
99
|
+
action names, not a simulation: it never touches the project and does not
|
|
100
|
+
validate parameters.
|
|
101
|
+
|
|
5
102
|
## What's New in v2.207.0 — execution audit report exports
|
|
6
103
|
|
|
7
104
|
Contributed in PR #185.
|
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)
|
|
@@ -280,6 +280,17 @@ reviewable audit artifact with the same summary, defaulting to
|
|
|
280
280
|
it instead — alongside a conform in a dated TransferFiles folder, say — and
|
|
281
281
|
creates the directories to get there, so check the path before you send it.
|
|
282
282
|
An existing file is never replaced without `overwrite: true`.
|
|
283
|
+
`inspect_operation(tool?, target_action?, target_params?)` evaluates pre-flight
|
|
284
|
+
risk level (`low`, `medium`, `high`, `critical`), destructive potential, and blast
|
|
285
|
+
radius (`item`, `track`, `timeline`, `project`, `system`) before taking action, while
|
|
286
|
+
`list_lifecycle_hooks()` inspects active execution interceptors.
|
|
287
|
+
|
|
288
|
+
It is a heuristic over action names, not a simulation — it never touches the
|
|
289
|
+
project and does not validate your parameters, so `recognised: false` means the
|
|
290
|
+
levels are defaults rather than a finding, and `snapshot_available: null` means
|
|
291
|
+
rollback availability was not determined rather than absent. Every shipped hook
|
|
292
|
+
observes; none replaces a tool's result, so `dry_run` always reaches the real
|
|
293
|
+
handler and nothing synthesises a preview for an action that has none.
|
|
283
294
|
|
|
284
295
|
A report for a run where nothing was verified says **"not established — no
|
|
285
296
|
checks recorded"**, not "passed". Absence of evidence is a question still open,
|
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.208.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -174,7 +174,9 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
|
|
|
174
174
|
|
|
175
175
|
### 导出执行审计报告
|
|
176
176
|
|
|
177
|
-
`export_execution_report(execution_id?, format="markdown"|"json")` 会把一条轨迹写成可供审阅的审计文件,默认落在 `logs/execution-reports/<execution_id>.md`。传 `path` 可以写到任何你想要的位置——比如跟着某次套底放进当天的 TransferFiles 文件夹——并且会自动创建沿途的目录,所以发出去之前请先确认路径。已存在的文件不会被覆盖,除非显式传 `overwrite: true
|
|
177
|
+
`export_execution_report(execution_id?, format="markdown"|"json")` 会把一条轨迹写成可供审阅的审计文件,默认落在 `logs/execution-reports/<execution_id>.md`。传 `path` 可以写到任何你想要的位置——比如跟着某次套底放进当天的 TransferFiles 文件夹——并且会自动创建沿途的目录,所以发出去之前请先确认路径。已存在的文件不会被覆盖,除非显式传 `overwrite: true`。`inspect_operation(tool?, target_action?, target_params?)` 会在执行前评估操作的风险等级(`low`、`medium`、`high`、`critical`)、破坏性以及影响范围(`item`、`track`、`timeline`、`project`、`system`),而 `list_lifecycle_hooks()` 则可以查看当前生效的生命周期钩子。
|
|
178
|
+
|
|
179
|
+
需要强调的是:这是一套基于动作名称的启发式判断,**不是模拟执行**——它完全不碰项目,也不会校验你传的参数。`recognised: false` 表示没有任何规则命中,那些等级只是按名字给出的默认值,而不是对这次操作的结论;`snapshot_available: null` 表示"是否能回滚未确定",而不是"不能回滚"。所有随包启用的钩子都只做观察,没有任何一个会替换工具的返回值——因此 `dry_run` 永远会走到真正的处理函数,不会有人替一个本身不支持 dry-run 的动作凭空编一份预览出来。
|
|
178
180
|
|
|
179
181
|
如果这次运行根本没有做过校验,报告里写的是**"not established — no checks recorded"(未确立——没有记录任何检查)**,而不是"通过"。没有证据是一个仍然悬而未决的问题;审计文件恰恰是最不该让读者把它读成"一切正常"的地方。
|
|
180
182
|
|
package/docs/SKILL.md
CHANGED
|
@@ -277,6 +277,24 @@ Example trace shape returned by `resolve_control(action="get_execution_trace")`:
|
|
|
277
277
|
structured output, `include_steps: false` for a shorter summary, or
|
|
278
278
|
`overwrite: true` to replace an existing report.
|
|
279
279
|
- **`clear_executions(dry_run?)`**: Clears the in-memory execution trace buffer.
|
|
280
|
+
- **`inspect_operation(tool?, target_action?, target_params?)`**: Evaluates operation risk
|
|
281
|
+
level (`low`, `medium`, `high`, `critical`), destructive potential, confirmation
|
|
282
|
+
requirements, and blast radius scope before executing an action.
|
|
283
|
+
|
|
284
|
+
**It is a heuristic over action names, not a simulation.** It does not touch
|
|
285
|
+
the project, does not validate your parameters, and cannot tell you whether
|
|
286
|
+
the clip ids you are holding exist. Read three fields before trusting it:
|
|
287
|
+
`recognised: false` means no rule matched and the levels are name-based
|
|
288
|
+
defaults rather than a finding; `snapshot_available: null` means rollback
|
|
289
|
+
availability was not determined, never that there is none; and
|
|
290
|
+
`pre_state_available` separates "no project open" from "state never read".
|
|
291
|
+
For an actual preview, use the action's own `dry_run` where it has one.
|
|
292
|
+
- **`list_lifecycle_hooks()`**: Returns active execution lifecycle pipeline hooks
|
|
293
|
+
(`risk_classification`, `resolve_state_inspection`, `readback_verification`,
|
|
294
|
+
`drift_detection`, `provenance_trace`). All of them observe; none replaces a
|
|
295
|
+
tool result. `dry_run` therefore always reaches the real handler — a tool
|
|
296
|
+
either implements it or does not, and nothing synthesises a preview on its
|
|
297
|
+
behalf.
|
|
280
298
|
|
|
281
299
|
Explicit correlation is also supported per-call: pass `params={"execution_id": ...}`
|
|
282
300
|
or `params={"trace_id": ...}` in any tool call to associate it with a specific trace.
|
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.208.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
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.208.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.208.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -76,6 +76,11 @@ from src.utils.execution_trace import (
|
|
|
76
76
|
clear_executions,
|
|
77
77
|
export_execution_report,
|
|
78
78
|
)
|
|
79
|
+
from src.utils import execution_lifecycle as _execution_lifecycle
|
|
80
|
+
from src.utils.execution_lifecycle import (
|
|
81
|
+
inspect_operation,
|
|
82
|
+
list_lifecycle_hooks,
|
|
83
|
+
)
|
|
79
84
|
from src.utils.render_ids import (
|
|
80
85
|
render_codec_id_from_codecs as _render_codec_id_from_codecs,
|
|
81
86
|
render_format_id_from_formats as _render_format_id_from_formats,
|
|
@@ -887,6 +892,37 @@ def _try_connect():
|
|
|
887
892
|
resolve = None
|
|
888
893
|
return None
|
|
889
894
|
|
|
895
|
+
def _get_resolve_lifecycle_state() -> Optional[Dict[str, Any]]:
|
|
896
|
+
"""Capture non-blocking pre-flight Resolve project and timeline metadata."""
|
|
897
|
+
global resolve
|
|
898
|
+
r = resolve
|
|
899
|
+
if r is None:
|
|
900
|
+
try:
|
|
901
|
+
r = _try_connect()
|
|
902
|
+
except Exception:
|
|
903
|
+
r = None
|
|
904
|
+
if r is None:
|
|
905
|
+
return None
|
|
906
|
+
try:
|
|
907
|
+
pm = r.GetProjectManager()
|
|
908
|
+
if not pm:
|
|
909
|
+
return None
|
|
910
|
+
proj = pm.GetCurrentProject()
|
|
911
|
+
if not proj:
|
|
912
|
+
return None
|
|
913
|
+
state: Dict[str, Any] = {"project_name": proj.GetName()}
|
|
914
|
+
tl = proj.GetCurrentTimeline()
|
|
915
|
+
if tl:
|
|
916
|
+
state["timeline_name"] = tl.GetName()
|
|
917
|
+
state["duration_frames"] = tl.GetEndFrame() - tl.GetStartFrame()
|
|
918
|
+
state["track_count_video"] = tl.GetTrackCount("video")
|
|
919
|
+
state["track_count_audio"] = tl.GetTrackCount("audio")
|
|
920
|
+
return state
|
|
921
|
+
except Exception:
|
|
922
|
+
return None
|
|
923
|
+
|
|
924
|
+
_execution_lifecycle.get_lifecycle_pipeline().set_state_provider(_get_resolve_lifecycle_state)
|
|
925
|
+
|
|
890
926
|
def _launch_resolve(headless: Optional[bool] = None):
|
|
891
927
|
"""Launch DaVinci Resolve and wait for it to become available.
|
|
892
928
|
|
|
@@ -1501,6 +1537,7 @@ _TRACE_OBSERVER_ACTIONS = {
|
|
|
1501
1537
|
"get_execution_trace", "get_execution", "list_recent_executions",
|
|
1502
1538
|
"clear_executions", "begin_execution", "end_execution",
|
|
1503
1539
|
"export_execution_report",
|
|
1540
|
+
"inspect_operation", "list_lifecycle_hooks",
|
|
1504
1541
|
}
|
|
1505
1542
|
|
|
1506
1543
|
|
|
@@ -1573,14 +1610,29 @@ def _guard_missing_params(fn):
|
|
|
1573
1610
|
async def wrapper(*args, **kwargs):
|
|
1574
1611
|
action = _guarded_action_name(args, kwargs)
|
|
1575
1612
|
params = _guarded_params(args, kwargs)
|
|
1613
|
+
lifecycle = _execution_lifecycle.get_lifecycle_pipeline()
|
|
1614
|
+
ctx = _execution_lifecycle.ToolCallContext(
|
|
1615
|
+
tool_name=tool_name,
|
|
1616
|
+
action=action,
|
|
1617
|
+
params=params or {},
|
|
1618
|
+
)
|
|
1619
|
+
decision = lifecycle.run_before(ctx)
|
|
1620
|
+
if decision and not decision.proceed and decision.short_circuit_result is not None:
|
|
1621
|
+
return _build_operation_envelope(tool_name, action, params, decision.short_circuit_result, duration_ms=0)
|
|
1622
|
+
|
|
1576
1623
|
t0 = time.perf_counter()
|
|
1577
1624
|
try:
|
|
1578
1625
|
result = await fn(*args, **kwargs)
|
|
1579
1626
|
except _MissingParam as exc:
|
|
1580
1627
|
result = _missing_param_error(exc, action)
|
|
1628
|
+
except Exception as exc:
|
|
1629
|
+
duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
|
|
1630
|
+
lifecycle.run_on_error(ctx, exc, duration_ms)
|
|
1631
|
+
raise
|
|
1581
1632
|
duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
|
|
1582
1633
|
enveloped = _build_operation_envelope(
|
|
1583
1634
|
tool_name, action, params, result, duration_ms=duration_ms)
|
|
1635
|
+
enveloped = lifecycle.run_after(ctx, enveloped, duration_ms)
|
|
1584
1636
|
_record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
|
|
1585
1637
|
return enveloped
|
|
1586
1638
|
else:
|
|
@@ -1588,14 +1640,29 @@ def _guard_missing_params(fn):
|
|
|
1588
1640
|
def wrapper(*args, **kwargs):
|
|
1589
1641
|
action = _guarded_action_name(args, kwargs)
|
|
1590
1642
|
params = _guarded_params(args, kwargs)
|
|
1643
|
+
lifecycle = _execution_lifecycle.get_lifecycle_pipeline()
|
|
1644
|
+
ctx = _execution_lifecycle.ToolCallContext(
|
|
1645
|
+
tool_name=tool_name,
|
|
1646
|
+
action=action,
|
|
1647
|
+
params=params or {},
|
|
1648
|
+
)
|
|
1649
|
+
decision = lifecycle.run_before(ctx)
|
|
1650
|
+
if decision and not decision.proceed and decision.short_circuit_result is not None:
|
|
1651
|
+
return _build_operation_envelope(tool_name, action, params, decision.short_circuit_result, duration_ms=0)
|
|
1652
|
+
|
|
1591
1653
|
t0 = time.perf_counter()
|
|
1592
1654
|
try:
|
|
1593
1655
|
result = fn(*args, **kwargs)
|
|
1594
1656
|
except _MissingParam as exc:
|
|
1595
1657
|
result = _missing_param_error(exc, action)
|
|
1658
|
+
except Exception as exc:
|
|
1659
|
+
duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
|
|
1660
|
+
lifecycle.run_on_error(ctx, exc, duration_ms)
|
|
1661
|
+
raise
|
|
1596
1662
|
duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
|
|
1597
1663
|
enveloped = _build_operation_envelope(
|
|
1598
1664
|
tool_name, action, params, result, duration_ms=duration_ms)
|
|
1665
|
+
enveloped = lifecycle.run_after(ctx, enveloped, duration_ms)
|
|
1599
1666
|
_record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
|
|
1600
1667
|
return enveloped
|
|
1601
1668
|
|
|
@@ -6539,6 +6606,26 @@ def _variant_item_placement(item) -> Dict[str, Any]:
|
|
|
6539
6606
|
}
|
|
6540
6607
|
|
|
6541
6608
|
|
|
6609
|
+
def _snapshot_track_item_counts(snapshot: Dict[str, Any]) -> Dict[str, int]:
|
|
6610
|
+
"""Per-track-type item counts read from a conform snapshot of a live timeline.
|
|
6611
|
+
|
|
6612
|
+
This is the ONLY honest answer to "what did the assembly actually place".
|
|
6613
|
+
The obvious alternative — counting what `MediaPool.AppendToTimeline`
|
|
6614
|
+
returned — is a witness derived from the same call it would be checking, and
|
|
6615
|
+
it lies in two measured ways: the in-app bridge caps any proxied list at
|
|
6616
|
+
`max_items` (an 864-clipInfo append came back as 500 items, so a variant
|
|
6617
|
+
holding 432 video + 432 audio was reported as 250 + 250), and Resolve drops
|
|
6618
|
+
colliding records from the reply without an error (see the api_truth entry
|
|
6619
|
+
"MediaPool.AppendToTimeline (overlapping records — earlier item wins)").
|
|
6620
|
+
Re-reading the timeline per track cannot be fooled by either.
|
|
6621
|
+
"""
|
|
6622
|
+
counts: Dict[str, int] = {}
|
|
6623
|
+
for track_type, block in (snapshot.get("tracks") or {}).items():
|
|
6624
|
+
rows = (block or {}).get("tracks") or []
|
|
6625
|
+
counts[str(track_type)] = sum(int(row.get("item_count") or 0) for row in rows)
|
|
6626
|
+
return counts
|
|
6627
|
+
|
|
6628
|
+
|
|
6542
6629
|
def _variant_audio_summary(built):
|
|
6543
6630
|
"""Video/audio range counts for an assembled variant, warning when it carries
|
|
6544
6631
|
no audio. create_variant_from_ranges places exactly the ranges given, so a
|
|
@@ -6551,6 +6638,61 @@ def _variant_audio_summary(built):
|
|
|
6551
6638
|
return summary
|
|
6552
6639
|
|
|
6553
6640
|
|
|
6641
|
+
def _variant_audio_accounting(variant: Dict[str, Any], *, planned_video: int,
|
|
6642
|
+
planned_audio: int) -> Dict[str, Any]:
|
|
6643
|
+
"""The planned-vs-placed block on a tighten / silence-ripple readback.
|
|
6644
|
+
|
|
6645
|
+
Shared by execute_tighten and execute_silence_ripple so the two cannot
|
|
6646
|
+
drift: they answer the same operator question, "did every range I planned
|
|
6647
|
+
actually land in the variant".
|
|
6648
|
+
|
|
6649
|
+
Placed counts come from the assembler's post-assembly re-read of the
|
|
6650
|
+
timeline, never from what `AppendToTimeline` returned — see
|
|
6651
|
+
`_snapshot_track_item_counts` for why the append's reply is not evidence.
|
|
6652
|
+
A count that is short for a *reporting* reason and a count that is short
|
|
6653
|
+
because material was dropped must never look the same here: on a silence
|
|
6654
|
+
ripple the operator's whole fear is dropped material, so a disagreement is
|
|
6655
|
+
stated outright rather than left to be discovered by hand-auditing tracks.
|
|
6656
|
+
"""
|
|
6657
|
+
placed = variant.get("placed_item_counts")
|
|
6658
|
+
video = (placed or {}).get("video")
|
|
6659
|
+
audio = (placed or {}).get("audio")
|
|
6660
|
+
accounting: Dict[str, Any] = {
|
|
6661
|
+
"planned_audio_ranges": planned_audio,
|
|
6662
|
+
"planned_video_ranges": planned_video,
|
|
6663
|
+
"variant_audio_items": audio,
|
|
6664
|
+
"variant_video_items": video,
|
|
6665
|
+
"counts_source": "post-assembly per-track read of the variant timeline",
|
|
6666
|
+
}
|
|
6667
|
+
if video is None or audio is None:
|
|
6668
|
+
accounting["note"] = (
|
|
6669
|
+
"Placed item counts are UNAVAILABLE — the variant could not be re-read "
|
|
6670
|
+
"after assembly. Verify with timeline_item get_items_in_track before "
|
|
6671
|
+
"using this variant."
|
|
6672
|
+
)
|
|
6673
|
+
return accounting
|
|
6674
|
+
disagreements = []
|
|
6675
|
+
if video != planned_video:
|
|
6676
|
+
disagreements.append(f"video {video}/{planned_video}")
|
|
6677
|
+
if audio != planned_audio:
|
|
6678
|
+
disagreements.append(f"audio {audio}/{planned_audio}")
|
|
6679
|
+
if disagreements:
|
|
6680
|
+
accounting["note"] = (
|
|
6681
|
+
"PLACED COUNT DISAGREES WITH THE PLAN (placed/planned: "
|
|
6682
|
+
+ ", ".join(disagreements)
|
|
6683
|
+
+ ") — ranges did not land. Resolve drops colliding records from an "
|
|
6684
|
+
"append without erroring; check readback.gaps_overlaps and the "
|
|
6685
|
+
"tracks themselves before using this variant."
|
|
6686
|
+
)
|
|
6687
|
+
elif planned_audio:
|
|
6688
|
+
accounting["note"] = "Variant carries audio mirrored from the video cuts."
|
|
6689
|
+
else:
|
|
6690
|
+
accounting["note"] = (
|
|
6691
|
+
"Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
|
|
6692
|
+
)
|
|
6693
|
+
return accounting
|
|
6694
|
+
|
|
6695
|
+
|
|
6554
6696
|
def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
6555
6697
|
ranges = p.get("ranges") or p.get("clip_infos")
|
|
6556
6698
|
if not isinstance(ranges, list) or not ranges:
|
|
@@ -6701,16 +6843,21 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
|
|
|
6701
6843
|
if p.get("cdl"):
|
|
6702
6844
|
target_ids = [row.get("timeline_item_id") for row in items_out if row.get("timeline_item_id") and row.get("range", {}).get("media_type") == 1]
|
|
6703
6845
|
look_result = _timeline_apply_look_to_items(new_tl, {"target_ids": target_ids, "cdl": p.get("cdl")})
|
|
6846
|
+
# One snapshot, two consumers: gap detection and the placed-item counts.
|
|
6847
|
+
# `items` above is only as complete as the append's REPLY, so it is not
|
|
6848
|
+
# evidence of what landed — `placed_item_counts` re-reads the timeline.
|
|
6849
|
+
snapshot = _timeline_conform_snapshot(new_tl, {})
|
|
6704
6850
|
return {
|
|
6705
6851
|
"success": True,
|
|
6706
6852
|
"name": new_tl.GetName(),
|
|
6707
6853
|
"id": new_tl.GetUniqueId(),
|
|
6708
6854
|
"items": items_out,
|
|
6855
|
+
"placed_item_counts": _snapshot_track_item_counts(snapshot),
|
|
6709
6856
|
"placement_mismatches": placement_mismatches,
|
|
6710
6857
|
"audio": _variant_audio_summary(built),
|
|
6711
6858
|
"markers": marker_results,
|
|
6712
6859
|
"look": look_result,
|
|
6713
|
-
"gaps_overlaps": _detect_gaps_overlaps_from_snapshot(
|
|
6860
|
+
"gaps_overlaps": _detect_gaps_overlaps_from_snapshot(snapshot, {}),
|
|
6714
6861
|
}
|
|
6715
6862
|
|
|
6716
6863
|
|
|
@@ -16126,6 +16273,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16126
16273
|
— Write a Markdown or JSON audit report for an execution trace (no connection needed).
|
|
16127
16274
|
clear_executions(dry_run?) -> {success, cleared}
|
|
16128
16275
|
— Clear the in-memory execution trace buffer.
|
|
16276
|
+
inspect_operation(tool?, target_action?, target_params?) -> {tool, action, risk, destructive, blast_radius, confirmation_required, snapshot_available, recognised, reasons, pre_state, pre_state_available}
|
|
16277
|
+
— Name-based heuristic, NOT a simulation: it never touches the project and does not validate params. recognised=false means no rule matched; snapshot_available=null means rollback was not determined, not that none exists.
|
|
16278
|
+
— Pre-flight risk assessment and blast radius inspection for any tool action before execution (no connection needed).
|
|
16279
|
+
list_lifecycle_hooks() -> {success, hooks, count}
|
|
16280
|
+
— List active agent tool execution lifecycle hooks and their enabled status (no connection needed).
|
|
16129
16281
|
"""
|
|
16130
16282
|
p = _params(params)
|
|
16131
16283
|
|
|
@@ -16286,6 +16438,14 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16286
16438
|
return {"success": True, "dry_run": True, "count": len(_execution_trace.list_recent_executions(100))}
|
|
16287
16439
|
res = _execution_trace.clear_executions()
|
|
16288
16440
|
return res
|
|
16441
|
+
if action == "inspect_operation":
|
|
16442
|
+
target_tool = p.get("tool") or p.get("tool_name") or "timeline"
|
|
16443
|
+
target_action = p.get("target_action") or p.get("action") or p.get("op") or "delete_clips"
|
|
16444
|
+
target_params = p.get("target_params") or p.get("params") or {}
|
|
16445
|
+
return _execution_lifecycle.inspect_operation(target_tool, target_action, target_params)
|
|
16446
|
+
if action == "list_lifecycle_hooks":
|
|
16447
|
+
hooks = _execution_lifecycle.list_lifecycle_hooks()
|
|
16448
|
+
return {"success": True, "hooks": hooks, "count": len(hooks)}
|
|
16289
16449
|
|
|
16290
16450
|
# Control-panel actions don't require Resolve to be running.
|
|
16291
16451
|
if action == "open_control_panel":
|
|
@@ -16501,7 +16661,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16501
16661
|
if err:
|
|
16502
16662
|
return _err(err)
|
|
16503
16663
|
return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
|
|
16504
|
-
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","export_execution_report","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"])
|
|
16664
|
+
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","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","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"])
|
|
16505
16665
|
|
|
16506
16666
|
|
|
16507
16667
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -24092,24 +24252,11 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
24092
24252
|
structural_diff if include_details
|
|
24093
24253
|
else _compact_structural_diff(structural_diff)
|
|
24094
24254
|
),
|
|
24095
|
-
|
|
24096
|
-
|
|
24097
|
-
|
|
24098
|
-
|
|
24099
|
-
|
|
24100
|
-
1 for it in (variant.get("items") or [])
|
|
24101
|
-
if (it.get("range") or {}).get("media_type") == 2
|
|
24102
|
-
),
|
|
24103
|
-
"variant_video_items": sum(
|
|
24104
|
-
1 for it in (variant.get("items") or [])
|
|
24105
|
-
if (it.get("range") or {}).get("media_type") == 1
|
|
24106
|
-
),
|
|
24107
|
-
"note": (
|
|
24108
|
-
"Variant carries audio mirrored from the video cuts."
|
|
24109
|
-
if audio_keep_ranges
|
|
24110
|
-
else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
|
|
24111
|
-
),
|
|
24112
|
-
},
|
|
24255
|
+
# variant_* count PLACED items, re-read from the variant;
|
|
24256
|
+
# variant["audio"] counts requested ranges.
|
|
24257
|
+
"audio_accounting": _variant_audio_accounting(
|
|
24258
|
+
variant, planned_video=video_keep_ranges, planned_audio=audio_keep_ranges,
|
|
24259
|
+
),
|
|
24113
24260
|
},
|
|
24114
24261
|
"plan_id": plan.get("plan_id"),
|
|
24115
24262
|
}
|
|
@@ -24228,23 +24375,9 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
24228
24375
|
structural_diff if include_details
|
|
24229
24376
|
else _compact_structural_diff(structural_diff)
|
|
24230
24377
|
),
|
|
24231
|
-
"audio_accounting":
|
|
24232
|
-
|
|
24233
|
-
|
|
24234
|
-
"variant_audio_items": sum(
|
|
24235
|
-
1 for it in (variant.get("items") or [])
|
|
24236
|
-
if (it.get("range") or {}).get("media_type") == 2
|
|
24237
|
-
),
|
|
24238
|
-
"variant_video_items": sum(
|
|
24239
|
-
1 for it in (variant.get("items") or [])
|
|
24240
|
-
if (it.get("range") or {}).get("media_type") == 1
|
|
24241
|
-
),
|
|
24242
|
-
"note": (
|
|
24243
|
-
"Variant carries audio mirrored from the video cuts."
|
|
24244
|
-
if audio_keep_ranges
|
|
24245
|
-
else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
|
|
24246
|
-
),
|
|
24247
|
-
},
|
|
24378
|
+
"audio_accounting": _variant_audio_accounting(
|
|
24379
|
+
variant, planned_video=video_keep_ranges, planned_audio=audio_keep_ranges,
|
|
24380
|
+
),
|
|
24248
24381
|
},
|
|
24249
24382
|
"plan_id": plan.get("plan_id"),
|
|
24250
24383
|
}
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Universal MCP Tool Execution Lifecycle and Hook Pipeline.
|
|
2
|
+
|
|
3
|
+
Provides a pluggable, server-wide lifecycle middleware for all compound MCP tools.
|
|
4
|
+
Every tool invocation (synchronous or asynchronous) passes through three distinct
|
|
5
|
+
lifecycle phases:
|
|
6
|
+
|
|
7
|
+
1. Pre-flight (run_before):
|
|
8
|
+
- Risk classification and blast radius calculation (low -> critical)
|
|
9
|
+
- Resolve state inspection (pre-flight timeline duration, track count, project)
|
|
10
|
+
- Safe dry-run simulation interception for non-native dry-run actions
|
|
11
|
+
2. Execution / Error tracking (run_on_error):
|
|
12
|
+
- Catches exceptions, records duration, informs error observers
|
|
13
|
+
3. Post-flight (run_after):
|
|
14
|
+
- Readback verification and contradiction evaluation
|
|
15
|
+
- State drift detection (unintended timeline duration/structure shifts)
|
|
16
|
+
- Correlated execution trace aggregation
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import enum
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("resolve-mcp.execution-lifecycle")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RiskLevel(str, enum.Enum):
|
|
31
|
+
"""Categorized risk level for tool operations."""
|
|
32
|
+
LOW = "low" # Read-only queries, info probes, status checks
|
|
33
|
+
MEDIUM = "medium" # Reversible edits, markers, non-destructive properties
|
|
34
|
+
HIGH = "high" # Deletions, ripples, timeline restructuring, batch edits
|
|
35
|
+
CRITICAL = "critical" # Project deletion, database resets, permanent loss
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BlastRadius(str, enum.Enum):
|
|
39
|
+
"""Scope of impact when an operation executes."""
|
|
40
|
+
ITEM = "item" # Single clip, single marker, node
|
|
41
|
+
TRACK = "track" # Single track or stem
|
|
42
|
+
TIMELINE = "timeline" # Entire active timeline
|
|
43
|
+
PROJECT = "project" # Entire Resolve project or Media Pool
|
|
44
|
+
SYSTEM = "system" # System preferences, filesystem, host process
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RiskAssessment:
|
|
49
|
+
"""Calculated risk evaluation for a tool action."""
|
|
50
|
+
level: RiskLevel = RiskLevel.LOW
|
|
51
|
+
destructive: bool = False
|
|
52
|
+
blast_radius: BlastRadius = BlastRadius.ITEM
|
|
53
|
+
confirmation_required: bool = False
|
|
54
|
+
#: None = not determined. The classifier reads action names; it does not
|
|
55
|
+
#: know whether timeline_versioning would archive a predecessor, and False
|
|
56
|
+
#: would assert "no rollback" as a finding it never made.
|
|
57
|
+
snapshot_available: Optional[bool] = None
|
|
58
|
+
#: False when no rule matched, i.e. the fields below are name-based
|
|
59
|
+
#: defaults rather than an assessment of this specific operation.
|
|
60
|
+
recognised: bool = True
|
|
61
|
+
reasons: List[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"level": self.level.value,
|
|
66
|
+
"destructive": self.destructive,
|
|
67
|
+
"blast_radius": self.blast_radius.value,
|
|
68
|
+
"confirmation_required": self.confirmation_required,
|
|
69
|
+
"snapshot_available": self.snapshot_available,
|
|
70
|
+
"recognised": self.recognised,
|
|
71
|
+
"reasons": list(self.reasons),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ToolCallContext:
|
|
77
|
+
"""Contextual metadata describing an in-flight tool invocation."""
|
|
78
|
+
tool_name: str
|
|
79
|
+
action: str
|
|
80
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
execution_id: Optional[str] = None
|
|
82
|
+
risk: RiskAssessment = field(default_factory=RiskAssessment)
|
|
83
|
+
pre_state: Optional[Dict[str, Any]] = None
|
|
84
|
+
post_state: Optional[Dict[str, Any]] = None
|
|
85
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class HookDecision:
|
|
90
|
+
"""Decision returned by a pre-flight hook."""
|
|
91
|
+
proceed: bool = True
|
|
92
|
+
short_circuit_result: Optional[Dict[str, Any]] = None
|
|
93
|
+
reason: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LifecycleHook:
|
|
97
|
+
"""Base class for all MCP tool lifecycle hooks."""
|
|
98
|
+
name: str = "base_hook"
|
|
99
|
+
enabled: bool = True
|
|
100
|
+
|
|
101
|
+
def before_tool_call(self, ctx: ToolCallContext) -> Optional[HookDecision]:
|
|
102
|
+
"""Runs before tool invocation. Can short-circuit or modify context."""
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def after_tool_call(
|
|
106
|
+
self, ctx: ToolCallContext, result: Any, duration_ms: int
|
|
107
|
+
) -> Optional[Dict[str, Any]]:
|
|
108
|
+
"""Runs after successful tool execution. Can enrich result or emit telemetry."""
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def on_error(
|
|
112
|
+
self, ctx: ToolCallContext, exc: Exception, duration_ms: int
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Runs when tool execution raises an unhandled exception."""
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ─── Built-in Hook Implementations ──────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RiskClassificationHook(LifecycleHook):
|
|
122
|
+
"""Evaluates tool + action + params to classify danger level and blast radius."""
|
|
123
|
+
name = "risk_classification"
|
|
124
|
+
|
|
125
|
+
_CRITICAL_ACTIONS: Set[Tuple[str, str]] = {
|
|
126
|
+
("project_manager", "delete_project"),
|
|
127
|
+
("project_manager", "close_project_without_saving"),
|
|
128
|
+
("media_pool", "delete_timelines"),
|
|
129
|
+
("media_pool", "delete_clips"),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_HIGH_RISK_ACTIONS: Set[Tuple[str, str]] = {
|
|
133
|
+
("timeline", "delete_clips"),
|
|
134
|
+
("timeline", "delete_clip_by_id"),
|
|
135
|
+
("timeline", "delete_markers"),
|
|
136
|
+
("timeline", "ripple_delete"),
|
|
137
|
+
("timeline", "cut_clip"),
|
|
138
|
+
("edit_engine", "execute_selects"),
|
|
139
|
+
("edit_engine", "auto_cut_silence"),
|
|
140
|
+
("edit_engine", "ripple_trim"),
|
|
141
|
+
("project_manager", "save_project_as"),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_READ_ONLY_PREFIXES = ("get_", "list_", "query_", "probe_", "inspect_", "export_", "check_")
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def classify(cls, tool_name: str, action: str, params: Dict[str, Any]) -> RiskAssessment:
|
|
148
|
+
reasons: List[str] = []
|
|
149
|
+
destructive = False
|
|
150
|
+
level = RiskLevel.LOW
|
|
151
|
+
radius = BlastRadius.ITEM
|
|
152
|
+
conf_required = False
|
|
153
|
+
recognised = True
|
|
154
|
+
|
|
155
|
+
pair = (tool_name, action)
|
|
156
|
+
|
|
157
|
+
if pair in cls._CRITICAL_ACTIONS:
|
|
158
|
+
level = RiskLevel.CRITICAL
|
|
159
|
+
destructive = True
|
|
160
|
+
radius = BlastRadius.PROJECT if "project" in tool_name else BlastRadius.TIMELINE
|
|
161
|
+
conf_required = True
|
|
162
|
+
reasons.append(f"Action '{action}' is permanently destructive across {radius.value}")
|
|
163
|
+
elif pair in cls._HIGH_RISK_ACTIONS or action.startswith("delete_") or action.startswith("remove_"):
|
|
164
|
+
level = RiskLevel.HIGH
|
|
165
|
+
destructive = True
|
|
166
|
+
if params.get("ripple", False):
|
|
167
|
+
radius = BlastRadius.TIMELINE
|
|
168
|
+
reasons.append("Ripple mode alters downstream timeline synchronization")
|
|
169
|
+
else:
|
|
170
|
+
radius = BlastRadius.ITEM
|
|
171
|
+
conf_required = True
|
|
172
|
+
reasons.append(f"Destructive timeline edit: {action}")
|
|
173
|
+
elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"}:
|
|
174
|
+
level = RiskLevel.LOW
|
|
175
|
+
destructive = False
|
|
176
|
+
radius = BlastRadius.ITEM
|
|
177
|
+
else:
|
|
178
|
+
# Everything the rules do not recognise. This is a heuristic over
|
|
179
|
+
# action NAMES, so "unrecognised" covers both a real action nobody
|
|
180
|
+
# listed and an action that does not exist — and it must not come
|
|
181
|
+
# back as a confident "medium, not destructive". The guard exists
|
|
182
|
+
# for hallucinated calls; answering one with reassurance is the
|
|
183
|
+
# failure it was built to prevent.
|
|
184
|
+
level = RiskLevel.MEDIUM
|
|
185
|
+
destructive = action.startswith("reset_") or action.startswith("clear_")
|
|
186
|
+
radius = BlastRadius.ITEM
|
|
187
|
+
recognised = False
|
|
188
|
+
reasons.append(
|
|
189
|
+
f"'{tool_name}.{action}' matches no risk rule. This assessment is a "
|
|
190
|
+
"name-based default, not a finding — treat the risk as unestablished "
|
|
191
|
+
"and check the tool's own documented behaviour before proceeding."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return RiskAssessment(
|
|
195
|
+
level=level,
|
|
196
|
+
destructive=destructive,
|
|
197
|
+
blast_radius=radius,
|
|
198
|
+
confirmation_required=conf_required,
|
|
199
|
+
snapshot_available=None,
|
|
200
|
+
reasons=reasons,
|
|
201
|
+
recognised=recognised,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def before_tool_call(self, ctx: ToolCallContext) -> Optional[HookDecision]:
|
|
205
|
+
ctx.risk = self.classify(ctx.tool_name, ctx.action, ctx.params)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ResolveStateInspectionHook(LifecycleHook):
|
|
210
|
+
"""Captures pre-flight Resolve project and timeline state non-blockingly."""
|
|
211
|
+
name = "resolve_state_inspection"
|
|
212
|
+
|
|
213
|
+
def __init__(self, state_provider: Optional[Callable[[], Optional[Dict[str, Any]]]] = None):
|
|
214
|
+
self._state_provider = state_provider
|
|
215
|
+
|
|
216
|
+
def before_tool_call(self, ctx: ToolCallContext) -> Optional[HookDecision]:
|
|
217
|
+
if self._state_provider is None:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
state = self._state_provider()
|
|
221
|
+
if state:
|
|
222
|
+
ctx.pre_state = state
|
|
223
|
+
ctx.risk.snapshot_available = True
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.debug(f"Pre-flight state inspection skipped: {exc}")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ReadbackVerificationHook(LifecycleHook):
|
|
230
|
+
"""Evaluates readback verification data attached to operation results."""
|
|
231
|
+
name = "readback_verification"
|
|
232
|
+
|
|
233
|
+
def after_tool_call(
|
|
234
|
+
self, ctx: ToolCallContext, result: Any, duration_ms: int
|
|
235
|
+
) -> Optional[Dict[str, Any]]:
|
|
236
|
+
if not isinstance(result, dict):
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
# Check if result carries a verification block
|
|
240
|
+
verif = result.get("verification")
|
|
241
|
+
if isinstance(verif, dict):
|
|
242
|
+
contradiction = verif.get("contradiction", False)
|
|
243
|
+
verified = verif.get("verified", False)
|
|
244
|
+
if contradiction:
|
|
245
|
+
logger.warning(
|
|
246
|
+
f"Readback contradiction detected on {ctx.tool_name}.{ctx.action}: {verif}"
|
|
247
|
+
)
|
|
248
|
+
return {
|
|
249
|
+
"readback_checked": True,
|
|
250
|
+
"verified": verified,
|
|
251
|
+
"contradiction": contradiction,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Track unverified destructive operations
|
|
255
|
+
if ctx.risk.destructive and "verification" not in result:
|
|
256
|
+
return {
|
|
257
|
+
"readback_checked": False,
|
|
258
|
+
"status": "unverified",
|
|
259
|
+
"notice": f"Destructive operation {ctx.tool_name}.{ctx.action} completed without readback verification",
|
|
260
|
+
}
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class DriftDetectionHook(LifecycleHook):
|
|
265
|
+
"""Detects unexpected timeline duration or track structure drift."""
|
|
266
|
+
name = "drift_detection"
|
|
267
|
+
|
|
268
|
+
_DURATION_ALTERING_ACTIONS = {
|
|
269
|
+
"ripple_delete", "ripple_insert", "auto_cut_silence", "execute_selects",
|
|
270
|
+
"delete_clips", "cut_clip", "delete_item", "ripple_trim"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def __init__(self, state_provider: Optional[Callable[[], Optional[Dict[str, Any]]]] = None):
|
|
274
|
+
self._state_provider = state_provider
|
|
275
|
+
|
|
276
|
+
def after_tool_call(
|
|
277
|
+
self, ctx: ToolCallContext, result: Any, duration_ms: int
|
|
278
|
+
) -> Optional[Dict[str, Any]]:
|
|
279
|
+
if not ctx.pre_state or self._state_provider is None:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
post_state = self._state_provider()
|
|
284
|
+
if not post_state:
|
|
285
|
+
return None
|
|
286
|
+
ctx.post_state = post_state
|
|
287
|
+
|
|
288
|
+
pre_dur = ctx.pre_state.get("duration_frames")
|
|
289
|
+
post_dur = post_state.get("duration_frames")
|
|
290
|
+
|
|
291
|
+
# Check if duration changed on a non-duration-altering action
|
|
292
|
+
if (
|
|
293
|
+
pre_dur is not None
|
|
294
|
+
and post_dur is not None
|
|
295
|
+
and pre_dur != post_dur
|
|
296
|
+
and ctx.action not in self._DURATION_ALTERING_ACTIONS
|
|
297
|
+
):
|
|
298
|
+
warning = (
|
|
299
|
+
f"Timeline duration drifted unexpectedly from {pre_dur} to {post_dur} frames "
|
|
300
|
+
f"during non-duration altering action '{ctx.action}'"
|
|
301
|
+
)
|
|
302
|
+
logger.warning(warning)
|
|
303
|
+
return {
|
|
304
|
+
"drift_detected": True,
|
|
305
|
+
"drift_warnings": [warning],
|
|
306
|
+
"duration_delta_frames": post_dur - pre_dur,
|
|
307
|
+
}
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
logger.debug(f"Drift detection evaluation skipped: {exc}")
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class ProvenanceTraceHook(LifecycleHook):
|
|
314
|
+
"""Correlates tool lifecycle events into the active execution trace."""
|
|
315
|
+
name = "provenance_trace"
|
|
316
|
+
|
|
317
|
+
def after_tool_call(
|
|
318
|
+
self, ctx: ToolCallContext, result: Any, duration_ms: int
|
|
319
|
+
) -> Optional[Dict[str, Any]]:
|
|
320
|
+
return {
|
|
321
|
+
"execution_id": ctx.execution_id,
|
|
322
|
+
"duration_ms": duration_ms,
|
|
323
|
+
"risk_level": ctx.risk.level.value,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ─── Pipeline Coordinator ───────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class LifecyclePipeline:
|
|
331
|
+
"""Thread-safe coordinator running registered lifecycle hooks."""
|
|
332
|
+
|
|
333
|
+
def __init__(self):
|
|
334
|
+
self._hooks: List[LifecycleHook] = []
|
|
335
|
+
self._lock = threading.RLock()
|
|
336
|
+
self._register_default_hooks()
|
|
337
|
+
|
|
338
|
+
def _register_default_hooks(self):
|
|
339
|
+
"""The hooks that ship enabled. All of them OBSERVE; none short-circuit.
|
|
340
|
+
|
|
341
|
+
`HookDecision(proceed=False)` exists so a deliberately registered hook
|
|
342
|
+
can gate a call, and `register_hook` is public for that. Nothing
|
|
343
|
+
shipping uses it, on purpose: a hook that replaces a tool's result is
|
|
344
|
+
answering on behalf of code that never ran.
|
|
345
|
+
|
|
346
|
+
The original of this pipeline shipped a dry-run interceptor that did
|
|
347
|
+
exactly that — any `dry_run: true` call outside a four-entry allowlist
|
|
348
|
+
was short-circuited with a synthesised `success: true`. Against 273
|
|
349
|
+
`dry_run` references in `src/server.py` it hijacked actions with real
|
|
350
|
+
dry-run paths (`setup.set_defaults`, `resolve_control.clear_executions`),
|
|
351
|
+
and it answered `success: true` for an invalid enum value and for adding
|
|
352
|
+
a marker with no timeline in existence. `dry_run` is the one thing an
|
|
353
|
+
editor reaches for before a destructive edit; a version of it that
|
|
354
|
+
always succeeds is worse than none, because it is trusted.
|
|
355
|
+
`test_no_default_hook_short_circuits` keeps it that way.
|
|
356
|
+
"""
|
|
357
|
+
self._hooks.append(RiskClassificationHook())
|
|
358
|
+
self._hooks.append(ResolveStateInspectionHook())
|
|
359
|
+
self._hooks.append(ReadbackVerificationHook())
|
|
360
|
+
self._hooks.append(DriftDetectionHook())
|
|
361
|
+
self._hooks.append(ProvenanceTraceHook())
|
|
362
|
+
|
|
363
|
+
def register_hook(self, hook: LifecycleHook) -> None:
|
|
364
|
+
with self._lock:
|
|
365
|
+
# Replace existing hook with same name if present
|
|
366
|
+
self._hooks = [h for h in self._hooks if h.name != hook.name]
|
|
367
|
+
self._hooks.append(hook)
|
|
368
|
+
|
|
369
|
+
def set_state_provider(self, provider: Callable[[], Optional[Dict[str, Any]]]) -> None:
|
|
370
|
+
"""Configures state provider callable on inspection and drift hooks."""
|
|
371
|
+
with self._lock:
|
|
372
|
+
for hook in self._hooks:
|
|
373
|
+
if isinstance(hook, (ResolveStateInspectionHook, DriftDetectionHook)):
|
|
374
|
+
hook._state_provider = provider
|
|
375
|
+
|
|
376
|
+
def run_before(self, ctx: ToolCallContext) -> HookDecision:
|
|
377
|
+
with self._lock:
|
|
378
|
+
hooks = list(self._hooks)
|
|
379
|
+
|
|
380
|
+
for hook in hooks:
|
|
381
|
+
if not hook.enabled:
|
|
382
|
+
continue
|
|
383
|
+
try:
|
|
384
|
+
decision = hook.before_tool_call(ctx)
|
|
385
|
+
if decision and not decision.proceed:
|
|
386
|
+
return decision
|
|
387
|
+
except Exception as exc:
|
|
388
|
+
logger.error(f"Error in hook '{hook.name}.before_tool_call': {exc}", exc_info=True)
|
|
389
|
+
return HookDecision(proceed=True)
|
|
390
|
+
|
|
391
|
+
def run_after(self, ctx: ToolCallContext, result: Any, duration_ms: int) -> Any:
|
|
392
|
+
with self._lock:
|
|
393
|
+
hooks = list(self._hooks)
|
|
394
|
+
|
|
395
|
+
contributions: Dict[str, Any] = {}
|
|
396
|
+
for hook in hooks:
|
|
397
|
+
if not hook.enabled:
|
|
398
|
+
continue
|
|
399
|
+
try:
|
|
400
|
+
contrib = hook.after_tool_call(ctx, result, duration_ms)
|
|
401
|
+
if contrib and isinstance(contrib, dict):
|
|
402
|
+
contributions[hook.name] = contrib
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
logger.error(f"Error in hook '{hook.name}.after_tool_call': {exc}", exc_info=True)
|
|
405
|
+
|
|
406
|
+
# Attach telemetry into operation envelope or result if it's a dict
|
|
407
|
+
if isinstance(result, dict) and contributions:
|
|
408
|
+
if "_operation" in result and isinstance(result["_operation"], dict):
|
|
409
|
+
result["_operation"].setdefault("lifecycle", {}).update(contributions)
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
def run_on_error(self, ctx: ToolCallContext, exc: Exception, duration_ms: int) -> None:
|
|
413
|
+
with self._lock:
|
|
414
|
+
hooks = list(self._hooks)
|
|
415
|
+
|
|
416
|
+
for hook in hooks:
|
|
417
|
+
if not hook.enabled:
|
|
418
|
+
continue
|
|
419
|
+
try:
|
|
420
|
+
hook.on_error(ctx, exc, duration_ms)
|
|
421
|
+
except Exception as hook_exc:
|
|
422
|
+
logger.error(f"Error in hook '{hook.name}.on_error': {hook_exc}", exc_info=True)
|
|
423
|
+
|
|
424
|
+
def list_hooks(self) -> List[Dict[str, Any]]:
|
|
425
|
+
with self._lock:
|
|
426
|
+
return [
|
|
427
|
+
{
|
|
428
|
+
"name": h.name,
|
|
429
|
+
"enabled": h.enabled,
|
|
430
|
+
"class": h.__class__.__name__,
|
|
431
|
+
}
|
|
432
|
+
for h in self._hooks
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
def inspect_operation(
|
|
436
|
+
self, tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
|
|
437
|
+
) -> Dict[str, Any]:
|
|
438
|
+
"""Inspects pre-flight risk, blast radius, and state before execution."""
|
|
439
|
+
p = params or {}
|
|
440
|
+
assessment = RiskClassificationHook.classify(tool_name, action, p)
|
|
441
|
+
pre_state = None
|
|
442
|
+
for hook in self._hooks:
|
|
443
|
+
if isinstance(hook, ResolveStateInspectionHook) and hook._state_provider:
|
|
444
|
+
try:
|
|
445
|
+
pre_state = hook._state_provider()
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"success": True,
|
|
452
|
+
"tool": tool_name,
|
|
453
|
+
"action": action,
|
|
454
|
+
"risk": assessment.to_dict(),
|
|
455
|
+
"destructive": assessment.destructive,
|
|
456
|
+
"blast_radius": assessment.blast_radius.value,
|
|
457
|
+
"confirmation_required": assessment.confirmation_required,
|
|
458
|
+
# One value, from one place. This previously reported
|
|
459
|
+
# `assessment.snapshot_available or (pre_state is not None)` at the
|
|
460
|
+
# top level while `risk.snapshot_available` stayed False — the same
|
|
461
|
+
# response answering "can I roll this back?" both ways. Reading a
|
|
462
|
+
# project name is not a restorable snapshot, and the classifier
|
|
463
|
+
# never sets the flag, so the honest answer is "not determined".
|
|
464
|
+
"snapshot_available": assessment.snapshot_available,
|
|
465
|
+
"reasons": assessment.reasons,
|
|
466
|
+
"recognised": assessment.recognised,
|
|
467
|
+
"pre_state": pre_state,
|
|
468
|
+
# Whether pre_state reflects a live Resolve at all, so a caller can
|
|
469
|
+
# tell "no project open" from "never asked".
|
|
470
|
+
"pre_state_available": pre_state is not None,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# Global singleton pipeline
|
|
475
|
+
_GLOBAL_PIPELINE = LifecyclePipeline()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def get_lifecycle_pipeline() -> LifecyclePipeline:
|
|
479
|
+
return _GLOBAL_PIPELINE
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def inspect_operation(
|
|
483
|
+
tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
|
|
484
|
+
) -> Dict[str, Any]:
|
|
485
|
+
return _GLOBAL_PIPELINE.inspect_operation(tool_name, action, params)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def list_lifecycle_hooks() -> List[Dict[str, Any]]:
|
|
489
|
+
return _GLOBAL_PIPELINE.list_hooks()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def classify_operation_risk(
|
|
493
|
+
tool_name: str, action: str, params: Optional[Dict[str, Any]] = None
|
|
494
|
+
) -> RiskAssessment:
|
|
495
|
+
return RiskClassificationHook.classify(tool_name, action, params or {})
|
|
496
|
+
|
|
@@ -33,6 +33,18 @@ proxy deliberately does not.
|
|
|
33
33
|
timeline item-by-item is the shape that bites.
|
|
34
34
|
- **Bridge absence.** If the in-Resolve script is not running, construction fails
|
|
35
35
|
with a clear message rather than pretending; there is nothing to fall back to.
|
|
36
|
+
- **Incomplete replies.** A returned container longer than the surface's
|
|
37
|
+
`max_items` comes back short. That used to be invisible, and a short list is
|
|
38
|
+
indistinguishable from a genuinely short result — an 864-clipInfo
|
|
39
|
+
`AppendToTimeline` returned 500 items and a caller counted them as the whole
|
|
40
|
+
answer. The surface now reports every drop and `_BoundMethod` surfaces it
|
|
41
|
+
(`transport.truncations`, plus a warning naming the method).
|
|
42
|
+
|
|
43
|
+
It warns rather than raising, deliberately: the native call has already *run*
|
|
44
|
+
by the time the reply is encoded, so raising would turn completed Resolve work
|
|
45
|
+
— a placed 864-item assembly — into an error and orphan the result. The honest
|
|
46
|
+
handling is for the caller to stop treating a returned list as a count, which
|
|
47
|
+
is why the tools that report item counts re-read them from the timeline.
|
|
36
48
|
"""
|
|
37
49
|
|
|
38
50
|
from __future__ import annotations
|
|
@@ -117,6 +129,31 @@ class BridgeTransport:
|
|
|
117
129
|
# keeps request/response pairing simple and matches the _bridge_lock
|
|
118
130
|
# discipline the rest of the server already follows.
|
|
119
131
|
self._lock = threading.RLock()
|
|
132
|
+
#: Replies the surface reported as incomplete, newest last: one row per
|
|
133
|
+
#: (method, dropped, total). Kept so a caller that suspects a short
|
|
134
|
+
#: enumeration can prove it instead of inferring it from a count that
|
|
135
|
+
#: looks plausible. Bounded — this is a diagnostic, not a log.
|
|
136
|
+
self.truncations: List[Dict[str, Any]] = []
|
|
137
|
+
|
|
138
|
+
def note_truncation(self, method: str, truncated: Any) -> None:
|
|
139
|
+
"""Record and announce a reply the surface could not carry in full."""
|
|
140
|
+
if not isinstance(truncated, dict):
|
|
141
|
+
return
|
|
142
|
+
row = {"method": method, **truncated}
|
|
143
|
+
with self._lock:
|
|
144
|
+
self.truncations.append(row)
|
|
145
|
+
del self.truncations[:-32]
|
|
146
|
+
# Shapes here come off the wire from a bridge that may be older than
|
|
147
|
+
# this client, so nothing is indexed or assumed present.
|
|
148
|
+
containers = truncated.get("containers")
|
|
149
|
+
first = containers[0] if isinstance(containers, list) and containers else {}
|
|
150
|
+
logger.warning(
|
|
151
|
+
"bridge reply for %s was TRUNCATED: %s of %s elements dropped (limit %s). "
|
|
152
|
+
"The returned list is not a count — re-read the object instead.",
|
|
153
|
+
method, truncated.get("dropped"),
|
|
154
|
+
first.get("total") if isinstance(first, dict) else None,
|
|
155
|
+
truncated.get("limit"),
|
|
156
|
+
)
|
|
120
157
|
|
|
121
158
|
def request(self, operation: str, arguments: Dict[str, Any]) -> Any:
|
|
122
159
|
payload = {
|
|
@@ -251,6 +288,7 @@ class _BoundMethod:
|
|
|
251
288
|
{"target": self._handle, "method": self._name,
|
|
252
289
|
"args": [_encode_argument(a) for a in args]},
|
|
253
290
|
)
|
|
291
|
+
self._transport.note_truncation(self._name, (result or {}).get("truncated"))
|
|
254
292
|
return _decode_value(self._transport, (result or {}).get("value"))
|
|
255
293
|
|
|
256
294
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
@@ -345,6 +383,7 @@ class BridgeProxy:
|
|
|
345
383
|
probe = self._transport.request("get_attribute",
|
|
346
384
|
{"target": self._handle, "name": name}) or {}
|
|
347
385
|
if probe.get("kind") == "value":
|
|
386
|
+
self._transport.note_truncation(name, probe.get("truncated"))
|
|
348
387
|
return _decode_value(self._transport, probe.get("value"))
|
|
349
388
|
if (name in _FUSION_UNENUMERATED_METHODS
|
|
350
389
|
and self._methods() & _FUSION_OBJECT_MARKERS):
|
|
@@ -174,13 +174,22 @@ class ResolveOperations:
|
|
|
174
174
|
#: loses a handle gets a clear `stale_handle` error and can re-fetch.
|
|
175
175
|
MAX_HANDLES = 4096
|
|
176
176
|
|
|
177
|
+
#: Elements carried out of one encoded return value. A timeline-scale
|
|
178
|
+
#: enumeration has to fit: the old 500 silently halved an 864-item
|
|
179
|
+
#: `AppendToTimeline` return, and the caller counted the 500 it got as the
|
|
180
|
+
#: whole truth (a 432+432 variant read back as 250+250, issue: silence-ripple
|
|
181
|
+
#: audio_accounting). Whatever the ceiling is, exceeding it is now REPORTED
|
|
182
|
+
#: — see `_encode` — because a short list that looks complete is the failure
|
|
183
|
+
#: mode, not the bound itself.
|
|
184
|
+
DEFAULT_MAX_ITEMS = 2000
|
|
185
|
+
|
|
177
186
|
def __init__(
|
|
178
187
|
self,
|
|
179
188
|
resolve: Any,
|
|
180
189
|
*,
|
|
181
190
|
media_roots: List[str],
|
|
182
191
|
output_roots: List[str],
|
|
183
|
-
max_items: int =
|
|
192
|
+
max_items: int = DEFAULT_MAX_ITEMS,
|
|
184
193
|
lifecycle: Optional[Callable[[str], Dict[str, Any]]] = None,
|
|
185
194
|
) -> None:
|
|
186
195
|
if resolve is None:
|
|
@@ -191,7 +200,16 @@ class ResolveOperations:
|
|
|
191
200
|
# pretending to stop something they have no handle on.
|
|
192
201
|
self._lifecycle = lifecycle
|
|
193
202
|
self.policy = PathPolicy(media_roots, output_roots)
|
|
194
|
-
|
|
203
|
+
# Capped at MAX_HANDLES, not at some larger round number: every live
|
|
204
|
+
# object in an encoded list mints a handle, so a list longer than the
|
|
205
|
+
# table evicts its own earliest entries before the client can use them
|
|
206
|
+
# and hands back handles that are already `stale_handle`. A ceiling
|
|
207
|
+
# above the table would trade a short list for a poisoned one.
|
|
208
|
+
self.max_items = max(1, min(int(max_items), self.MAX_HANDLES))
|
|
209
|
+
#: Set by `_encode` when a return value did not fit, read by the ops
|
|
210
|
+
#: that encode. Not a counter across calls — it answers "was THIS reply
|
|
211
|
+
#: complete", which is the only question a caller can act on.
|
|
212
|
+
self._encode_truncation: List[Dict[str, Any]] = []
|
|
195
213
|
self._routes: Dict[str, Callable[[Dict[str, Any]], Any]] = {
|
|
196
214
|
name: getattr(self, f"op_{name}") for name in self.OPERATIONS
|
|
197
215
|
}
|
|
@@ -507,21 +525,68 @@ class ResolveOperations:
|
|
|
507
525
|
Every object produced by one call carries the same shape, including the
|
|
508
526
|
elements of a returned list — a track's timeline items are homogeneous,
|
|
509
527
|
which is exactly the case where sharing a cached method set pays.
|
|
528
|
+
|
|
529
|
+
**Dropping elements is recorded, never silent.** A container longer than
|
|
530
|
+
`max_items` used to come back shortened with nothing anywhere saying so,
|
|
531
|
+
and a short list is indistinguishable from a genuinely short result: an
|
|
532
|
+
864-clipInfo `AppendToTimeline` returned 500 items, the caller counted
|
|
533
|
+
them, and a variant holding 432 video + 432 audio was reported to the
|
|
534
|
+
operator as 250 + 250 — which reads exactly like 182 ranges failing to
|
|
535
|
+
land. The bound itself is legitimate (see `max_items`); hiding it is
|
|
536
|
+
not, so every drop is reported alongside the value.
|
|
510
537
|
"""
|
|
511
538
|
if value is None or isinstance(value, (bool, int, float, str)):
|
|
512
539
|
return value
|
|
513
540
|
if depth > 6:
|
|
514
541
|
return str(value)
|
|
515
542
|
if isinstance(value, (list, tuple)):
|
|
516
|
-
|
|
543
|
+
items = list(value)
|
|
544
|
+
self._note_truncation(len(items), "list", depth, shape)
|
|
545
|
+
return [self._encode(v, depth + 1, shape) for v in items[: self.max_items]]
|
|
517
546
|
if isinstance(value, dict):
|
|
518
|
-
|
|
547
|
+
pairs = list(value.items())
|
|
548
|
+
self._note_truncation(len(pairs), "dict", depth, shape)
|
|
549
|
+
return {str(k): self._encode(v, depth + 1, shape) for k, v in pairs[: self.max_items]}
|
|
519
550
|
return {
|
|
520
551
|
"__handle__": self._mint(value, shape),
|
|
521
552
|
"__type__": type(value).__name__,
|
|
522
553
|
"__shape__": shape,
|
|
523
554
|
}
|
|
524
555
|
|
|
556
|
+
def _note_truncation(self, total: int, kind: str, depth: int, shape: str) -> None:
|
|
557
|
+
if total <= self.max_items:
|
|
558
|
+
return
|
|
559
|
+
self._encode_truncation.append({
|
|
560
|
+
"shape": shape, "kind": kind, "depth": depth,
|
|
561
|
+
"returned": self.max_items, "total": total,
|
|
562
|
+
"dropped": total - self.max_items,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
def _encoded(self, value: Any, shape: str) -> Dict[str, Any]:
|
|
566
|
+
"""Encode one return value into a reply, carrying any truncation with it.
|
|
567
|
+
|
|
568
|
+
The `truncated` block is the whole point: a caller that reads `value` as
|
|
569
|
+
a complete answer is wrong exactly when this key is present, and it
|
|
570
|
+
cannot know that from the value alone.
|
|
571
|
+
"""
|
|
572
|
+
self._encode_truncation = []
|
|
573
|
+
encoded = self._encode(value, shape=shape)
|
|
574
|
+
reply: Dict[str, Any] = {"value": encoded}
|
|
575
|
+
if self._encode_truncation:
|
|
576
|
+
dropped = sum(row["dropped"] for row in self._encode_truncation)
|
|
577
|
+
reply["truncated"] = {
|
|
578
|
+
"dropped": dropped,
|
|
579
|
+
"limit": self.max_items,
|
|
580
|
+
"containers": self._encode_truncation[:8],
|
|
581
|
+
"hint": (
|
|
582
|
+
"This reply is INCOMPLETE — the value is not evidence of how many "
|
|
583
|
+
"items exist. Re-read in smaller pieces, or count from the object "
|
|
584
|
+
"itself rather than from this list."
|
|
585
|
+
),
|
|
586
|
+
}
|
|
587
|
+
self._encode_truncation = []
|
|
588
|
+
return reply
|
|
589
|
+
|
|
525
590
|
def _decode(self, value: Any) -> Any:
|
|
526
591
|
"""Argument -> live object, rehydrating handles the bridge itself issued."""
|
|
527
592
|
if isinstance(value, dict):
|
|
@@ -571,7 +636,7 @@ class ResolveOperations:
|
|
|
571
636
|
"resolve_raised",
|
|
572
637
|
f"Resolve raised while running {method}: {str(exc)[:200]}",
|
|
573
638
|
)
|
|
574
|
-
return
|
|
639
|
+
return self._encoded(result, shape=f"{self._shape_of(target_key)}.{method}")
|
|
575
640
|
|
|
576
641
|
def op_list_methods(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
577
642
|
"""The public attribute names on a target — what `hasattr` should answer.
|
|
@@ -690,7 +755,8 @@ class ResolveOperations:
|
|
|
690
755
|
return {"kind": "none",
|
|
691
756
|
"note": "present-but-None; on Resolve objects this is indistinguishable "
|
|
692
757
|
"from absent, because getattr never raises"}
|
|
693
|
-
|
|
758
|
+
encoded = self._encoded(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")
|
|
759
|
+
return {"kind": "value", **encoded}
|
|
694
760
|
|
|
695
761
|
def op_release_handles(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
696
762
|
"""Drop handles a client no longer needs, or all of them."""
|