davinci-resolve-mcp 2.37.3 → 2.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
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.39.0
6
+
7
+ Governance enforce mode and actor identity — the staged Phase 3 of the
8
+ Resolve 21 AI-ops work.
9
+
10
+ - **Added** governance `mode` for the media-creating AI ops (motion deblur,
11
+ speech generation): `advisory` (default, unchanged — confirm-preview
12
+ warnings only) or `enforce`, where an over-tier run is blocked with
13
+ `GOVERNANCE_BLOCKED` before token issuance, naming the exceeded dimensions.
14
+ Escape hatches: raise the tier, relax the mode, or pass
15
+ `override_governance=true` to consciously exceed the tier once.
16
+ `set_ai_governance` accepts `mode` (preset now optional);
17
+ `get_ai_governance` reports it.
18
+ - **Added** instance-level actor identity (per the recorded concurrency
19
+ design): each entry point declares itself — `stdio`, `network-sse`,
20
+ `network-http`, `control-panel`, `batch-cli` — and AI-ops ledger rows,
21
+ brain edits, and timeline versions now carry `actor` (`<instance>:<pid>`)
22
+ alongside `initiator`. Schema v8 (additive columns); migration verified
23
+ against a copy of a real project DB with all rows preserved.
24
+
25
+ ## What's New in v2.38.0
26
+
27
+ Busy gate for long DaVinci Resolve operations — the first piece of the
28
+ concurrency design for the stdio + networked two-instance setup.
29
+
30
+ - **Added** cross-process registration for long synchronous Resolve calls
31
+ (timeline export/import, scene-cut detection, subtitle generation,
32
+ Dolby Vision analysis, folder/clip audio transcription). A tool call that
33
+ arrives while one is running now waits up to 5 seconds and then returns a
34
+ structured `RESOLVE_BUSY` error (new `busy` category, retryable, with
35
+ `state.busy_with` and `state.age_seconds`) instead of hanging silently
36
+ inside the scripting bridge. Stale registrations from crashed operations
37
+ are ignored automatically; an operation never gates its own thread.
38
+ - Design decisions recorded: single-editor/multi-client is the supported
39
+ concurrency target; confirm tokens stay per-instance; actor identity is
40
+ deferred to the governance phase.
41
+
5
42
  ## What's New in v2.37.3
6
43
 
7
44
  Deep-audit fixes, rounds two and three: agent-facing action discovery,
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.37.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.39.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-33%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -694,7 +694,13 @@ The two media-creating ops also have **soft governance tiers**
694
694
  deblur/speech runs, bytes, and render time. It is advisory — the confirm dialog
695
695
  warns when near/over the tier but never blocks (the ops are confirm-gated).
696
696
  Inspect/set with `media_analysis(action="get_ai_governance")` and
697
- `media_analysis(action="set_ai_governance", preset=…, overrides={...})`; the AI
697
+ `media_analysis(action="set_ai_governance", preset=…, mode=…, overrides={...})`.
698
+ Governance `mode` is `advisory` by default (preview warnings only); `enforce`
699
+ blocks an over-tier run with `GOVERNANCE_BLOCKED` until the tier is raised, the
700
+ mode is relaxed, or the op is re-called with `override_governance=true`. Ledger
701
+ rows, brain edits, and timeline versions record the acting instance
702
+ (`stdio` / `network-sse` / `network-http` / `control-panel` / `batch-cli` + pid)
703
+ in an `actor` field; the AI
698
704
  Console's Governance section offers a tier picker + consumption gauges.
699
705
 
700
706
  The caps layer:
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.37.3"
38
+ VERSION = "2.39.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.37.3",
3
+ "version": "2.39.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -14678,6 +14678,8 @@ def _warm_inventory_cache(project_root: str) -> None:
14678
14678
 
14679
14679
 
14680
14680
  def main() -> None:
14681
+ from src.utils import actor_identity
14682
+ actor_identity.set_instance("control-panel")
14681
14683
  args = parse_args()
14682
14684
  state = DashboardState(args.project_name, args.project_id, args.analysis_root)
14683
14685
  Handler.state = state
package/src/batch_cli.py CHANGED
@@ -526,6 +526,8 @@ _HANDLERS = {
526
526
 
527
527
 
528
528
  def main(argv: Optional[List[str]] = None) -> int:
529
+ from src.utils import actor_identity
530
+ actor_identity.set_instance("batch-cli")
529
531
  parser = _build_parser()
530
532
  args = parser.parse_args(argv)
531
533
  if not hasattr(args, "json"):
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.37.3"
83
+ VERSION = "2.39.0"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.37.3"
14
+ VERSION = "2.39.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -84,6 +84,8 @@ from src.utils.media_analysis import (
84
84
  summarize_reports as summarize_media_analysis_reports,
85
85
  )
