davinci-resolve-mcp 2.54.3 → 2.54.4
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 +26 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +14 -8
- package/src/granular/common.py +1 -1
- package/src/server.py +126 -20
- package/src/utils/analysis_memory.py +10 -1
- package/src/utils/fusion_group_settings.py +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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.54.4
|
|
6
|
+
|
|
7
|
+
Persistence-safety hardening (generalizes issue #71 to the analysis state
|
|
8
|
+
stores). A multi-agent audit found several state files whose **readers** swallow a
|
|
9
|
+
`JSONDecodeError` and reset to an empty default — so a later read-modify-write
|
|
10
|
+
writes back only the new field, silently wiping prior data. Atomic writes don't
|
|
11
|
+
help, because the loss happens at *read* time.
|
|
12
|
+
|
|
13
|
+
- **Fixed** read-modify-write paths now read strictly and **refuse to overwrite**
|
|
14
|
+
a corrupt existing file (via a shared `ConfigParseError` + `_read_json_strict`),
|
|
15
|
+
rather than clobbering it:
|
|
16
|
+
- media-analysis **preferences** — 5 write paths (set-defaults, timed-marker
|
|
17
|
+
default, sampling-mode default, AI governance, caps preset)
|
|
18
|
+
- **update-state** — configure/clear update settings (3 paths), now also written
|
|
19
|
+
atomically (temp + `os.replace`)
|
|
20
|
+
- per-clip **corrections** (human edit history — the highest-value store) —
|
|
21
|
+
update and revert paths
|
|
22
|
+
- **Fixed** non-atomic writes that a crash mid-write could truncate: the bin
|
|
23
|
+
summary markdown and Fusion `.setting` files now write to a temp file and
|
|
24
|
+
`os.replace`.
|
|
25
|
+
- **Fixed** a read-modify-write race on `analysis.json` under the threaded control
|
|
26
|
+
panel: transcript regeneration is now serialized under the existing state lock,
|
|
27
|
+
so a concurrent regen/batch write can't drop the other's updates.
|
|
28
|
+
- Read-only callers keep their forgiving behavior (they fall back to defaults and
|
|
29
|
+
never write). New tests in `tests/test_persistence_safety.py`.
|
|
30
|
+
|
|
5
31
|
## What's New in v2.54.3
|
|
6
32
|
|
|
7
33
|
Follow-up to the v2.54.2 config-merge fix (#71): the JSONC sanitizer's
|
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/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.54.
|
|
38
|
+
VERSION = "2.54.4"
|
|
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
|
@@ -15176,14 +15176,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
15176
15176
|
self._json({"success": False, "error": "Transcript regeneration is loopback-only."}, HTTPStatus.FORBIDDEN)
|
|
15177
15177
|
return
|
|
15178
15178
|
clip_id = path.split("/")[3]
|
|
15179
|
-
|
|
15180
|
-
|
|
15181
|
-
|
|
15182
|
-
|
|
15183
|
-
|
|
15184
|
-
|
|
15185
|
-
|
|
15186
|
-
|
|
15179
|
+
# Serialize the analysis.json read-modify-write: this server is
|
|
15180
|
+
# threaded, so concurrent regenerations (or a regen racing a batch
|
|
15181
|
+
# job's report write) would interleave and the last writer would drop
|
|
15182
|
+
# the other's updates (PS9).
|
|
15183
|
+
with self.state.lock:
|
|
15184
|
+
result = regenerate_clip_transcript(
|
|
15185
|
+
self.state.project_root,
|
|
15186
|
+
clip_id,
|
|
15187
|
+
with_words=bool(body.get("with_words", True)),
|
|
15188
|
+
backend=body.get("backend") or None,
|
|
15189
|
+
language=body.get("language") or None,
|
|
15190
|
+
model=body.get("model") or None,
|
|
15191
|
+
)
|
|
15192
|
+
self._json(result)
|
|
15187
15193
|
return
|
|
15188
15194
|
if path.startswith("/api/clips/") and path.endswith("/transcript/corrections"):
|
|
15189
15195
|
if not _request_is_loopback(self):
|
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.54.
|
|
83
|
+
VERSION = "2.54.4"
|
|
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.54.
|
|
14
|
+
VERSION = "2.54.4"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -7273,6 +7273,39 @@ def _media_analysis_preferences_path() -> str:
|
|
|
7273
7273
|
return os.path.join(project_dir, "logs", "media-analysis-preferences.json")
|
|
7274
7274
|
|
|
7275
7275
|
|
|
7276
|
+
class ConfigParseError(Exception):
|
|
7277
|
+
"""A persisted state/config file exists but its contents could not be read.
|
|
7278
|
+
|
|
7279
|
+
Read-modify-write callers MUST refuse to overwrite when this is raised — a
|
|
7280
|
+
transient/corrupt read returning a default, followed by a write, silently
|
|
7281
|
+
wipes the user's prior data. This is the issue-#71 bug class generalized to
|
|
7282
|
+
the analysis state stores (see local gameplan Phase 5 / PS1–PS3).
|
|
7283
|
+
"""
|
|
7284
|
+
|
|
7285
|
+
|
|
7286
|
+
def _read_json_strict(path: str) -> Dict[str, Any]:
|
|
7287
|
+
"""Read JSON for a read-modify-write cycle.
|
|
7288
|
+
|
|
7289
|
+
Returns ``{}`` only when the file is absent or empty (safe to create). Raises
|
|
7290
|
+
:class:`ConfigParseError` when the file exists but cannot be read/parsed, so
|
|
7291
|
+
the caller can refuse to overwrite and avoid clobbering prior contents.
|
|
7292
|
+
"""
|
|
7293
|
+
try:
|
|
7294
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
7295
|
+
raw = handle.read()
|
|
7296
|
+
except FileNotFoundError:
|
|
7297
|
+
return {}
|
|
7298
|
+
except OSError as exc:
|
|
7299
|
+
raise ConfigParseError(f"{path}: {exc}") from exc
|
|
7300
|
+
if not raw.strip():
|
|
7301
|
+
return {}
|
|
7302
|
+
try:
|
|
7303
|
+
payload = json.loads(raw)
|
|
7304
|
+
except json.JSONDecodeError as exc:
|
|
7305
|
+
raise ConfigParseError(f"{path}: {exc}") from exc
|
|
7306
|
+
return payload if isinstance(payload, dict) else {}
|
|
7307
|
+
|
|
7308
|
+
|
|
7276
7309
|
def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
7277
7310
|
path = _media_analysis_preferences_path()
|
|
7278
7311
|
try:
|
|
@@ -7283,6 +7316,12 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
|
7283
7316
|
return {}
|
|
7284
7317
|
|
|
7285
7318
|
|
|
7319
|
+
def _read_media_analysis_preferences_strict() -> Dict[str, Any]:
|
|
7320
|
+
"""Strict prefs read for read-modify-write paths — raises ConfigParseError on
|
|
7321
|
+
a corrupt existing file so the caller refuses to overwrite (PS1)."""
|
|
7322
|
+
return _read_json_strict(_media_analysis_preferences_path())
|
|
7323
|
+
|
|
7324
|
+
|
|
7286
7325
|
def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
|
|
7287
7326
|
# Atomic replace: a crash mid-write must not truncate the file, because the
|
|
7288
7327
|
# reader resets to {} on corruption and the next save would then wipe every
|
|
@@ -7669,6 +7708,12 @@ def _media_analysis_timed_marker_decision(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
7669
7708
|
|
|
7670
7709
|
if choice in {"default_yes", "default_no"}:
|
|
7671
7710
|
saved = "yes" if choice == "default_yes" else "no"
|
|
7711
|
+
# Strict re-read before writing: a corrupt prefs file must refuse, not
|
|
7712
|
+
# clobber every saved preference (PS1).
|
|
7713
|
+
try:
|
|
7714
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
7715
|
+
except ConfigParseError as exc:
|
|
7716
|
+
return _err(f"Refusing to save timed-marker default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
7672
7717
|
preferences["timed_markers_default"] = saved
|
|
7673
7718
|
preferences["timed_markers_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
7674
7719
|
_write_media_analysis_preferences(preferences)
|
|
@@ -7833,6 +7878,10 @@ def _media_analysis_sampling_mode_decision(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
7833
7878
|
source = "explicit"
|
|
7834
7879
|
if save_default:
|
|
7835
7880
|
saved = mode
|
|
7881
|
+
try:
|
|
7882
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
7883
|
+
except ConfigParseError as exc:
|
|
7884
|
+
return _err(f"Refusing to save sampling-mode default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
7836
7885
|
preferences["sampling_mode_default"] = mode
|
|
7837
7886
|
preferences["sampling_mode_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
7838
7887
|
_write_media_analysis_preferences(preferences)
|
|
@@ -11040,6 +11089,31 @@ def _setup_update_state() -> Dict[str, Any]:
|
|
|
11040
11089
|
return {}
|
|
11041
11090
|
|
|
11042
11091
|
|
|
11092
|
+
def _setup_update_state_strict() -> Dict[str, Any]:
|
|
11093
|
+
"""Strict update-state read for read-modify-write paths — raises
|
|
11094
|
+
ConfigParseError on a corrupt existing file so the caller refuses to
|
|
11095
|
+
overwrite and wipe saved update prefs (PS2)."""
|
|
11096
|
+
return _read_json_strict(str(update_state_path(project_dir)))
|
|
11097
|
+
|
|
11098
|
+
|
|
11099
|
+
def _write_setup_update_state(state: Dict[str, Any]) -> None:
|
|
11100
|
+
"""Atomically persist update state (temp + os.replace), so a crash mid-write
|
|
11101
|
+
can't truncate the file that _setup_update_state then resets to {} (PS2)."""
|
|
11102
|
+
path = str(update_state_path(project_dir))
|
|
11103
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
11104
|
+
tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
|
|
11105
|
+
try:
|
|
11106
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
11107
|
+
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11108
|
+
handle.write("\n")
|
|
11109
|
+
os.replace(tmp_path, path)
|
|
11110
|
+
finally:
|
|
11111
|
+
try:
|
|
11112
|
+
os.remove(tmp_path)
|
|
11113
|
+
except OSError:
|
|
11114
|
+
pass
|
|
11115
|
+
|
|
11116
|
+
|
|
11043
11117
|
def _setup_update_env() -> Dict[str, str]:
|
|
11044
11118
|
env = dict(os.environ)
|
|
11045
11119
|
state = _setup_update_state()
|
|
@@ -11263,7 +11337,13 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
|
|
|
11263
11337
|
if not requested:
|
|
11264
11338
|
return {"changed": False, "recognized": False}
|
|
11265
11339
|
|
|
11266
|
-
|
|
11340
|
+
# Strict read: this is a read-modify-write of the prefs file, so a corrupt
|
|
11341
|
+
# existing file must refuse rather than seed the write from {} and wipe every
|
|
11342
|
+
# saved preference (PS1).
|
|
11343
|
+
try:
|
|
11344
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
11345
|
+
except ConfigParseError as exc:
|
|
11346
|
+
return _err(f"Refusing to update media-analysis defaults: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
11267
11347
|
before = _media_analysis_effective_preferences()
|
|
11268
11348
|
next_preferences = dict(preferences)
|
|
11269
11349
|
updates: Dict[str, Dict[str, Any]] = {}
|
|
@@ -11519,9 +11599,18 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
|
|
|
11519
11599
|
if dry_run:
|
|
11520
11600
|
return {"changed": True, "recognized": True, "updates": updates, "before": before, "dry_run": True}
|
|
11521
11601
|
|
|
11602
|
+
# Strict re-read before mutating: a corrupt update-state file must refuse,
|
|
11603
|
+
# not seed the write from {} and wipe saved update prefs (PS2).
|
|
11604
|
+
try:
|
|
11605
|
+
state = _setup_update_state_strict()
|
|
11606
|
+
except ConfigParseError as exc:
|
|
11607
|
+
return _err(f"Refusing to update settings: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11522
11608
|
if "mode" in updates:
|
|
11523
11609
|
set_update_mode(project_dir, updates["mode"]["after"], env=_setup_update_env())
|
|
11524
|
-
|
|
11610
|
+
try:
|
|
11611
|
+
state = _setup_update_state_strict()
|
|
11612
|
+
except ConfigParseError as exc:
|
|
11613
|
+
return _err(f"Refusing to update settings: {exc}. The update-state file became unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11525
11614
|
updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
11526
11615
|
if "check_interval_hours" in updates:
|
|
11527
11616
|
state["check_interval_hours"] = updates["check_interval_hours"]["after"]
|
|
@@ -11529,9 +11618,7 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
|
|
|
11529
11618
|
state["snooze_hours"] = updates["snooze_hours"]["after"]
|
|
11530
11619
|
if updates:
|
|
11531
11620
|
state["setup_defaults_updated_at"] = updated_at
|
|
11532
|
-
|
|
11533
|
-
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11534
|
-
handle.write("\n")
|
|
11621
|
+
_write_setup_update_state(state)
|
|
11535
11622
|
return {
|
|
11536
11623
|
"changed": bool(updates),
|
|
11537
11624
|
"recognized": True,
|
|
@@ -11602,26 +11689,28 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
|
|
|
11602
11689
|
result["cleared"].append("updates.mode")
|
|
11603
11690
|
|
|
11604
11691
|
if clear_all or normalized_keys & {"updates.check_interval_hours", "check_interval_hours", "update_interval_hours"}:
|
|
11605
|
-
state = _setup_update_state()
|
|
11606
11692
|
if dry_run:
|
|
11607
11693
|
result["update_check_interval_hours"] = {"changed": True, "dry_run": True}
|
|
11608
11694
|
else:
|
|
11695
|
+
try:
|
|
11696
|
+
state = _setup_update_state_strict()
|
|
11697
|
+
except ConfigParseError as exc:
|
|
11698
|
+
return _err(f"Refusing to clear update interval: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11609
11699
|
state.pop("check_interval_hours", None)
|
|
11610
|
-
|
|
11611
|
-
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11612
|
-
handle.write("\n")
|
|
11700
|
+
_write_setup_update_state(state)
|
|
11613
11701
|
result["update_check_interval_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
|
|
11614
11702
|
result["cleared"].append("updates.check_interval_hours")
|
|
11615
11703
|
|
|
11616
11704
|
if clear_all or normalized_keys & {"updates.snooze_hours", "snooze_hours", "update_snooze_hours"}:
|
|
11617
|
-
state = _setup_update_state()
|
|
11618
11705
|
if dry_run:
|
|
11619
11706
|
result["update_snooze_hours"] = {"changed": True, "dry_run": True}
|
|
11620
11707
|
else:
|
|
11708
|
+
try:
|
|
11709
|
+
state = _setup_update_state_strict()
|
|
11710
|
+
except ConfigParseError as exc:
|
|
11711
|
+
return _err(f"Refusing to clear snooze: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11621
11712
|
state.pop("snooze_hours", None)
|
|
11622
|
-
|
|
11623
|
-
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11624
|
-
handle.write("\n")
|
|
11713
|
+
_write_setup_update_state(state)
|
|
11625
11714
|
result["update_snooze_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
|
|
11626
11715
|
result["cleared"].append("updates.snooze_hours")
|
|
11627
11716
|
|
|
@@ -11988,7 +12077,7 @@ def _v2_corrections_path_for_clip(project_root: str, clip_dir: Optional[str], cl
|
|
|
11988
12077
|
return None
|
|
11989
12078
|
|
|
11990
12079
|
|
|
11991
|
-
def _v2_read_corrections(path: str) -> Dict[str, Any]:
|
|
12080
|
+
def _v2_read_corrections(path: str, *, strict: bool = False) -> Dict[str, Any]:
|
|
11992
12081
|
if not os.path.isfile(path):
|
|
11993
12082
|
return {"schema_version": "2.0", "current": {}, "changelog": []}
|
|
11994
12083
|
try:
|
|
@@ -12001,7 +12090,12 @@ def _v2_read_corrections(path: str) -> Dict[str, Any]:
|
|
|
12001
12090
|
data.setdefault("current", {})
|
|
12002
12091
|
data.setdefault("changelog", [])
|
|
12003
12092
|
return data
|
|
12004
|
-
except (OSError, json.JSONDecodeError):
|
|
12093
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
12094
|
+
# strict=True is for read-modify-write callers: an existing-but-corrupt
|
|
12095
|
+
# corrections file must refuse, not reset to empty and then erase the
|
|
12096
|
+
# user's human edit history on the next write (PS3, issue #71 class).
|
|
12097
|
+
if strict:
|
|
12098
|
+
raise ConfigParseError(f"{path}: {exc}") from exc
|
|
12005
12099
|
return {"schema_version": "2.0", "current": {}, "changelog": []}
|
|
12006
12100
|
|
|
12007
12101
|
|
|
@@ -12049,7 +12143,10 @@ def _v2_update_field(project_root: str, p: Dict[str, Any], *, entity_type: str)
|
|
|
12049
12143
|
if not path:
|
|
12050
12144
|
return _err(f"Could not locate clip directory for clip_id={clip_id} under {project_root}/clips/. Pass clip_dir explicitly if the clip directory is non-standard.")
|
|
12051
12145
|
|
|
12052
|
-
|
|
12146
|
+
try:
|
|
12147
|
+
data = _v2_read_corrections(path, strict=True)
|
|
12148
|
+
except ConfigParseError as exc:
|
|
12149
|
+
return _err(f"Refusing to write correction: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
|
|
12053
12150
|
if clip_id and "clip_id" not in data:
|
|
12054
12151
|
data["clip_id"] = str(clip_id)
|
|
12055
12152
|
|
|
@@ -12149,7 +12246,10 @@ def _v2_revert_field(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
12149
12246
|
path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id)
|
|
12150
12247
|
if not path or not os.path.isfile(path):
|
|
12151
12248
|
return _err("No corrections file exists for this clip; nothing to revert.")
|
|
12152
|
-
|
|
12249
|
+
try:
|
|
12250
|
+
data = _v2_read_corrections(path, strict=True)
|
|
12251
|
+
except ConfigParseError as exc:
|
|
12252
|
+
return _err(f"Refusing to revert: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
|
|
12153
12253
|
key = f"{entity_type}:{entity_uuid}:{field_path}"
|
|
12154
12254
|
if key not in data.get("current", {}):
|
|
12155
12255
|
return _err(f"No current correction for {key}; nothing to revert.")
|
|
@@ -15896,7 +15996,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15896
15996
|
return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}")
|
|
15897
15997
|
if mode and mode not in _AI_GOVERNANCE_MODES:
|
|
15898
15998
|
return _err(f"unknown mode '{mode}'. Valid: {list(_AI_GOVERNANCE_MODES)}")
|
|
15899
|
-
|
|
15999
|
+
try:
|
|
16000
|
+
prefs = _read_media_analysis_preferences_strict()
|
|
16001
|
+
except ConfigParseError as exc:
|
|
16002
|
+
return _err(f"Refusing to save AI governance: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
15900
16003
|
if preset:
|
|
15901
16004
|
prefs["resolve_ai_governance_preset"] = preset
|
|
15902
16005
|
if mode:
|
|
@@ -15916,7 +16019,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15916
16019
|
if preset not in _ac.VALID_PRESETS:
|
|
15917
16020
|
return _err(f"unknown preset '{preset}'. Valid: {sorted(_ac.VALID_PRESETS)}")
|
|
15918
16021
|
# Update preference file directly (same pattern as set_defaults).
|
|
15919
|
-
|
|
16022
|
+
try:
|
|
16023
|
+
prefs = _read_media_analysis_preferences_strict()
|
|
16024
|
+
except ConfigParseError as exc:
|
|
16025
|
+
return _err(f"Refusing to save caps preset: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
15920
16026
|
prefs["analysis_caps_preset"] = preset
|
|
15921
16027
|
if isinstance(p.get("overrides"), dict):
|
|
15922
16028
|
prefs["analysis_caps_overrides"] = p["overrides"]
|
|
@@ -358,9 +358,13 @@ def regenerate_bin_summary_from_manifest(
|
|
|
358
358
|
lines.append("\n")
|
|
359
359
|
|
|
360
360
|
path = bin_summary_path(project_root)
|
|
361
|
+
# Atomic write (temp + os.replace): a crash mid-write must not truncate the
|
|
362
|
+
# bin summary that session_start_context reads at startup (PS4).
|
|
363
|
+
tmp_path = path + ".tmp"
|
|
361
364
|
try:
|
|
362
|
-
with open(
|
|
365
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
363
366
|
handle.writelines(lines)
|
|
367
|
+
os.replace(tmp_path, path)
|
|
364
368
|
return {
|
|
365
369
|
"success": True,
|
|
366
370
|
"path": path,
|
|
@@ -376,6 +380,11 @@ def regenerate_bin_summary_from_manifest(
|
|
|
376
380
|
},
|
|
377
381
|
}
|
|
378
382
|
except OSError as exc:
|
|
383
|
+
if os.path.isfile(tmp_path):
|
|
384
|
+
try:
|
|
385
|
+
os.remove(tmp_path)
|
|
386
|
+
except OSError:
|
|
387
|
+
pass
|
|
379
388
|
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
380
389
|
|
|
381
390
|
|
|
@@ -304,8 +304,19 @@ def splice_inputs_block(
|
|
|
304
304
|
after_inputs = parse_instance_input_block(new_inner)
|
|
305
305
|
|
|
306
306
|
os.makedirs(os.path.dirname(os.path.abspath(dest_path)) or ".", exist_ok=True)
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
# Atomic write (temp + os.replace): a crash mid-write must not truncate the
|
|
308
|
+
# Fusion .setting file and leave the GroupOperator config uneditable (PS5).
|
|
309
|
+
tmp_path = f"{dest_path}.tmp-{os.getpid()}"
|
|
310
|
+
try:
|
|
311
|
+
with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
|
|
312
|
+
handle.write(new_content)
|
|
313
|
+
os.replace(tmp_path, dest_path)
|
|
314
|
+
finally:
|
|
315
|
+
if os.path.isfile(tmp_path):
|
|
316
|
+
try:
|
|
317
|
+
os.remove(tmp_path)
|
|
318
|
+
except OSError:
|
|
319
|
+
pass
|
|
309
320
|
|
|
310
321
|
return {
|
|
311
322
|
"source_path": os.path.abspath(source_path),
|