davinci-resolve-mcp 2.24.1 → 2.26.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 +155 -0
- package/README.md +1 -1
- package/bin/davinci-resolve-mcp.mjs +14 -0
- package/docs/SKILL.md +188 -18
- package/docs/guides/color-decision-guide.md +40 -0
- package/docs/guides/editorial-decision-guide.md +29 -0
- package/docs/kernels/fusion-composition-kernel.md +30 -0
- package/docs/reference/api-coverage.md +8 -2
- package/install.py +291 -24
- package/package.json +1 -1
- package/src/analysis_dashboard.py +2535 -35
- package/src/batch_cli.py +456 -0
- package/src/granular/common.py +1 -1
- package/src/granular/media_pool_item.py +54 -4
- package/src/server.py +2640 -81
- package/src/utils/analysis_caps.py +669 -0
- package/src/utils/analysis_runs.py +302 -0
- package/src/utils/brain_edits.py +380 -0
- package/src/utils/destructive_hook.py +615 -0
- package/src/utils/failure_tracker.py +119 -0
- package/src/utils/fusion_group_settings.py +323 -0
- package/src/utils/media_analysis.py +1686 -55
- package/src/utils/media_analysis_jobs.py +3 -0
- package/src/utils/media_pool_changes.py +116 -0
- package/src/utils/timeline_brain_db.py +475 -0
- package/src/utils/timeline_versioning.py +648 -0
- package/src/utils/update_check.py +72 -6
package/install.py
CHANGED
|
@@ -21,6 +21,7 @@ import shutil
|
|
|
21
21
|
import subprocess
|
|
22
22
|
import sys
|
|
23
23
|
import textwrap
|
|
24
|
+
import time
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
|
|
26
27
|
from src.utils.update_check import (
|
|
@@ -34,7 +35,7 @@ from src.utils.update_check import (
|
|
|
34
35
|
|
|
35
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
36
37
|
|
|
37
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.26.0"
|
|
38
39
|
SUPPORTED_PYTHON_MIN = (3, 10)
|
|
39
40
|
SUPPORTED_PYTHON_MAX = (3, 12)
|
|
40
41
|
|
|
@@ -654,49 +655,315 @@ def _git_failure_message(result, fallback):
|
|
|
654
655
|
return output or fallback
|
|
655
656
|
|
|
656
657
|
|
|
657
|
-
def
|
|
658
|
-
"""
|
|
658
|
+
def _record_update_history(project_dir, entry):
|
|
659
|
+
"""Append `entry` to `<project_dir>/logs/update_history.json` (best-effort)."""
|
|
660
|
+
import json as _json
|
|
661
|
+
log_dir = os.path.join(project_dir, "logs")
|
|
662
|
+
try:
|
|
663
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
664
|
+
except OSError:
|
|
665
|
+
return
|
|
666
|
+
path = os.path.join(log_dir, "update_history.json")
|
|
667
|
+
history = {"entries": []}
|
|
668
|
+
if os.path.isfile(path):
|
|
669
|
+
try:
|
|
670
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
671
|
+
loaded = _json.load(fh)
|
|
672
|
+
if isinstance(loaded, dict) and isinstance(loaded.get("entries"), list):
|
|
673
|
+
history = loaded
|
|
674
|
+
except (OSError, ValueError):
|
|
675
|
+
pass
|
|
676
|
+
history.setdefault("entries", []).append(entry)
|
|
677
|
+
# Trim to most recent 200 entries — keep history bounded.
|
|
678
|
+
if len(history["entries"]) > 200:
|
|
679
|
+
history["entries"] = history["entries"][-200:]
|
|
680
|
+
history["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
681
|
+
tmp_path = path + ".tmp"
|
|
682
|
+
try:
|
|
683
|
+
with open(tmp_path, "w", encoding="utf-8") as fh:
|
|
684
|
+
_json.dump(history, fh, indent=2)
|
|
685
|
+
os.replace(tmp_path, path)
|
|
686
|
+
except OSError:
|
|
687
|
+
try:
|
|
688
|
+
os.remove(tmp_path)
|
|
689
|
+
except OSError:
|
|
690
|
+
pass
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _read_current_version(project_dir):
|
|
694
|
+
"""Best-effort read of VERSION constant from src/server.py."""
|
|
695
|
+
server_path = os.path.join(project_dir, "src", "server.py")
|
|
696
|
+
try:
|
|
697
|
+
with open(server_path, "r", encoding="utf-8") as fh:
|
|
698
|
+
for line in fh:
|
|
699
|
+
if line.startswith("VERSION = "):
|
|
700
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
701
|
+
except OSError:
|
|
702
|
+
pass
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _record_attempt(project_dir, *, kind, success, reason=None, message=None,
|
|
707
|
+
from_version=None, to_version=None, from_sha=None, to_sha=None,
|
|
708
|
+
initiator=None, extra=None):
|
|
709
|
+
"""Append a structured row to update_history.json. `extra` is merged in flat."""
|
|
710
|
+
entry = {
|
|
711
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
712
|
+
"kind": kind, # "update" | "rollback" | "dry_run"
|
|
713
|
+
"success": bool(success),
|
|
714
|
+
"reason": reason,
|
|
715
|
+
"message": message,
|
|
716
|
+
"from_version": from_version,
|
|
717
|
+
"to_version": to_version,
|
|
718
|
+
"from_sha": from_sha,
|
|
719
|
+
"to_sha": to_sha,
|
|
720
|
+
"initiator": initiator,
|
|
721
|
+
}
|
|
722
|
+
if isinstance(extra, dict):
|
|
723
|
+
entry.update(extra)
|
|
724
|
+
_record_update_history(project_dir, entry)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def apply_safe_self_update(project_dir, dry_run=False, *, initiator="cli",
|
|
728
|
+
strategy="refuse_on_dirty"):
|
|
729
|
+
"""Apply a guarded git fast-forward update.
|
|
730
|
+
|
|
731
|
+
`strategy` controls behavior when the working tree is dirty:
|
|
732
|
+
- `"refuse_on_dirty"` (default) — return reason="local_changes" without
|
|
733
|
+
touching anything.
|
|
734
|
+
- `"stash_if_needed"` — `git stash push`, apply the update, `git stash pop`.
|
|
735
|
+
If pop conflicts, leave the stash in place and return reason="stash_pop_conflict"
|
|
736
|
+
with the stash ref so the user can resolve.
|
|
737
|
+
|
|
738
|
+
Every attempt — success or failure — is recorded in
|
|
739
|
+
`<project_dir>/logs/update_history.json` so the dashboard can show what
|
|
740
|
+
happened and rollback can find the prior SHA.
|
|
741
|
+
"""
|
|
659
742
|
inside = _run_git(project_dir, ["rev-parse", "--is-inside-work-tree"])
|
|
660
743
|
if inside is None or isinstance(inside, subprocess.TimeoutExpired) or inside.returncode != 0:
|
|
661
|
-
|
|
744
|
+
msg = _git_failure_message(inside, "not a git checkout")
|
|
745
|
+
_record_attempt(project_dir, kind="update", success=False, reason="not_git", message=msg, initiator=initiator)
|
|
746
|
+
return {"success": False, "reason": "not_git", "message": msg}
|
|
662
747
|
|
|
663
748
|
status = _run_git(project_dir, ["status", "--porcelain"])
|
|
664
749
|
if status is None or isinstance(status, subprocess.TimeoutExpired) or status.returncode != 0:
|
|
665
|
-
|
|
750
|
+
msg = _git_failure_message(status, "could not inspect git status")
|
|
751
|
+
_record_attempt(project_dir, kind="update", success=False, reason="status_failed", message=msg, initiator=initiator)
|
|
752
|
+
return {"success": False, "reason": "status_failed", "message": msg}
|
|
753
|
+
|
|
754
|
+
stash_ref = None
|
|
666
755
|
if status.stdout.strip():
|
|
667
|
-
|
|
668
|
-
"
|
|
669
|
-
"
|
|
670
|
-
"
|
|
671
|
-
|
|
756
|
+
if strategy != "stash_if_needed":
|
|
757
|
+
msg = "local changes are present; continuing with the current build"
|
|
758
|
+
_record_attempt(project_dir, kind="update", success=False, reason="local_changes", message=msg, initiator=initiator)
|
|
759
|
+
return {"success": False, "reason": "local_changes", "message": msg}
|
|
760
|
+
# Auto-stash path: push the working tree onto the stash, remember the
|
|
761
|
+
# ref so we can pop (or surface) it after the update.
|
|
762
|
+
stash_msg = f"mcp-update-autostash-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}"
|
|
763
|
+
stash = _run_git(project_dir, ["stash", "push", "-u", "-m", stash_msg], timeout=30)
|
|
764
|
+
if stash is None or stash.returncode != 0 or "No local changes to save" in (stash.stdout or ""):
|
|
765
|
+
# If we got here `status` was dirty, so a stash failure means trouble.
|
|
766
|
+
msg = _git_failure_message(stash, "git stash push failed")
|
|
767
|
+
_record_attempt(project_dir, kind="update", success=False, reason="stash_failed", message=msg, initiator=initiator)
|
|
768
|
+
return {"success": False, "reason": "stash_failed", "message": msg}
|
|
769
|
+
stash_ref = stash_msg
|
|
672
770
|
|
|
673
771
|
upstream = _run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
|
674
772
|
if upstream is None or isinstance(upstream, subprocess.TimeoutExpired) or upstream.returncode != 0:
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
773
|
+
msg = _git_failure_message(upstream, "current branch has no configured upstream")
|
|
774
|
+
_record_attempt(project_dir, kind="update", success=False, reason="no_upstream", message=msg, initiator=initiator)
|
|
775
|
+
return {"success": False, "reason": "no_upstream", "message": msg}
|
|
776
|
+
|
|
777
|
+
# Capture pre-update SHA + version so rollback knows where to revert to.
|
|
778
|
+
head_before = _run_git(project_dir, ["rev-parse", "HEAD"])
|
|
779
|
+
from_sha = head_before.stdout.strip() if head_before and head_before.returncode == 0 else None
|
|
780
|
+
from_version = _read_current_version(project_dir)
|
|
680
781
|
|
|
681
782
|
if dry_run:
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
"message": f"would fast-forward from {upstream.stdout.strip()}",
|
|
687
|
-
}
|
|
783
|
+
msg = f"would fast-forward from {upstream.stdout.strip()}"
|
|
784
|
+
_record_attempt(project_dir, kind="dry_run", success=True, message=msg,
|
|
785
|
+
from_version=from_version, from_sha=from_sha, initiator=initiator)
|
|
786
|
+
return {"success": True, "changed": False, "dry_run": True, "message": msg}
|
|
688
787
|
|
|
689
788
|
fetch = _run_git(project_dir, ["fetch", "--tags", "--prune"], timeout=90)
|
|
690
789
|
if fetch is None or isinstance(fetch, subprocess.TimeoutExpired) or fetch.returncode != 0:
|
|
691
|
-
|
|
790
|
+
msg = _git_failure_message(fetch, "git fetch failed")
|
|
791
|
+
_record_attempt(project_dir, kind="update", success=False, reason="fetch_failed", message=msg,
|
|
792
|
+
from_version=from_version, from_sha=from_sha, initiator=initiator)
|
|
793
|
+
return {"success": False, "reason": "fetch_failed", "message": msg}
|
|
692
794
|
|
|
693
795
|
pull = _run_git(project_dir, ["pull", "--ff-only"], timeout=120)
|
|
694
796
|
if pull is None or isinstance(pull, subprocess.TimeoutExpired) or pull.returncode != 0:
|
|
695
|
-
|
|
797
|
+
msg = _git_failure_message(pull, "git pull --ff-only failed")
|
|
798
|
+
_record_attempt(project_dir, kind="update", success=False, reason="pull_failed", message=msg,
|
|
799
|
+
from_version=from_version, from_sha=from_sha, initiator=initiator)
|
|
800
|
+
return {"success": False, "reason": "pull_failed", "message": msg}
|
|
801
|
+
|
|
802
|
+
head_after = _run_git(project_dir, ["rev-parse", "HEAD"])
|
|
803
|
+
to_sha = head_after.stdout.strip() if head_after and head_after.returncode == 0 else None
|
|
804
|
+
to_version = _read_current_version(project_dir)
|
|
696
805
|
|
|
697
806
|
output = "\n".join(part.strip() for part in (pull.stdout, pull.stderr) if part and part.strip())
|
|
698
807
|
changed = "Already up to date." not in output
|
|
699
|
-
|
|
808
|
+
|
|
809
|
+
# Integrity verification: confirm our local HEAD matches what GitHub said
|
|
810
|
+
# was the target SHA. Mismatch could mean: (a) user pushed to a fork mid-
|
|
811
|
+
# update, (b) the release we resolved was different from the branch tip
|
|
812
|
+
# (force-pushed), (c) corruption. We don't roll back automatically — the
|
|
813
|
+
# user may have intentional reasons — but we log it loudly.
|
|
814
|
+
integrity = {"verified": None, "expected_sha": None, "actual_sha": to_sha}
|
|
815
|
+
try:
|
|
816
|
+
from src.utils.update_check import check_for_updates as _cfu # type: ignore
|
|
817
|
+
info = _cfu(from_version or "0.0.0", project_dir, force=False)
|
|
818
|
+
expected = info.get("release_target_sha")
|
|
819
|
+
if expected:
|
|
820
|
+
integrity["expected_sha"] = expected
|
|
821
|
+
# Compare prefixes (release SHA may be a tag name or short SHA).
|
|
822
|
+
ok = bool(to_sha) and (
|
|
823
|
+
expected == to_sha
|
|
824
|
+
or (len(expected) >= 7 and to_sha.startswith(expected[:7]))
|
|
825
|
+
or (len(to_sha) >= 7 and expected.startswith(to_sha[:7]))
|
|
826
|
+
)
|
|
827
|
+
integrity["verified"] = ok
|
|
828
|
+
except Exception as exc:
|
|
829
|
+
integrity["error"] = f"{type(exc).__name__}: {exc}"
|
|
830
|
+
|
|
831
|
+
# If we stashed changes earlier, try to reapply them now.
|
|
832
|
+
stash_pop_conflict = False
|
|
833
|
+
if stash_ref:
|
|
834
|
+
pop = _run_git(project_dir, ["stash", "pop"], timeout=60)
|
|
835
|
+
if pop is None or pop.returncode != 0:
|
|
836
|
+
stash_pop_conflict = True
|
|
837
|
+
|
|
838
|
+
result = {
|
|
839
|
+
"success": True, "changed": changed,
|
|
840
|
+
"message": output or "update complete",
|
|
841
|
+
"from_version": from_version, "to_version": to_version,
|
|
842
|
+
"from_sha": from_sha, "to_sha": to_sha,
|
|
843
|
+
"stash_ref": stash_ref,
|
|
844
|
+
"stash_pop_conflict": stash_pop_conflict,
|
|
845
|
+
"integrity": integrity,
|
|
846
|
+
}
|
|
847
|
+
if stash_pop_conflict:
|
|
848
|
+
# Update applied successfully but the stash pop hit a conflict; the
|
|
849
|
+
# user's changes are still in the stash. Don't fail the overall update,
|
|
850
|
+
# but surface the conflict prominently.
|
|
851
|
+
result["reason"] = "stash_pop_conflict"
|
|
852
|
+
result["remediation"] = (
|
|
853
|
+
f"Update applied, but your stashed changes ({stash_ref}) conflict with the new build. "
|
|
854
|
+
"Resolve via `git stash list` + `git stash pop` after restarting; "
|
|
855
|
+
"use `git stash drop` if you want to discard them."
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
_record_attempt(project_dir, kind="update", success=True,
|
|
859
|
+
message=output or "update complete",
|
|
860
|
+
from_version=from_version, to_version=to_version,
|
|
861
|
+
from_sha=from_sha, to_sha=to_sha, initiator=initiator,
|
|
862
|
+
extra={"stash_ref": stash_ref,
|
|
863
|
+
"stash_pop_conflict": stash_pop_conflict,
|
|
864
|
+
"integrity": integrity})
|
|
865
|
+
|
|
866
|
+
return result
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def rollback_to_previous_build(project_dir, *, initiator="cli"):
|
|
870
|
+
"""git reset --hard to the from_sha of the most recent successful update.
|
|
871
|
+
|
|
872
|
+
Refuses if local changes exist (same guard as apply_safe_self_update) so we
|
|
873
|
+
never silently lose user work. Records a rollback row in update_history.
|
|
874
|
+
"""
|
|
875
|
+
import json as _json
|
|
876
|
+
history_path = os.path.join(project_dir, "logs", "update_history.json")
|
|
877
|
+
if not os.path.isfile(history_path):
|
|
878
|
+
return {"success": False, "reason": "no_history", "message": "no update_history.json found"}
|
|
879
|
+
try:
|
|
880
|
+
with open(history_path, "r", encoding="utf-8") as fh:
|
|
881
|
+
history = _json.load(fh)
|
|
882
|
+
except (OSError, _json.JSONDecodeError) as exc:
|
|
883
|
+
return {"success": False, "reason": "history_read_failed", "message": str(exc)}
|
|
884
|
+
|
|
885
|
+
# Newest first; find the latest successful "update" entry with a from_sha.
|
|
886
|
+
candidates = [e for e in reversed(history.get("entries") or [])
|
|
887
|
+
if e.get("kind") == "update" and e.get("success") and e.get("from_sha")]
|
|
888
|
+
if not candidates:
|
|
889
|
+
return {"success": False, "reason": "no_target", "message": "no prior successful update to roll back to"}
|
|
890
|
+
target = candidates[0]
|
|
891
|
+
from_sha = target["from_sha"]
|
|
892
|
+
|
|
893
|
+
status = _run_git(project_dir, ["status", "--porcelain"])
|
|
894
|
+
if status is None or status.returncode != 0:
|
|
895
|
+
return {"success": False, "reason": "status_failed", "message": "could not inspect git status"}
|
|
896
|
+
if status.stdout.strip():
|
|
897
|
+
return {"success": False, "reason": "local_changes",
|
|
898
|
+
"message": "local changes are present; commit or stash before rolling back"}
|
|
899
|
+
|
|
900
|
+
head_before = _run_git(project_dir, ["rev-parse", "HEAD"])
|
|
901
|
+
pre_rollback_sha = head_before.stdout.strip() if head_before and head_before.returncode == 0 else None
|
|
902
|
+
pre_rollback_version = _read_current_version(project_dir)
|
|
903
|
+
|
|
904
|
+
reset = _run_git(project_dir, ["reset", "--hard", from_sha], timeout=60)
|
|
905
|
+
if reset is None or reset.returncode != 0:
|
|
906
|
+
msg = _git_failure_message(reset, f"git reset --hard {from_sha} failed")
|
|
907
|
+
_record_attempt(project_dir, kind="rollback", success=False, reason="reset_failed", message=msg,
|
|
908
|
+
from_version=pre_rollback_version, from_sha=pre_rollback_sha,
|
|
909
|
+
to_sha=from_sha, initiator=initiator)
|
|
910
|
+
return {"success": False, "reason": "reset_failed", "message": msg}
|
|
911
|
+
|
|
912
|
+
to_version = _read_current_version(project_dir)
|
|
913
|
+
_record_attempt(project_dir, kind="rollback", success=True,
|
|
914
|
+
message=f"rolled back to {from_sha[:10]}",
|
|
915
|
+
from_version=pre_rollback_version, to_version=to_version,
|
|
916
|
+
from_sha=pre_rollback_sha, to_sha=from_sha, initiator=initiator)
|
|
917
|
+
return {"success": True, "changed": pre_rollback_sha != from_sha,
|
|
918
|
+
"message": f"rolled back to {from_sha[:10]}",
|
|
919
|
+
"from_version": pre_rollback_version, "to_version": to_version,
|
|
920
|
+
"from_sha": pre_rollback_sha, "to_sha": from_sha}
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def preview_update(project_dir):
|
|
924
|
+
"""Fetch the target release metadata + scan for breaking-change markers.
|
|
925
|
+
|
|
926
|
+
Returns a dict the UI can render before the user confirms the update:
|
|
927
|
+
{success, latest_version, release_notes, breaking_changes, prerelease,
|
|
928
|
+
channel, current_version, target_sha}
|
|
929
|
+
"""
|
|
930
|
+
try:
|
|
931
|
+
from src.utils.update_check import check_for_updates # type: ignore
|
|
932
|
+
except Exception as exc:
|
|
933
|
+
return {"success": False, "error": f"update_check unavailable: {exc}"}
|
|
934
|
+
current = _read_current_version(project_dir) or "0.0.0"
|
|
935
|
+
try:
|
|
936
|
+
result = check_for_updates(current, project_dir, force=True)
|
|
937
|
+
except Exception as exc:
|
|
938
|
+
return {"success": False, "error": str(exc)}
|
|
939
|
+
return {
|
|
940
|
+
"success": True,
|
|
941
|
+
"current_version": current,
|
|
942
|
+
"latest_version": result.get("latest_version"),
|
|
943
|
+
"release_notes": result.get("release_notes") or "",
|
|
944
|
+
"breaking_changes": result.get("release_notes_breaking") or [],
|
|
945
|
+
"prerelease": bool(result.get("prerelease")),
|
|
946
|
+
"channel": result.get("channel"),
|
|
947
|
+
"target_sha": result.get("release_target_sha"),
|
|
948
|
+
"release_url": result.get("release_url"),
|
|
949
|
+
"status": result.get("status"),
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def read_update_history(project_dir, limit=20):
|
|
954
|
+
"""Most-recent-first list of recorded update attempts. For the dashboard."""
|
|
955
|
+
import json as _json
|
|
956
|
+
path = os.path.join(project_dir, "logs", "update_history.json")
|
|
957
|
+
if not os.path.isfile(path):
|
|
958
|
+
return {"success": True, "entries": []}
|
|
959
|
+
try:
|
|
960
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
961
|
+
history = _json.load(fh)
|
|
962
|
+
except (OSError, _json.JSONDecodeError) as exc:
|
|
963
|
+
return {"success": False, "error": str(exc), "entries": []}
|
|
964
|
+
entries = (history.get("entries") or [])[-int(limit):]
|
|
965
|
+
entries.reverse()
|
|
966
|
+
return {"success": True, "entries": entries}
|
|
700
967
|
|
|
701
968
|
|
|
702
969
|
def _restart_installer():
|