86
86
  from src.utils.sync_detection import detect_sync_events_for_records as detect_media_sync_events
87
+ from src.utils import actor_identity, resolve_busy
88
+ from src.utils.resolve_busy import long_resolve_op
87
89
  from src.utils.media_analysis_jobs import (
88
90
  MEDIA_EXTENSIONS,
89
91
  batch_job_status as media_analysis_batch_job_status,
@@ -714,6 +716,54 @@ def _ai_governance_check(op: str) -> Dict[str, Any]:
714
716
  return {"applies": False}
715
717
 
716
718
 
719
+ _AI_GOVERNANCE_MODES = ("advisory", "enforce")
720
+
721
+
722
+ def _ai_governance_mode() -> str:
723
+ try:
724
+ raw = str(_read_media_analysis_preferences().get("resolve_ai_governance_mode") or "").strip().lower()
725
+ return raw if raw in _AI_GOVERNANCE_MODES else "advisory"
726
+ except Exception:
727
+ return "advisory"
728
+
729
+
730
+ def _ai_governance_gate(op: str, p: Dict[str, Any]) -> Optional[Dict[str, Any]]:
731
+ """Hard gate for a governed AI render op when governance mode is 'enforce'.
732
+
733
+ Returns None to proceed (advisory mode, within tier, or explicit
734
+ override_governance=True), otherwise a destructive_blocked error naming
735
+ the exceeded dimensions. Checked before token issuance so the agent
736
+ learns about the block without burning a confirm round-trip.
737
+ """
738
+ if _ai_governance_mode() != "enforce":
739
+ return None
740
+ if p.get("override_governance") or p.get("overrideGovernance"):
741
+ return None
742
+ check = _ai_governance_check(op)
743
+ if not check.get("applies") or not check.get("exceeded"):
744
+ return None
745
+ return _err(
746
+ f"Governance tier '{check.get('tier')}' blocks this {op} run: "
747
+ + " ".join(check.get("warnings") or ["over threshold."]),
748
+ code="GOVERNANCE_BLOCKED",
749
+ category="destructive_blocked",
750
+ retryable=False,
751
+ remediation=(
752
+ "Raise the tier or thresholds via media_analysis(action='set_ai_governance'), "
753
+ "switch governance mode back to 'advisory', or re-call with override_governance=true "
754
+ "to consciously exceed the tier this once."
755
+ ),
756
+ state={
757
+ "op": op,
758
+ "mode": "enforce",
759
+ "tier": check.get("tier"),
760
+ "usage": check.get("usage"),
761
+ "projected": check.get("projected"),
762
+ "thresholds": check.get("thresholds"),
763
+ },
764
+ )
765
+
766
+
717
767
  def _destructive_preference_provider(key: str) -> Any:
718
768
  """Reader for C6 preferences out of the existing media-analysis prefs file."""
719
769
  try:
@@ -830,6 +880,7 @@ ERROR_CATEGORIES = (
830
880
  "wrong_page", # action requires a specific page (Color, Edit, Fairlight…)
831
881
  "invalid_input", # caller-side fixable (bad param shape, unknown enum)
832
882
  "resolve_api_failed", # Resolve returned None/False for unclear reasons
883
+ "busy", # another long Resolve op holds the bridge; retry later
833
884
  "destructive_blocked", # strict-mode/confirm-token refusal
834
885
  "pending_user_decision", # confirm_token required
835
886
  "unsupported", # feature/version/method not available
@@ -846,6 +897,7 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
846
897
  "wrong_page": True, # trivial caller fix; retry after page switch
847
898
  "invalid_input": False, # caller fix required
848
899
  "resolve_api_failed": True, # often transient; retry once
900
+ "busy": True, # another long Resolve op holds the bridge
849
901
  "destructive_blocked": False, # user decision required
850
902
  "pending_user_decision": False, # confirm_token required
851
903
  "unsupported": False, # API/version mismatch
@@ -1028,11 +1080,14 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
1028
1080
  rec = _CONFIRM_TOKENS.pop(token, None) # one-time use
1029
1081
  if rec is None:
1030
1082
  return _err(
1031
- "confirm_token is invalid or expired",
1083
+ "confirm_token is invalid, expired, or was issued by a different "
1084
+ "server instance (tokens are valid only on the instance that "
1085
+ "issued them — e.g. a stdio-server token is not honored by the "
1086
+ "networked server).",
1032
1087
  code="CONFIRM_TOKEN_INVALID",
1033
1088
  category="destructive_blocked",
1034
1089
  retryable=False,
1035
- remediation=f"Re-call {action} without confirm_token to receive a fresh token.",
1090
+ remediation=f"Re-call {action} without confirm_token on this instance to receive a fresh token.",
1036
1091
  )
1037
1092
  if rec.get("action") != action:
1038
1093
  return _err(
@@ -1671,6 +1726,19 @@ def _annotation_boundary_report(tl, p: Dict[str, Any]):
1671
1726
  }
1672
1727
 
1673
1728
  def _check():
1729
+ busy = resolve_busy.wait_until_free()
1730
+ if busy:
1731
+ return None, None, _err(
1732
+ f"Resolve is busy with a long operation: {busy['label']} "
1733
+ f"(running for {busy['age_seconds']}s). Retry after it completes.",
1734
+ code="RESOLVE_BUSY", category="busy",
1735
+ remediation="Wait for the named operation to finish, then retry this call.",
1736
+ state={
1737
+ "busy_with": busy["label"],
1738
+ "age_seconds": busy["age_seconds"],
1739
+ "same_process": busy["same_process"],
1740
+ },
1741
+ )
1674
1742
  resolve = get_resolve()
1675
1743
  if resolve is None:
1676
1744
  return None, None, _err(
@@ -4916,7 +4984,8 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
4916
4984
  spec = _timeline_export_spec(p, resolve)
4917
4985
  if p.get("dry_run"):
4918
4986
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
4919
- success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
4987
+ with long_resolve_op("timeline.export_timeline_checked"):
4988
+ success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
4920
4989
  files = []
4921
4990
  primary_file = path
4922
4991
  if success and os.path.isdir(path):
@@ -4969,7 +5038,8 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
4969
5038
  tl_existing = proj.GetTimelineByIndex(index)
4970
5039
  if tl_existing:
4971
5040
  before_ids.add(str(tl_existing.GetUniqueId()))
4972
- imported = mp.ImportTimelineFromFile(path, options)
5041
+ with long_resolve_op("timeline.import_timeline_checked"):
5042
+ imported = mp.ImportTimelineFromFile(path, options)
4973
5043
  if not imported:
4974
5044
  return _err("Failed to import timeline")
4975
5045
  imported_id = str(imported.GetUniqueId())
@@ -5937,7 +6007,8 @@ def _subtitle_generation_probe(tl, p: Dict[str, Any]):
5937
6007
  return _ok(would_generate=True, settings=settings, note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
5938
6008
  if not _has_method(tl, "CreateSubtitlesFromAudio"):
5939
6009
  return _err("CreateSubtitlesFromAudio unavailable")
5940
- return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
6010
+ with long_resolve_op("timeline.create_subtitles_from_audio"):
6011
+ return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
5941
6012
 
5942
6013
 
5943
6014
  def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
@@ -12978,6 +13049,9 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12978
13049
  return _err("generate_speech requires speech_generation_settings with a 'TextInput' string. "
12979
13050
  "Optional keys: VoiceModel, CustomVoiceFile, Speed, Variation, Pitch, GenerationID, Filename, AddToTimeline, AudioTrack.")
12980
13051
  timecode = _first_param(p, "timecode", default="") or ""
13052
+ gate = _ai_governance_gate("generate_speech", p)
13053
+ if gate:
13054
+ return gate
12981
13055
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
12982
13056
  return _issue_confirm_token(
12983
13057
  action="project_settings.generate_speech",
@@ -13811,7 +13885,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
13811
13885
  elif action == "setup_multicam_timeline":
13812
13886
  return _setup_multicam_timeline(proj, mp, p)
13813
13887
  elif action == "import_timeline":
13814
- tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
13888
+ with long_resolve_op("media_pool.import_timeline"):
13889
+ tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
13815
13890
  return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
13816
13891
  elif action == "delete_timelines":
13817
13892
  count = proj.GetTimelineCount()
@@ -14021,8 +14096,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14021
14096
  elif action == "transcribe_audio":
14022
14097
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
14023
14098
  if usd is None:
14024
- return {"success": bool(f.TranscribeAudio())}
14025
- return {"success": bool(f.TranscribeAudio(bool(usd)))}
14099
+ with long_resolve_op("folder.transcribe_audio"):
14100
+ return {"success": bool(f.TranscribeAudio())}
14101
+ with long_resolve_op("folder.transcribe_audio"):
14102
+ return {"success": bool(f.TranscribeAudio(bool(usd)))}
14026
14103
  elif action == "clear_transcription":
14027
14104
  return {"success": bool(f.ClearTranscription())}
14028
14105
  elif action == "perform_audio_classification":
@@ -14067,6 +14144,9 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14067
14144
  if missing:
14068
14145
  return missing
14069
14146
  deblur = _first_param(p, "deblur_option", "deblurOption", default=None) or {}
14147
+ gate = _ai_governance_gate("remove_motion_blur", p)
14148
+ if gate:
14149
+ return gate
14070
14150
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
14071
14151
  return _issue_confirm_token(
14072
14152
  action="folder.remove_motion_blur",
@@ -14338,8 +14418,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14338
14418
  elif action == "transcribe_audio":
14339
14419
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
14340
14420
  if usd is None:
14341
- return {"success": bool(clip.TranscribeAudio())}
14342
- return {"success": bool(clip.TranscribeAudio(bool(usd)))}
14421
+ with long_resolve_op("media_pool_item.transcribe_audio"):
14422
+ return {"success": bool(clip.TranscribeAudio())}
14423
+ with long_resolve_op("media_pool_item.transcribe_audio"):
14424
+ return {"success": bool(clip.TranscribeAudio(bool(usd)))}
14343
14425
  elif action == "clear_transcription":
14344
14426
  return {"success": bool(clip.ClearTranscription())}
14345
14427
  elif action == "get_transcription":
@@ -14400,6 +14482,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14400
14482
  if missing:
14401
14483
  return missing
14402
14484
  deblur = _first_param(p, "deblur_option", "deblurOption", default=None) or {}
14485
+ gate = _ai_governance_gate("remove_motion_blur", p)
14486
+ if gate:
14487
+ return gate
14403
14488
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
14404
14489
  return _issue_confirm_token(
14405
14490
  action="media_pool_item.remove_motion_blur",
@@ -14583,8 +14668,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14583
14668
  set_caps_preset(preset, overrides?) -> {success, preset, overrides} — preset is minimal | standard | generous | unlimited. Overrides is a dict of {field: int|"unlimited"} that wins over the preset.
14584
14669
  get_usage(scope?, scope_key?, clip_id?, job_id?) -> {scope, usage} — raw usage rollup for one scope (clip | job | day).
14585
14670
  get_resolve_ai_usage(session_only?, op?, limit?) -> {summary, recent} — ledger of Resolve 21 local AI ops (audio classification, IntelliSearch, slate, motion-deblur, speech). Tracks invocations, wall-clock, and files/bytes created by the two media-creating ops. Separate from get_caps/get_usage (those meter Claude-side tokens; these ops don't spend them).
14586
- get_ai_governance() -> {tier, thresholds, usage, tiers_available} — soft governance tier (off|lenient|standard|strict) for the media-creating AI ops vs this session's render usage. Advisory; surfaced in the confirm preview + panel, never blocks.
14587
- set_ai_governance(preset, overrides?) -> {success, tier, overrides} — set the governance tier. Overrides keys: deblur_runs, speech_runs, render_bytes, render_wall_clock_ms (int or "unlimited").
14671
+ get_ai_governance() -> {tier, mode, thresholds, usage, tiers_available} — governance tier (off|lenient|standard|strict) for the media-creating AI ops vs this session's render usage. mode is advisory (default; preview warnings only) or enforce (over-tier runs are blocked).
14672
+ set_ai_governance(preset?, mode?, overrides?) -> {success, tier, mode, overrides} — set the tier, the mode (advisory|enforce), and/or overrides (deblur_runs, speech_runs, render_bytes, render_wall_clock_ms; int or "unlimited"). In enforce mode a blocked run returns GOVERNANCE_BLOCKED; pass override_governance=true on the op to consciously exceed the tier once.
14588
14673
  resolve_output_root(analysis_root?, source_paths?) -> {project_root}
14589
14674
  plan(target, depth?, analysis_root?, transcription?, vision?, dry_run?) -> {clips, artifacts}
14590
14675
  analyze_file(path|file_path, dry_run?, session_only?, persist?) -> {clips, manifest}
@@ -14767,19 +14852,32 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14767
14852
  project_root=project_root, session_id=_AI_LEDGER_SESSION_ID,
14768
14853
  preset=_ai_governance_preset(), overrides=_ai_governance_overrides(),
14769
14854
  )
14770
- return {"success": True, "session_id": _AI_LEDGER_SESSION_ID, **st}
14855
+ return {"success": True, "session_id": _AI_LEDGER_SESSION_ID, "mode": _ai_governance_mode(), **st}
14771
14856
  except Exception as exc:
14772
14857
  return _err(f"{type(exc).__name__}: {exc}")
14773
14858
  if action == "set_ai_governance":
14774
14859
  preset = (p.get("preset") or "").strip().lower()
14775
- if preset not in _resolve_ai_governance.VALID_TIERS:
14860
+ mode = (p.get("mode") or "").strip().lower()
14861
+ if not preset and not mode and not isinstance(p.get("overrides"), dict):
14862
+ return _err("set_ai_governance requires at least one of: preset, mode, overrides")
14863
+ if preset and preset not in _resolve_ai_governance.VALID_TIERS:
14776
14864
  return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}")
14865
+ if mode and mode not in _AI_GOVERNANCE_MODES:
14866
+ return _err(f"unknown mode '{mode}'. Valid: {list(_AI_GOVERNANCE_MODES)}")
14777
14867
  prefs = _read_media_analysis_preferences()
14778
- prefs["resolve_ai_governance_preset"] = preset
14868
+ if preset:
14869
+ prefs["resolve_ai_governance_preset"] = preset
14870
+ if mode:
14871
+ prefs["resolve_ai_governance_mode"] = mode
14779
14872
  if isinstance(p.get("overrides"), dict):
14780
14873
  prefs["resolve_ai_governance_overrides"] = p["overrides"]
14781
14874
  _write_media_analysis_preferences(prefs)
14782
- return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}}
14875
+ return {
14876
+ "success": True,
14877
+ "tier": prefs.get("resolve_ai_governance_preset") or _resolve_ai_governance.DEFAULT_TIER,
14878
+ "mode": prefs.get("resolve_ai_governance_mode") or "advisory",
14879
+ "overrides": prefs.get("resolve_ai_governance_overrides") or {},
14880
+ }
14783
14881
  if action == "set_caps_preset":
14784
14882
  preset = (p.get("preset") or "").strip().lower()
14785
14883
  from src.utils import analysis_caps as _ac
@@ -15808,7 +15906,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15808
15906
  elif action == "import_into_timeline":
15809
15907
  return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
15810
15908
  elif action == "export":
15811
- return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
15909
+ with long_resolve_op("timeline.export"):
15910
+ return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
15812
15911
  elif action == "get_setting":
15813
15912
  return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
15814
15913
  elif action == "set_setting":
@@ -16249,9 +16348,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16249
16348
  return err
16250
16349
 
16251
16350
  if action == "create_subtitles":
16252
- return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
16351
+ with long_resolve_op("timeline_ai.create_subtitles"):
16352
+ return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
16253
16353
  elif action == "detect_scene_cuts":
16254
- return {"success": bool(tl.DetectSceneCuts())}
16354
+ with long_resolve_op("timeline_ai.detect_scene_cuts"):
16355
+ return {"success": bool(tl.DetectSceneCuts())}
16255
16356
  elif action == "analyze_dolby_vision":
16256
16357
  clip_ids = p.get("clip_ids", [])
16257
16358
  items = []
@@ -16262,7 +16363,8 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16262
16363
  if it.GetUniqueId() in clip_ids:
16263
16364
  items.append(it)
16264
16365
  analysis_type = p.get("analysis_type")
16265
- return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
16366
+ with long_resolve_op("timeline_ai.analyze_dolby_vision"):
16367
+ return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
16266
16368
  elif action == "grab_still":
16267
16369
  still = tl.GrabStill()
16268
16370
  return _ok() if still else _err("Failed to grab still")
@@ -21334,6 +21436,7 @@ if __name__ == "__main__":
21334
21436
  del sys.argv[_i:_i + 2]
21335
21437
  if transport in ("sse", "streamable-http"):
21336
21438
  from src.utils.mcp_transport import run_networked
21439
+ actor_identity.set_instance("network-sse" if transport == "sse" else "network-http")
21337
21440
  logger.info(f"Starting DaVinci Resolve MCP Server ({transport} transport)")
21338
21441
  run_networked(mcp, transport)
21339
21442
  sys.exit(0)
@@ -0,0 +1,46 @@
1
+ """Instance-level actor identity for ledger and destructive-op records.
2
+
3
+ Design decision (2026-06-09): the supported concurrency target is a single
4
+ editor running multiple clients — a stdio server, a networked server, the
5
+ control panel, and the batch CLI may all drive one Resolve. Records therefore
6
+ identify the INSTANCE that performed an op, not a per-request client; finer
7
+ per-client fingerprints can layer on in a multi-user future without changing
8
+ this surface.
9
+
10
+ Each entry point declares itself once at startup via set_instance():
11
+
12
+ stdio — the default MCP server over stdio
13
+ network-sse — the networked server (SSE transport)
14
+ network-http — the networked server (streamable-http transport)
15
+ control-panel — the analysis dashboard process
16
+ batch-cli — the headless batch runner
17
+
18
+ actor_string() is the compact form persisted in DB columns: "<instance>:<pid>".
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from typing import Any, Dict
24
+
25
+ _instance = "stdio"
26
+
27
+ KNOWN_INSTANCES = ("stdio", "network-sse", "network-http", "control-panel", "batch-cli")
28
+
29
+
30
+ def set_instance(kind: str) -> None:
31
+ """Declare what kind of process this is. Unknown kinds are kept verbatim."""
32
+ global _instance
33
+ kind = str(kind or "").strip() or "stdio"
34
+ _instance = kind
35
+
36
+
37
+ def get_instance() -> str:
38
+ return _instance
39
+
40
+
41
+ def current_actor() -> Dict[str, Any]:
42
+ return {"instance": _instance, "pid": os.getpid()}
43
+
44
+
45
+ def actor_string() -> str:
46
+ return f"{_instance}:{os.getpid()}"
@@ -24,7 +24,7 @@ import os
24
24
  import time
25
25
  from typing import Any, Callable, Dict, List, Optional
26
26
 
27
- from src.utils import timeline_brain_db
27
+ from src.utils import actor_identity, timeline_brain_db
28
28
 
29
29
  logger = logging.getLogger("resolve-mcp.brain-edits")
30
30
 
@@ -220,14 +220,15 @@ def log_brain_edit(
220
220
  analysis_run_id, timeline_before, timeline_after, edit_type,
221
221
  tool_name, action_name, target_metric, metric_direction,
222
222
  before_value, after_value, rationale, params_json,
223
- result_summary_json, created_at, initiator
224
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
223
+ result_summary_json, created_at, initiator, actor
224
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
225
225
  """,
226
226
  (
227
227
  analysis_run_id, timeline_before, timeline_after, edit_type,
228
228
  tool_name, action_name, target_metric, metric_direction,
229
229
  before_value, after_value, rationale, params_json,
230
230
  result_json, created_at, initiator,
231
+ actor_identity.actor_string(),
231
232
  ),
232
233
  )
233
234
  row_id = cursor.lastrowid
@@ -22,7 +22,7 @@ import logging
22
22
  import time
23
23
  from typing import Any, Dict, List, Optional
24
24
 
25
- from src.utils import timeline_brain_db
25
+ from src.utils import actor_identity, timeline_brain_db
26
26
 
27
27
  logger = logging.getLogger("resolve-mcp.resolve-ai-ledger")
28
28
 
@@ -64,6 +64,7 @@ def record_op(
64
64
  output_path: Optional[str] = None,
65
65
  output_bytes: Optional[int] = None,
66
66
  error: Optional[str] = None,
67
+ actor: Optional[str] = None,
67
68
  ) -> Optional[int]:
68
69
  """Persist one ledger row. Returns the row id, or None on any failure.
69
70
 
@@ -81,8 +82,8 @@ def record_op(
81
82
  INSERT INTO resolve_ai_op_usage(
82
83
  op, op_class, clip_id, session_id, success, wall_clock_ms,
83
84
  output_path, output_bytes, extra_required, error,
84
- occurred_at, day_bucket
85
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
85
+ occurred_at, day_bucket, actor
86
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
87
  """,
87
88
  (
88
89
  op, meta["op_class"], clip_id, session_id, 1 if success else 0,
@@ -90,6 +91,7 @@ def record_op(
90
91
  int(output_bytes) if output_bytes is not None else None,
91
92
  meta["extra_required"], error,
92
93
  _iso(now), _day_bucket(now),
94
+ actor or actor_identity.actor_string(),
93
95
  ),
94
96
  )
95
97
  return cursor.lastrowid
@@ -177,7 +179,7 @@ def get_usage(
177
179
  rows = conn.execute(
178
180
  f"""
179
181
  SELECT op, op_class, clip_id, session_id, success, wall_clock_ms,
180
- output_path, output_bytes, extra_required, error, occurred_at
182
+ output_path, output_bytes, extra_required, error, occurred_at, actor
181
183
  FROM resolve_ai_op_usage{where}
182
184
  ORDER BY id DESC LIMIT ?
183
185
  """,
@@ -0,0 +1,171 @@
1
+ """Cross-process visibility for long synchronous DaVinci Resolve operations.
2
+
3
+ The Resolve scripting bridge executes one call at a time. Most calls return in
4
+ milliseconds, but a handful block for seconds-to-minutes (timeline export and
5
+ import, scene-cut detection, subtitle generation, audio transcription). A
6
+ second tool call issued during one of those — from another thread of this
7
+ server, or from the other instance when stdio and networked servers share one
8
+ Resolve — simply hangs inside fusionscript with no feedback.
9
+
10
+ This module gives those long operations a name and a presence that every
11
+ instance can see, so the Resolve preflight can wait briefly and then return a
12
+ structured "busy" answer instead of hanging:
13
+
14
+ with long_resolve_op("timeline.export"):
15
+ tl.Export(...)
16
+
17
+ op = wait_until_free(timeout_seconds=5.0) # None when free
18
+ if op:
19
+ ... return a RESOLVE_BUSY error naming op["label"] ...
20
+
21
+ Registration is advisory and best-effort: a sidecar JSON file in the temp dir
22
+ (same approach as page_lock) carries {label, pid, thread, started_at}. Records
23
+ are ignored when their pid is dead or they exceed MAX_OP_AGE_SECONDS, so a
24
+ crashed operation can never wedge the server. The registering thread is exempt
25
+ from its own gate — long operations call Resolve helpers internally.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import tempfile
32
+ import threading
33
+ import time
34
+ from contextlib import contextmanager
35
+ from typing import Any, Dict, Optional
36
+
37
+ _SIDECAR = os.path.join(tempfile.gettempdir(), "davinci_resolve_mcp_busy.json")
38
+ # A long op that has run this long is presumed crashed/leaked and ignored.
39
+ MAX_OP_AGE_SECONDS = 2 * 60 * 60
40
+ # Default time a preflight will wait for the bridge to free up before
41
+ # returning a busy error.
42
+ DEFAULT_WAIT_SECONDS = 5.0
43
+ _POLL_SECONDS = 0.25
44
+
45
+ _lock = threading.Lock()
46
+ _local_owner: Optional[int] = None # thread ident of the in-process registrant
47
+
48
+
49
+ def _pid_alive(pid: int) -> bool:
50
+ try:
51
+ os.kill(pid, 0)
52
+ return True
53
+ except ProcessLookupError:
54
+ return False
55
+ except PermissionError:
56
+ return True
57
+ except OSError:
58
+ return False
59
+
60
+
61
+ def _read_record() -> Optional[Dict[str, Any]]:
62
+ try:
63
+ with open(_SIDECAR, "r", encoding="utf-8") as handle:
64
+ record = json.load(handle)
65
+ except (OSError, json.JSONDecodeError):
66
+ return None
67
+ if not isinstance(record, dict) or not record.get("label"):
68
+ return None
69
+ started_at = record.get("started_at")
70
+ if not isinstance(started_at, (int, float)):
71
+ return None
72
+ if time.time() - started_at > MAX_OP_AGE_SECONDS:
73
+ return None
74
+ pid = record.get("pid")
75
+ if not isinstance(pid, int) or not _pid_alive(pid):
76
+ return None
77
+ return record
78
+
79
+
80
+ def _write_record(record: Dict[str, Any]) -> None:
81
+ tmp_path = f"{_SIDECAR}.tmp-{os.getpid()}-{threading.get_ident()}"
82
+ try:
83
+ with open(tmp_path, "w", encoding="utf-8") as handle:
84
+ json.dump(record, handle)
85
+ os.replace(tmp_path, _SIDECAR)
86
+ except OSError:
87
+ pass
88
+ finally:
89
+ try:
90
+ os.remove(tmp_path)
91
+ except OSError:
92
+ pass
93
+
94
+
95
+ def _clear_record() -> None:
96
+ record = None
97
+ try:
98
+ with open(_SIDECAR, "r", encoding="utf-8") as handle:
99
+ record = json.load(handle)
100
+ except (OSError, json.JSONDecodeError):
101
+ pass
102
+ # Only the owning process removes the sidecar; never clobber another
103
+ # instance's registration.
104
+ if isinstance(record, dict) and record.get("pid") == os.getpid():
105
+ try:
106
+ os.remove(_SIDECAR)
107
+ except OSError:
108
+ pass
109
+
110
+
111
+ def current_long_op() -> Optional[Dict[str, Any]]:
112
+ """The active long operation visible across instances, or None."""
113
+ record = _read_record()
114
+ if record is None:
115
+ return None
116
+ return {
117
+ "label": record.get("label"),
118
+ "pid": record.get("pid"),
119
+ "same_process": record.get("pid") == os.getpid(),
120
+ "started_at": record.get("started_at"),
121
+ "age_seconds": round(max(0.0, time.time() - float(record.get("started_at", 0.0))), 1),
122
+ }
123
+
124
+
125
+ @contextmanager
126
+ def long_resolve_op(label: str):
127
+ """Register a long synchronous Resolve call for its duration."""
128
+ global _local_owner
129
+ ident = threading.get_ident()
130
+ with _lock:
131
+ nested = _local_owner is not None
132
+ if not nested:
133
+ _local_owner = ident
134
+ if not nested:
135
+ _write_record({
136
+ "label": str(label),
137
+ "pid": os.getpid(),
138
+ "thread": ident,
139
+ "started_at": time.time(),
140
+ })
141
+ try:
142
+ yield
143
+ finally:
144
+ if not nested:
145
+ with _lock:
146
+ _local_owner = None
147
+ _clear_record()
148
+
149
+
150
+ def wait_until_free(timeout_seconds: Optional[float] = None) -> Optional[Dict[str, Any]]:
151
+ """Wait briefly for any long op to finish.
152
+
153
+ Returns None when the bridge is free (or becomes free within the timeout),
154
+ otherwise the still-running operation's info. The thread that registered
155
+ the current in-process op is never gated by it. timeout_seconds defaults
156
+ to DEFAULT_WAIT_SECONDS, read at call time so callers/tests can tune it.
157
+ """
158
+ if timeout_seconds is None:
159
+ timeout_seconds = DEFAULT_WAIT_SECONDS
160
+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
161
+ while True:
162
+ op = current_long_op()
163
+ if op is None:
164
+ return None
165
+ if op["same_process"]:
166
+ record = _read_record() or {}
167
+ if record.get("thread") == threading.get_ident():
168
+ return None
169
+ if time.monotonic() >= deadline:
170
+ return op
171
+ time.sleep(_POLL_SECONDS)
@@ -29,7 +29,7 @@ from typing import Callable, Dict, Iterator, Optional, Tuple
29
29
 
30
30
  logger = logging.getLogger("resolve-mcp.timeline-brain-db")
31
31
 
32
- SCHEMA_VERSION = 7
32
+ SCHEMA_VERSION = 8
33
33
  DB_FILENAME = "timeline_brain.sqlite"
34
34
  SOUL_DIRNAME = "_soul"
35
35
 
@@ -464,6 +464,20 @@ def _migrate_v7_resolve_ai_op_usage(conn: sqlite3.Connection) -> None:
464
464
  )
465
465
 
466
466
 
467
+ @register_migration(8)
468
+ def _migrate_v8_actor_identity(conn: sqlite3.Connection) -> None:
469
+ """Instance-level actor provenance (design decision 2026-06-09).
470
+
471
+ Stamps which process kind performed an op — "<instance>:<pid>" where
472
+ instance is stdio / network-sse / network-http / control-panel /
473
+ batch-cli. Complements `initiator` (auto vs manual) on the versioning
474
+ tables: initiator says WHY a row exists, actor says WHO wrote it.
475
+ """
476
+ for table in ("resolve_ai_op_usage", "brain_edits", "timeline_versions"):
477
+ if not _column_exists(conn, table, "actor"):
478
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN actor TEXT")
479
+
480
+
467
481
  @register_migration(5)
468
482
  def _migrate_v5_analysis_token_usage(conn: sqlite3.Connection) -> None:
469
483
  """Track real vendor token + frame upload usage so caps can enforce budgets.
@@ -32,7 +32,7 @@ import time
32
32
  import uuid
33
33
  from typing import Any, Dict, List, Optional, Tuple
34
34
 
35
- from src.utils import timeline_brain_db
35
+ from src.utils import actor_identity, timeline_brain_db
36
36
 
37
37
  logger = logging.getLogger("resolve-mcp.timeline-versioning")
38
38
 
@@ -159,8 +159,8 @@ def archive_current_timeline(
159
159
  """
160
160
  INSERT INTO timeline_versions(
161
161
  timeline_name, version, created_at, analysis_run_id,
162
- archived_timeline_name, archived_bin_path, reason, initiator
163
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
162
+ archived_timeline_name, archived_bin_path, reason, initiator, actor
163
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
164
164
  """,
165
165
  (
166
166
  working_name,
@@ -171,6 +171,7 @@ def archive_current_timeline(
171
171
  _archive_bin_path(media_pool),
172
172
  reason,
173
173
  initiator,
174
+ actor_identity.actor_string(),
174
175
  ),
175
176
  )
176
177
  row_id = cursor.lastrowid