Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions anygen/agent-harness/cli_anything/anygen/core/session.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
"""Session management — undo/redo and command history for AnyGen CLI."""

import json
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path


def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)


@dataclass
class HistoryEntry:
command: str
Expand Down Expand Up @@ -101,9 +128,7 @@ def save(self, path: str):
"history": [e.to_dict() for e in self._history],
"redo_stack": [e.to_dict() for e in self._redo_stack],
}
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True, default=str)
_locked_save_json(path, data, indent=2, sort_keys=True, default=str)

def _load(self, path: str):
p = Path(path)
Expand Down
30 changes: 27 additions & 3 deletions audacity/agent-harness/cli_anything/audacity/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@
import os
import copy
from typing import Dict, Any, Optional, List


def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
from datetime import datetime


Expand Down Expand Up @@ -119,9 +145,7 @@ def save_session(self, path: Optional[str] = None) -> str:

# Save project
self.project["metadata"]["modified"] = datetime.now().isoformat()
os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
with open(save_path, "w") as f:
json.dump(self.project, f, indent=2, sort_keys=True, default=str)
_locked_save_json(save_path, self.project, indent=2, sort_keys=True, default=str)

self.project_path = save_path
self._modified = False
Expand Down
29 changes: 27 additions & 2 deletions blender/agent-harness/cli_anything/blender/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@
from datetime import datetime


def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)


class Session:
"""Manages project state with undo/redo history."""

Expand Down Expand Up @@ -111,8 +137,7 @@ def save_session(self, path: Optional[str] = None) -> str:

# Save project
self.project["metadata"]["modified"] = datetime.now().isoformat()
with open(save_path, "w") as f:
json.dump(self.project, f, indent=2, sort_keys=True, default=str)
_locked_save_json(save_path, self.project, indent=2, sort_keys=True, default=str)

self.project_path = save_path
self._modified = False
Expand Down
30 changes: 30 additions & 0 deletions cli-anything-plugin/HARNESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,36 @@ designed for humans, without needing a display or mouse.
5. **Add rendering/export** — The export pipeline calls the backend module.
Generate valid intermediate files, then invoke the real software for conversion.
6. **Add session management** — State persistence, undo/redo

**Session file locking** — When saving session JSON, use exclusive file locking
to prevent concurrent writes from corrupting data. Never use bare
`open("w") + json.dump()` — `open("w")` truncates the file before any lock
can be acquired. Instead, open with `"r+"`, lock, then truncate inside the lock:
```python
def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+") # no truncation on open
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w") # first save — file doesn't exist yet
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass # Windows / unsupported FS — proceed unlocked
try:
f.seek(0)
f.truncate() # truncate INSIDE the lock
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
```
7. **Add the REPL with unified skin** — Interactive mode wrapping the subcommands.
- Copy `repl_skin.py` from the plugin (`cli-anything-plugin/repl_skin.py`) into
`utils/repl_skin.py` in your CLI package
Expand Down
30 changes: 28 additions & 2 deletions drawio/agent-harness/cli_anything/drawio/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@
from ..utils import drawio_xml


def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
path = str(path)
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)


SESSION_DIR = Path.home() / ".drawio-cli" / "sessions"
MAX_UNDO_DEPTH = 50

Expand Down Expand Up @@ -126,8 +153,7 @@ def save_session_state(self) -> str:
"timestamp": time.time(),
}
path = SESSION_DIR / f"{self.session_id}.json"
with open(path, "w") as f:
json.dump(state, f, indent=2, sort_keys=True)
_locked_save_json(path, state, indent=2, sort_keys=True)
return str(path)

@classmethod
Expand Down
29 changes: 27 additions & 2 deletions gimp/agent-harness/cli_anything/gimp/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@
from datetime import datetime


def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)


class Session:
"""Manages project state with undo/redo history."""

Expand Down Expand Up @@ -111,8 +137,7 @@ def save_session(self, path: Optional[str] = None) -> str:

# Save project
self.project["metadata"]["modified"] = datetime.now().isoformat()
with open(save_path, "w") as f:
json.dump(self.project, f, indent=2, sort_keys=True, default=str)
_locked_save_json(save_path, self.project, indent=2, sort_keys=True, default=str)

self.project_path = save_path
self._modified = False
Expand Down
73 changes: 73 additions & 0 deletions gimp/agent-harness/cli_anything/gimp/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,76 @@ def test_max_undo(self):
for i in range(10):
sess.snapshot(f"action {i}")
assert len(sess._undo_stack) == 5


# ── Concurrent Save Tests ───────────────────────────────────────

class TestLockedSaveJson:
"""Tests for _locked_save_json atomic file writes."""

def test_basic_save(self, tmp_path):
from cli_anything.gimp.core.session import _locked_save_json
path = str(tmp_path / "test.json")
_locked_save_json(path, {"key": "value"}, indent=2)
with open(path) as f:
data = json.load(f)
assert data == {"key": "value"}

def test_overwrite_existing(self, tmp_path):
from cli_anything.gimp.core.session import _locked_save_json
path = str(tmp_path / "test.json")
_locked_save_json(path, {"version": 1}, indent=2)
_locked_save_json(path, {"version": 2}, indent=2)
with open(path) as f:
data = json.load(f)
assert data == {"version": 2}

def test_overwrite_shorter_data(self, tmp_path):
"""Ensure truncation works — shorter data doesn't leave old bytes."""
from cli_anything.gimp.core.session import _locked_save_json
path = str(tmp_path / "test.json")
_locked_save_json(path, {"key": "a" * 1000}, indent=2)
_locked_save_json(path, {"k": 1}, indent=2)
with open(path) as f:
data = json.load(f)
assert data == {"k": 1}

def test_creates_parent_dirs(self, tmp_path):
from cli_anything.gimp.core.session import _locked_save_json
path = str(tmp_path / "nested" / "dir" / "test.json")
_locked_save_json(path, {"nested": True})
with open(path) as f:
data = json.load(f)
assert data == {"nested": True}

def test_concurrent_writes_produce_valid_json(self, tmp_path):
"""Multiple threads writing to the same file should not corrupt it."""
from cli_anything.gimp.core.session import _locked_save_json
import threading

path = str(tmp_path / "concurrent.json")
errors = []

def writer(thread_id):
try:
for i in range(50):
_locked_save_json(
path,
{"thread": thread_id, "iteration": i},
indent=2, sort_keys=True,
)
except Exception as e:
errors.append(e)

threads = [threading.Thread(target=writer, args=(t,)) for t in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()

assert not errors, f"Errors during concurrent writes: {errors}"
# Final file must be valid JSON
with open(path) as f:
data = json.load(f)
assert "thread" in data
assert "iteration" in data
30 changes: 27 additions & 3 deletions inkscape/agent-harness/cli_anything/inkscape/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@
from datetime import datetime


def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)


class Session:
"""Manages project state with undo/redo history."""

Expand Down Expand Up @@ -110,9 +136,7 @@ def save_session(self, path: Optional[str] = None) -> str:
raise ValueError("No save path specified.")

self.project["metadata"]["modified"] = datetime.now().isoformat()
os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
with open(save_path, "w") as f:
json.dump(self.project, f, indent=2, sort_keys=True, default=str)
_locked_save_json(save_path, self.project, indent=2, sort_keys=True, default=str)

self.project_path = save_path
self._modified = False
Expand Down
Loading