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 +37 -0
- package/README.md +1 -1
- package/docs/SKILL.md +7 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +2 -0
- package/src/batch_cli.py +2 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +124 -21
- package/src/utils/actor_identity.py +46 -0
- package/src/utils/brain_edits.py +4 -3
- package/src/utils/resolve_ai_ledger.py +6 -4
- package/src/utils/resolve_busy.py +171 -0
- package/src/utils/timeline_brain_db.py +15 -1
- package/src/utils/timeline_versioning.py +4 -3
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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={...})
|
|
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.
|
|
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
|
@@ -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"):
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14025
|
-
|
|
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
|
-
|
|
14342
|
-
|
|
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} —
|
|
14587
|
-
set_ai_governance(preset
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()}"
|
package/src/utils/brain_edits.py
CHANGED
|
@@ -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 =
|
|
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
|