davinci-resolve-mcp 2.206.0 → 2.208.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 +108 -0
- package/README.md +22 -1
- package/README.zh-CN.md +10 -2
- package/docs/SKILL.md +37 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +113 -2
- package/src/utils/execution_lifecycle.py +496 -0
- package/src/utils/execution_trace.py +254 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,114 @@
|
|
|
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.0 — agent execution lifecycle & pre-flight risk inspection
|
|
6
|
+
|
|
7
|
+
Adapted from the design contributed in PR #187.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Agent execution lifecycle pipeline & hooks:**
|
|
12
|
+
Tools passing through `_guard_missing_params` now execute within a structured
|
|
13
|
+
lifecycle pipeline, supporting pre-flight inspection (`before_tool_call`),
|
|
14
|
+
post-execution enrichment (`after_tool_call`), and failure handling (`on_error`).
|
|
15
|
+
- **Pre-flight operation risk & blast radius assessment:**
|
|
16
|
+
`resolve_control(action="inspect_operation")` evaluates any tool and action
|
|
17
|
+
prior to execution, returning risk levels (`low`, `medium`, `high`, `critical`),
|
|
18
|
+
destructive flags, confirmation requirements, and blast radius scopes (`item`,
|
|
19
|
+
`track`, `timeline`, `project`, `system`).
|
|
20
|
+
- **Lifecycle hooks introspection:**
|
|
21
|
+
`resolve_control(action="list_lifecycle_hooks")` exposes registered pipeline
|
|
22
|
+
hooks and their active states.
|
|
23
|
+
|
|
24
|
+
### Notes on the adaptation
|
|
25
|
+
|
|
26
|
+
- **The dry-run simulation interceptor is not included.** As contributed, any
|
|
27
|
+
call carrying `dry_run: true` outside a hardcoded four-entry allowlist was
|
|
28
|
+
short-circuited and answered with a synthesised `{"success": true,
|
|
29
|
+
"simulated": true}`. `src/server.py` has 273 `dry_run` references, so the
|
|
30
|
+
allowlist was not close: `setup.set_defaults` and
|
|
31
|
+
`resolve_control.clear_executions` both have real, tested dry-run paths and
|
|
32
|
+
were hijacked. It also answered `success: true` to
|
|
33
|
+
`set_defaults(result_envelope="banana")` — a dry run of an operation that
|
|
34
|
+
cannot succeed — and to adding a marker with no timeline in existence.
|
|
35
|
+
`dry_run` is the call an editor makes *because* they do not trust the next
|
|
36
|
+
one; a version of it that always succeeds is worse than none, because it is
|
|
37
|
+
believed. Nothing about dry-run behaviour changes in this release: every
|
|
38
|
+
`dry_run` reaches the handler that owns it.
|
|
39
|
+
- **The pipeline can gate a call, but nothing shipped does.**
|
|
40
|
+
`HookDecision(proceed=False)` and the public `register_hook` remain, so a
|
|
41
|
+
deliberately registered hook can intercept. Every default hook only observes,
|
|
42
|
+
and `test_no_default_hook_short_circuits` keeps it that way.
|
|
43
|
+
- **`inspect_operation` no longer contradicts itself about rollback.** It
|
|
44
|
+
reported `snapshot_available` two ways in one response — `false` inside
|
|
45
|
+
`risk`, and `true` at the top level whenever any pre-state could be read.
|
|
46
|
+
Reading a project name is not a restorable snapshot. It is now a single
|
|
47
|
+
`null`, meaning "not determined", with `pre_state_available` reporting
|
|
48
|
+
separately whether live state was read at all.
|
|
49
|
+
- **An unrecognised operation is no longer assessed as safe.** Any action
|
|
50
|
+
matching no rule fell into a general-mutation bucket and returned `medium` /
|
|
51
|
+
`destructive: false` / `confirmation_required: false` — a confident answer
|
|
52
|
+
about an operation the classifier had never heard of, including ones that do
|
|
53
|
+
not exist. Responses now carry `recognised: false` and say in `reasons` that
|
|
54
|
+
the levels are name-based defaults rather than a finding. The guard exists
|
|
55
|
+
for hallucinated calls; answering one with reassurance was the failure it was
|
|
56
|
+
built to prevent.
|
|
57
|
+
- The docs now state plainly that `inspect_operation` is a heuristic over
|
|
58
|
+
action names, not a simulation: it never touches the project and does not
|
|
59
|
+
validate parameters.
|
|
60
|
+
|
|
61
|
+
## What's New in v2.207.0 — execution audit report exports
|
|
62
|
+
|
|
63
|
+
Contributed in PR #185.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **Execution traces can now be exported as reviewable audit reports.**
|
|
68
|
+
`resolve_control(action="export_execution_report")` writes the latest trace,
|
|
69
|
+
or a named `execution_id`, as Markdown or JSON. The report carries the
|
|
70
|
+
request, status, start/end timestamps, duration, tool summary, semantic
|
|
71
|
+
deltas, verification rollup, warnings, notes, and optional per-step table.
|
|
72
|
+
- **Reports default beside the trace log.** When no path is passed, reports are
|
|
73
|
+
written under `logs/execution-reports/<execution_id>.md` or `.json`.
|
|
74
|
+
`RESOLVE_MCP_TRACE_REPORT_DIR` can move that default destination without
|
|
75
|
+
changing where append-only trace events are logged.
|
|
76
|
+
- **Exports are observer-safe.** Creating a report is exempt from execution-step
|
|
77
|
+
recording, just like querying traces, so inspecting or exporting a trace
|
|
78
|
+
cannot mutate the trace being reviewed.
|
|
79
|
+
- **Existing files are protected by default.** A caller must pass
|
|
80
|
+
`overwrite=true` to replace a report at the chosen path.
|
|
81
|
+
|
|
82
|
+
### Notes
|
|
83
|
+
|
|
84
|
+
- The export is built from the existing trace summary fields, not raw tool
|
|
85
|
+
arguments or raw tool results. It is meant for review and audit, not a replay
|
|
86
|
+
script.
|
|
87
|
+
- Added focused unit and server integration coverage for Markdown export, JSON
|
|
88
|
+
export, step omission, invalid formats, overwrite protection, observer
|
|
89
|
+
isolation, and `resolve_control` dispatch.
|
|
90
|
+
|
|
91
|
+
### Fixed on the way in
|
|
92
|
+
|
|
93
|
+
- **An unverified run no longer reports itself as passed.** The verification
|
|
94
|
+
rollup collapsed "nothing reported any evidence" into `passed: True`, and the
|
|
95
|
+
report printed it verbatim — so a workflow where nothing was checked produced
|
|
96
|
+
an audit document reading `Status: unverified` on one line and `Passed: yes`
|
|
97
|
+
on the next. Those sit inches apart and only one of them gets scanned. The
|
|
98
|
+
rollup now carries `None` for the unknown case and the Passed row renders
|
|
99
|
+
"not established — no checks recorded"; a real pass still says yes and a real
|
|
100
|
+
failure still says no. This is the same distinction v2.206.0 documented for
|
|
101
|
+
`verification.status`, and the export is exactly where it stops being a
|
|
102
|
+
nuance and starts being a claim on paper.
|
|
103
|
+
- **The Simplified Chinese README was carrying a false version line.** Its
|
|
104
|
+
badge and "本翻译对应 vX.Y.Z 版 README" line were bumped to 2.207.0 without the
|
|
105
|
+
section itself, which is the specific failure the release process calls out —
|
|
106
|
+
a lagging translation whose version line asserts otherwise. Translated.
|
|
107
|
+
- `path` is documented as honoured-as-given, creating directories to reach the
|
|
108
|
+
destination: deliberate, since a conform's paperwork belongs beside the
|
|
109
|
+
conform rather than in `logs/`, but worth stating next to a source-media
|
|
110
|
+
safety policy. The `execution_id` route is sanitised to a bare filename and
|
|
111
|
+
cannot escape the report directory — verified.
|
|
112
|
+
|
|
5
113
|
## What's New in v2.206.0 — agent execution traces
|
|
6
114
|
|
|
7
115
|
Adapted from the design contributed in PR #183.
|
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)
|
|
@@ -274,6 +274,27 @@ semantic deltas (`items_deleted`, `items_added`), and readback verifications.
|
|
|
274
274
|
Agents and editors can inspect workflows via `resolve_control`:
|
|
275
275
|
`get_execution_trace(execution_id?)`, `list_recent_executions()`, or open a
|
|
276
276
|
scoped execution with `begin_execution(request="...")` / `end_execution()`.
|
|
277
|
+
`export_execution_report(execution_id?, format="markdown"|"json")` writes a
|
|
278
|
+
reviewable audit artifact with the same summary, defaulting to
|
|
279
|
+
`logs/execution-reports/<execution_id>.md`. `path` writes it anywhere you want
|
|
280
|
+
it instead — alongside a conform in a dated TransferFiles folder, say — and
|
|
281
|
+
creates the directories to get there, so check the path before you send it.
|
|
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.
|
|
294
|
+
|
|
295
|
+
A report for a run where nothing was verified says **"not established — no
|
|
296
|
+
checks recorded"**, not "passed". Absence of evidence is a question still open,
|
|
297
|
+
and an audit document is the last place to let a reader read it as an all-clear.
|
|
277
298
|
|
|
278
299
|
Traces live in a 100-entry in-memory ring and are appended to
|
|
279
300
|
`logs/execution-traces.jsonl` beside `server.log` — `RESOLVE_MCP_TRACE_FILE`
|
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.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -172,6 +172,14 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
|
|
|
172
172
|
|
|
173
173
|
轨迹保存在一个容量 100 条的内存环形缓冲里,并追加写入 `logs/execution-traces.jsonl`(就在 `server.log` 旁边,可用 `RESOLVE_MCP_TRACE_FILE` 改位置),文件到 8 MB 会轮转并保留一份上一代。`list_recent_executions` 会返回该路径以及是否可写,这样"日志是空的"和"根本没在写"就能区分开。记录的内容是工具名、动作、耗时、状态、语义增量和校验结果——不记录参数,也不记录文件路径。唯一的自由文本是你传给 `begin_execution` 的 `request`,在客户项目上请把它当成提交信息来写。
|
|
174
174
|
|
|
175
|
+
### 导出执行审计报告
|
|
176
|
+
|
|
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 的动作凭空编一份预览出来。
|
|
180
|
+
|
|
181
|
+
如果这次运行根本没有做过校验,报告里写的是**"not established — no checks recorded"(未确立——没有记录任何检查)**,而不是"通过"。没有证据是一个仍然悬而未决的问题;审计文件恰恰是最不该让读者把它读成"一切正常"的地方。
|
|
182
|
+
|
|
175
183
|
## 可选增强
|
|
176
184
|
|
|
177
185
|
核心安装刻意保持精简:Python、ffmpeg 和 Resolve 脚本 API。有些功能需要更多依赖,且**每一项都会诚实拒绝并给出自己的安装命令,而不是退化成瞎猜**——编造的节拍或虚构的电平会产出自信但错误的结果,比没有这个功能更糟。
|
package/docs/SKILL.md
CHANGED
|
@@ -271,7 +271,30 @@ Example trace shape returned by `resolve_control(action="get_execution_trace")`:
|
|
|
271
271
|
Fetches the trace for a specific ID, or the most recent execution if omitted.
|
|
272
272
|
- **`list_recent_executions(limit?)`**: Returns the recent execution traces
|
|
273
273
|
(newest first, default limit 20).
|
|
274
|
+
- **`export_execution_report(execution_id?, format?, path?, overwrite?, include_steps?)`**:
|
|
275
|
+
Writes a Markdown or JSON audit report for a trace. The default destination is
|
|
276
|
+
`logs/execution-reports/<execution_id>.md`; pass `format: "json"` for
|
|
277
|
+
structured output, `include_steps: false` for a shorter summary, or
|
|
278
|
+
`overwrite: true` to replace an existing report.
|
|
274
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.
|
|
275
298
|
|
|
276
299
|
Explicit correlation is also supported per-call: pass `params={"execution_id": ...}`
|
|
277
300
|
or `params={"trace_id": ...}` in any tool call to associate it with a specific trace.
|
|
@@ -290,6 +313,20 @@ The only free text is the `request` string passed to `begin_execution`.
|
|
|
290
313
|
|
|
291
314
|
The file rotates at 8 MB, keeping one previous generation as
|
|
292
315
|
`execution-traces.jsonl.1`. Both are gitignored along with the rest of `logs/`.
|
|
316
|
+
Audit reports are separate point-in-time exports; `RESOLVE_MCP_TRACE_REPORT_DIR`
|
|
317
|
+
moves their default directory without moving the append-only trace log.
|
|
318
|
+
|
|
319
|
+
`path` is honoured as given — the report can be written anywhere, and the
|
|
320
|
+
directories are created to reach it. That is deliberate (a conform's paperwork
|
|
321
|
+
belongs beside the conform, not in `logs/`), so treat it the way you would any
|
|
322
|
+
other export destination and do not invent a path near source media. The
|
|
323
|
+
`execution_id` route cannot escape the report directory: it is sanitised to a
|
|
324
|
+
filename.
|
|
325
|
+
|
|
326
|
+
A report whose run recorded no verification checks renders **"not established
|
|
327
|
+
— no checks recorded"** in the Passed row, never "yes" — the same rule as
|
|
328
|
+
`verification.status: "unverified"` in the envelope. Do not report such a run
|
|
329
|
+
to the user as verified.
|
|
293
330
|
|
|
294
331
|
---
|
|
295
332
|
|
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.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
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.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.
|
|
14
|
+
VERSION = "2.208.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -74,6 +74,12 @@ from src.utils.execution_trace import (
|
|
|
74
74
|
begin_execution,
|
|
75
75
|
end_execution,
|
|
76
76
|
clear_executions,
|
|
77
|
+
export_execution_report,
|
|
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,
|
|
77
83
|
)
|
|
78
84
|
from src.utils.render_ids import (
|
|
79
85
|
render_codec_id_from_codecs as _render_codec_id_from_codecs,
|
|
@@ -886,6 +892,37 @@ def _try_connect():
|
|
|
886
892
|
resolve = None
|
|
887
893
|
return None
|
|
888
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
|
+
|
|
889
926
|
def _launch_resolve(headless: Optional[bool] = None):
|
|
890
927
|
"""Launch DaVinci Resolve and wait for it to become available.
|
|
891
928
|
|
|
@@ -1499,6 +1536,8 @@ def _guarded_params(args, kwargs) -> Optional[Dict[str, Any]]:
|
|
|
1499
1536
|
_TRACE_OBSERVER_ACTIONS = {
|
|
1500
1537
|
"get_execution_trace", "get_execution", "list_recent_executions",
|
|
1501
1538
|
"clear_executions", "begin_execution", "end_execution",
|
|
1539
|
+
"export_execution_report",
|
|
1540
|
+
"inspect_operation", "list_lifecycle_hooks",
|
|
1502
1541
|
}
|
|
1503
1542
|
|
|
1504
1543
|
|
|
@@ -1571,14 +1610,29 @@ def _guard_missing_params(fn):
|
|
|
1571
1610
|
async def wrapper(*args, **kwargs):
|
|
1572
1611
|
action = _guarded_action_name(args, kwargs)
|
|
1573
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
|
+
|
|
1574
1623
|
t0 = time.perf_counter()
|
|
1575
1624
|
try:
|
|
1576
1625
|
result = await fn(*args, **kwargs)
|
|
1577
1626
|
except _MissingParam as exc:
|
|
1578
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
|
|
1579
1632
|
duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
|
|
1580
1633
|
enveloped = _build_operation_envelope(
|
|
1581
1634
|
tool_name, action, params, result, duration_ms=duration_ms)
|
|
1635
|
+
enveloped = lifecycle.run_after(ctx, enveloped, duration_ms)
|
|
1582
1636
|
_record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
|
|
1583
1637
|
return enveloped
|
|
1584
1638
|
else:
|
|
@@ -1586,14 +1640,29 @@ def _guard_missing_params(fn):
|
|
|
1586
1640
|
def wrapper(*args, **kwargs):
|
|
1587
1641
|
action = _guarded_action_name(args, kwargs)
|
|
1588
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
|
+
|
|
1589
1653
|
t0 = time.perf_counter()
|
|
1590
1654
|
try:
|
|
1591
1655
|
result = fn(*args, **kwargs)
|
|
1592
1656
|
except _MissingParam as exc:
|
|
1593
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
|
|
1594
1662
|
duration_ms = max(0, int((time.perf_counter() - t0) * 1000))
|
|
1595
1663
|
enveloped = _build_operation_envelope(
|
|
1596
1664
|
tool_name, action, params, result, duration_ms=duration_ms)
|
|
1665
|
+
enveloped = lifecycle.run_after(ctx, enveloped, duration_ms)
|
|
1597
1666
|
_record_execution_step(tool_name, action, params, result, enveloped, duration_ms)
|
|
1598
1667
|
return enveloped
|
|
1599
1668
|
|
|
@@ -16120,8 +16189,15 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16120
16189
|
— Open a multi-step execution trace so subsequent tool calls thread under this execution ID.
|
|
16121
16190
|
end_execution(execution_id?, verification?, status?, notes?) -> {success, trace}
|
|
16122
16191
|
— Conclude an execution trace and compute final rollups.
|
|
16192
|
+
export_execution_report(execution_id?, format?, path?, overwrite?, include_steps?) -> {success, path, bytes}
|
|
16193
|
+
— Write a Markdown or JSON audit report for an execution trace (no connection needed).
|
|
16123
16194
|
clear_executions(dry_run?) -> {success, cleared}
|
|
16124
16195
|
— Clear the in-memory execution trace buffer.
|
|
16196
|
+
inspect_operation(tool?, target_action?, target_params?) -> {tool, action, risk, destructive, blast_radius, confirmation_required, snapshot_available, recognised, reasons, pre_state, pre_state_available}
|
|
16197
|
+
— 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.
|
|
16198
|
+
— Pre-flight risk assessment and blast radius inspection for any tool action before execution (no connection needed).
|
|
16199
|
+
list_lifecycle_hooks() -> {success, hooks, count}
|
|
16200
|
+
— List active agent tool execution lifecycle hooks and their enabled status (no connection needed).
|
|
16125
16201
|
"""
|
|
16126
16202
|
p = _params(params)
|
|
16127
16203
|
|
|
@@ -16249,12 +16325,47 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16249
16325
|
if not trace:
|
|
16250
16326
|
return _err("No active or matching execution to end", code="NOT_FOUND", category="state")
|
|
16251
16327
|
return {"success": True, "trace": trace}
|
|
16328
|
+
if action == "export_execution_report":
|
|
16329
|
+
exec_id = p.get("execution_id") or p.get("id")
|
|
16330
|
+
report_format = p.get("format") or p.get("report_format") or "markdown"
|
|
16331
|
+
include_steps = _setup_bool(p.get("include_steps", p.get("includeSteps")), True)
|
|
16332
|
+
overwrite = _setup_bool(p.get("overwrite"), False)
|
|
16333
|
+
try:
|
|
16334
|
+
res = _execution_trace.export_execution_report(
|
|
16335
|
+
execution_id=exec_id,
|
|
16336
|
+
report_format=report_format,
|
|
16337
|
+
output_path=p.get("path") or p.get("output_path") or p.get("outputPath"),
|
|
16338
|
+
overwrite=overwrite,
|
|
16339
|
+
include_steps=include_steps,
|
|
16340
|
+
)
|
|
16341
|
+
except FileExistsError as exc:
|
|
16342
|
+
return _err(str(exc), code="REPORT_EXISTS", category="invalid_input")
|
|
16343
|
+
except ValueError as exc:
|
|
16344
|
+
return _err(str(exc), code="INVALID_REPORT_FORMAT", category="invalid_input")
|
|
16345
|
+
except OSError as exc:
|
|
16346
|
+
return _err(
|
|
16347
|
+
"Could not write execution report",
|
|
16348
|
+
code="REPORT_WRITE_FAILED",
|
|
16349
|
+
category="io",
|
|
16350
|
+
reason=str(exc),
|
|
16351
|
+
)
|
|
16352
|
+
if not res:
|
|
16353
|
+
return _err(f"No execution trace found for id: {exec_id or 'latest'}", code="NOT_FOUND", category="state")
|
|
16354
|
+
return res
|
|
16252
16355
|
if action == "clear_executions":
|
|
16253
16356
|
dry_run = _setup_bool(p.get("dry_run", p.get("dryRun")), False)
|
|
16254
16357
|
if dry_run:
|
|
16255
16358
|
return {"success": True, "dry_run": True, "count": len(_execution_trace.list_recent_executions(100))}
|
|
16256
16359
|
res = _execution_trace.clear_executions()
|
|
16257
16360
|
return res
|
|
16361
|
+
if action == "inspect_operation":
|
|
16362
|
+
target_tool = p.get("tool") or p.get("tool_name") or "timeline"
|
|
16363
|
+
target_action = p.get("target_action") or p.get("action") or p.get("op") or "delete_clips"
|
|
16364
|
+
target_params = p.get("target_params") or p.get("params") or {}
|
|
16365
|
+
return _execution_lifecycle.inspect_operation(target_tool, target_action, target_params)
|
|
16366
|
+
if action == "list_lifecycle_hooks":
|
|
16367
|
+
hooks = _execution_lifecycle.list_lifecycle_hooks()
|
|
16368
|
+
return {"success": True, "hooks": hooks, "count": len(hooks)}
|
|
16258
16369
|
|
|
16259
16370
|
# Control-panel actions don't require Resolve to be running.
|
|
16260
16371
|
if action == "open_control_panel":
|
|
@@ -16470,7 +16581,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16470
16581
|
if err:
|
|
16471
16582
|
return _err(err)
|
|
16472
16583
|
return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
|
|
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"])
|
|
16584
|
+
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"])
|
|
16474
16585
|
|
|
16475
16586
|
|
|
16476
16587
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -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
|
+
|
|
@@ -26,11 +26,12 @@ import collections
|
|
|
26
26
|
import json
|
|
27
27
|
import logging
|
|
28
28
|
import os
|
|
29
|
+
import re
|
|
29
30
|
import threading
|
|
30
31
|
import time
|
|
31
32
|
import uuid
|
|
32
33
|
from pathlib import Path
|
|
33
|
-
from typing import Any, Deque, Dict, List, Optional
|
|
34
|
+
from typing import Any, Deque, Dict, List, Optional, Union
|
|
34
35
|
|
|
35
36
|
logger = logging.getLogger("resolve-mcp.execution-trace")
|
|
36
37
|
|
|
@@ -135,8 +136,12 @@ def _merge_verification(
|
|
|
135
136
|
status = "passed"
|
|
136
137
|
passed = True
|
|
137
138
|
else:
|
|
139
|
+
# None, not True. Nothing reported any evidence either way, and
|
|
140
|
+
# collapsing that into a boolean makes the rollup assert a pass it
|
|
141
|
+
# never observed — which then reaches a human as "Passed: yes" in an
|
|
142
|
+
# exported audit report.
|
|
138
143
|
status = "unverified"
|
|
139
|
-
passed =
|
|
144
|
+
passed = None
|
|
140
145
|
|
|
141
146
|
return {
|
|
142
147
|
"status": status,
|
|
@@ -174,7 +179,10 @@ def begin_execution(
|
|
|
174
179
|
"changes": None,
|
|
175
180
|
"verification": {
|
|
176
181
|
"status": "unverified",
|
|
177
|
-
"passed"
|
|
182
|
+
# None, not True. "Nothing was checked" is not "everything passed",
|
|
183
|
+
# and the difference reaches a human in an exported audit report —
|
|
184
|
+
# see _execution_report_markdown.
|
|
185
|
+
"passed": None,
|
|
178
186
|
"contradiction": False,
|
|
179
187
|
"checks": [],
|
|
180
188
|
},
|
|
@@ -313,7 +321,7 @@ def record_step(
|
|
|
313
321
|
"changes": None,
|
|
314
322
|
"verification": {
|
|
315
323
|
"status": "unverified",
|
|
316
|
-
"passed":
|
|
324
|
+
"passed": None, # see above: unknown, not passed
|
|
317
325
|
"contradiction": False,
|
|
318
326
|
"checks": [],
|
|
319
327
|
},
|
|
@@ -338,7 +346,7 @@ def record_step(
|
|
|
338
346
|
"changes": None,
|
|
339
347
|
"verification": {
|
|
340
348
|
"status": "unverified",
|
|
341
|
-
"passed":
|
|
349
|
+
"passed": None, # see above: unknown, not passed
|
|
342
350
|
"contradiction": False,
|
|
343
351
|
"checks": [],
|
|
344
352
|
},
|
|
@@ -466,6 +474,247 @@ def clear_executions() -> Dict[str, Any]:
|
|
|
466
474
|
return {"success": True, "cleared": count}
|
|
467
475
|
|
|
468
476
|
|
|
477
|
+
# ── Audit Reports ───────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
def _report_format(report_format: str) -> str:
|
|
480
|
+
fmt = str(report_format or "markdown").strip().lower()
|
|
481
|
+
if fmt in {"md", "markdown"}:
|
|
482
|
+
return "markdown"
|
|
483
|
+
if fmt == "json":
|
|
484
|
+
return "json"
|
|
485
|
+
raise ValueError("format must be markdown or json")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _report_extension(report_format: str) -> str:
|
|
489
|
+
return ".md" if _report_format(report_format) == "markdown" else ".json"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _report_filename(execution_id: str, report_format: str) -> str:
|
|
493
|
+
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(execution_id or "execution")).strip("._")
|
|
494
|
+
if not safe_id:
|
|
495
|
+
safe_id = "execution"
|
|
496
|
+
return f"{safe_id}{_report_extension(report_format)}"
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def execution_report_dir() -> str:
|
|
500
|
+
"""Directory used for generated execution audit reports."""
|
|
501
|
+
override = os.environ.get("RESOLVE_MCP_TRACE_REPORT_DIR")
|
|
502
|
+
if override:
|
|
503
|
+
return os.path.realpath(os.path.abspath(os.path.expanduser(override)))
|
|
504
|
+
return str(_REPO_ROOT / "logs" / "execution-reports")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _default_report_path(trace: Dict[str, Any], report_format: str) -> str:
|
|
508
|
+
return str(Path(execution_report_dir()) / _report_filename(trace["execution_id"], report_format))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _format_ms(value: Any) -> str:
|
|
512
|
+
try:
|
|
513
|
+
return f"{max(0, int(value))} ms"
|
|
514
|
+
except (TypeError, ValueError):
|
|
515
|
+
return "0 ms"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _scalar(value: Any) -> str:
|
|
519
|
+
if value is None:
|
|
520
|
+
return ""
|
|
521
|
+
if isinstance(value, bool):
|
|
522
|
+
return "yes" if value else "no"
|
|
523
|
+
if isinstance(value, (int, float)):
|
|
524
|
+
return str(value)
|
|
525
|
+
if isinstance(value, (dict, list)):
|
|
526
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
527
|
+
return str(value)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _markdown_row(*cells: Any) -> str:
|
|
531
|
+
escaped = []
|
|
532
|
+
for cell in cells:
|
|
533
|
+
text = _scalar(cell).replace("\n", " ").replace("|", "\\|")
|
|
534
|
+
escaped.append(text)
|
|
535
|
+
return "| " + " | ".join(escaped) + " |"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _execution_report_json(trace: Dict[str, Any], *, include_steps: bool = True) -> Dict[str, Any]:
|
|
539
|
+
"""Return a stable, compact report object derived from safe trace summaries."""
|
|
540
|
+
out = {
|
|
541
|
+
"execution_id": trace.get("execution_id"),
|
|
542
|
+
"request": trace.get("request"),
|
|
543
|
+
"status": trace.get("status"),
|
|
544
|
+
"started_at": trace.get("started_at"),
|
|
545
|
+
"ended_at": trace.get("ended_at"),
|
|
546
|
+
"duration_ms": trace.get("duration_ms", 0),
|
|
547
|
+
"initiator": trace.get("initiator"),
|
|
548
|
+
"is_active": trace.get("is_active", False),
|
|
549
|
+
"tools": trace.get("tools", []),
|
|
550
|
+
"changes": trace.get("changes"),
|
|
551
|
+
"verification": trace.get("verification", {}),
|
|
552
|
+
"warnings": trace.get("warnings", []),
|
|
553
|
+
}
|
|
554
|
+
if trace.get("notes"):
|
|
555
|
+
out["notes"] = trace.get("notes")
|
|
556
|
+
if include_steps:
|
|
557
|
+
out["steps"] = trace.get("steps", [])
|
|
558
|
+
return out
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _execution_report_markdown(trace: Dict[str, Any], *, include_steps: bool = True) -> str:
|
|
562
|
+
report = _execution_report_json(trace, include_steps=include_steps)
|
|
563
|
+
lines = [
|
|
564
|
+
"# Execution Audit Report",
|
|
565
|
+
"",
|
|
566
|
+
_markdown_row("Field", "Value"),
|
|
567
|
+
_markdown_row("---", "---"),
|
|
568
|
+
_markdown_row("Execution ID", report.get("execution_id")),
|
|
569
|
+
_markdown_row("Request", report.get("request")),
|
|
570
|
+
_markdown_row("Status", report.get("status")),
|
|
571
|
+
_markdown_row("Started", report.get("started_at")),
|
|
572
|
+
_markdown_row("Ended", report.get("ended_at")),
|
|
573
|
+
_markdown_row("Duration", _format_ms(report.get("duration_ms"))),
|
|
574
|
+
_markdown_row("Initiator", report.get("initiator")),
|
|
575
|
+
"",
|
|
576
|
+
"## Tool Summary",
|
|
577
|
+
"",
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
tools = report.get("tools") or []
|
|
581
|
+
if tools:
|
|
582
|
+
lines.extend([
|
|
583
|
+
_markdown_row("Tool", "Calls", "Duration"),
|
|
584
|
+
_markdown_row("---", "---:", "---:"),
|
|
585
|
+
])
|
|
586
|
+
for tool in tools:
|
|
587
|
+
lines.append(_markdown_row(tool.get("tool"), tool.get("count", 0), _format_ms(tool.get("duration_ms"))))
|
|
588
|
+
else:
|
|
589
|
+
lines.append("No tool calls recorded.")
|
|
590
|
+
|
|
591
|
+
lines.extend(["", "## Changes", ""])
|
|
592
|
+
changes = report.get("changes")
|
|
593
|
+
if isinstance(changes, dict) and changes:
|
|
594
|
+
lines.extend([
|
|
595
|
+
_markdown_row("Change", "Value"),
|
|
596
|
+
_markdown_row("---", "---"),
|
|
597
|
+
])
|
|
598
|
+
for key in sorted(changes):
|
|
599
|
+
lines.append(_markdown_row(key, changes[key]))
|
|
600
|
+
else:
|
|
601
|
+
lines.append("No semantic changes recorded.")
|
|
602
|
+
|
|
603
|
+
verification = report.get("verification") or {}
|
|
604
|
+
lines.extend([
|
|
605
|
+
"",
|
|
606
|
+
"## Verification",
|
|
607
|
+
"",
|
|
608
|
+
_markdown_row("Field", "Value"),
|
|
609
|
+
_markdown_row("---", "---"),
|
|
610
|
+
_markdown_row("Status", verification.get("status")),
|
|
611
|
+
_markdown_row("Passed", _verification_passed_label(verification)),
|
|
612
|
+
_markdown_row("Contradiction", verification.get("contradiction")),
|
|
613
|
+
_markdown_row("Checks", len(verification.get("checks") or [])),
|
|
614
|
+
])
|
|
615
|
+
|
|
616
|
+
warnings = report.get("warnings") or []
|
|
617
|
+
lines.extend(["", "## Warnings", ""])
|
|
618
|
+
if warnings:
|
|
619
|
+
lines.extend(f"- {_scalar(w)}" for w in warnings)
|
|
620
|
+
else:
|
|
621
|
+
lines.append("No warnings recorded.")
|
|
622
|
+
|
|
623
|
+
if report.get("notes"):
|
|
624
|
+
lines.extend(["", "## Notes", "", _scalar(report["notes"])])
|
|
625
|
+
|
|
626
|
+
if include_steps:
|
|
627
|
+
lines.extend(["", "## Steps", ""])
|
|
628
|
+
steps = report.get("steps") or []
|
|
629
|
+
if steps:
|
|
630
|
+
lines.extend([
|
|
631
|
+
_markdown_row("#", "Timestamp", "Operation", "Status", "Duration"),
|
|
632
|
+
_markdown_row("---:", "---", "---", "---", "---:"),
|
|
633
|
+
])
|
|
634
|
+
for step in steps:
|
|
635
|
+
lines.append(_markdown_row(
|
|
636
|
+
step.get("seq"),
|
|
637
|
+
step.get("timestamp"),
|
|
638
|
+
step.get("operation"),
|
|
639
|
+
step.get("status"),
|
|
640
|
+
_format_ms(step.get("duration_ms")),
|
|
641
|
+
))
|
|
642
|
+
else:
|
|
643
|
+
lines.append("No steps recorded.")
|
|
644
|
+
|
|
645
|
+
lines.append("")
|
|
646
|
+
return "\n".join(lines)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _verification_passed_label(verification: Dict[str, Any]) -> str:
|
|
650
|
+
"""How the Passed row reads when nothing was actually verified.
|
|
651
|
+
|
|
652
|
+
A report that prints `Status: unverified` beside `Passed: yes` is read by a
|
|
653
|
+
human as "it passed" — the two lines are inches apart and only one of them
|
|
654
|
+
is scanned. Unverified means no evidence was reported, which is a question
|
|
655
|
+
still open, not a clean bill of health; an audit document is the last place
|
|
656
|
+
that distinction should be left to the reader.
|
|
657
|
+
"""
|
|
658
|
+
passed = verification.get("passed")
|
|
659
|
+
if passed is None:
|
|
660
|
+
return "not established — no checks recorded"
|
|
661
|
+
return "yes" if passed else "no"
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def render_execution_report(
|
|
665
|
+
trace: Dict[str, Any],
|
|
666
|
+
*,
|
|
667
|
+
report_format: str = "markdown",
|
|
668
|
+
include_steps: bool = True,
|
|
669
|
+
) -> Union[str, Dict[str, Any]]:
|
|
670
|
+
"""Render an execution trace as a Markdown or JSON audit report."""
|
|
671
|
+
fmt = _report_format(report_format)
|
|
672
|
+
if fmt == "json":
|
|
673
|
+
return _execution_report_json(trace, include_steps=include_steps)
|
|
674
|
+
return _execution_report_markdown(trace, include_steps=include_steps)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def export_execution_report(
|
|
678
|
+
execution_id: Optional[str] = None,
|
|
679
|
+
*,
|
|
680
|
+
report_format: str = "markdown",
|
|
681
|
+
output_path: Optional[str] = None,
|
|
682
|
+
overwrite: bool = False,
|
|
683
|
+
include_steps: bool = True,
|
|
684
|
+
) -> Optional[Dict[str, Any]]:
|
|
685
|
+
"""Write an execution audit report to disk.
|
|
686
|
+
|
|
687
|
+
The export is built from trace summaries, not raw tool arguments/results.
|
|
688
|
+
"""
|
|
689
|
+
trace = get_execution_trace(execution_id)
|
|
690
|
+
if not trace:
|
|
691
|
+
return None
|
|
692
|
+
|
|
693
|
+
fmt = _report_format(report_format)
|
|
694
|
+
path = output_path or _default_report_path(trace, fmt)
|
|
695
|
+
real_path = os.path.realpath(os.path.abspath(os.path.expanduser(path)))
|
|
696
|
+
if os.path.exists(real_path) and not overwrite:
|
|
697
|
+
raise FileExistsError(f"Report already exists: {real_path}")
|
|
698
|
+
|
|
699
|
+
rendered = render_execution_report(trace, report_format=fmt, include_steps=include_steps)
|
|
700
|
+
os.makedirs(os.path.dirname(real_path), exist_ok=True)
|
|
701
|
+
if fmt == "json":
|
|
702
|
+
payload = json.dumps(rendered, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
703
|
+
else:
|
|
704
|
+
payload = str(rendered)
|
|
705
|
+
with open(real_path, "w", encoding="utf-8") as fh:
|
|
706
|
+
fh.write(payload)
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
"success": True,
|
|
710
|
+
"execution_id": trace["execution_id"],
|
|
711
|
+
"format": fmt,
|
|
712
|
+
"path": real_path,
|
|
713
|
+
"bytes": len(payload.encode("utf-8")),
|
|
714
|
+
"included_steps": bool(include_steps),
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
469
718
|
# ── Persistence (Best-Effort) ────────────────────────────────────────────────
|
|
470
719
|
|
|
471
720
|
def trace_log_path() -> str:
|