mirror of
https://mirror.skon.top/github.com/cft0808/edict
synced 2026-04-20 21:00:16 +08:00
sync_scripts_to_workspaces() previously used physical file copies. Scripts that derive project root from __file__ (e.g. kanban_update.py) therefore resolved to the workspace directory when run as a copied file, causing tasks_source.json writes to land in the wrong location while the Dashboard reads from the canonical data/ directory. Replace write_bytes() with os.symlink() so __file__ always resolves back to the project scripts/ directory. This ensures that all path-derived constants (TASKS_FILE, DATA, etc.) point to the single canonical data/ folder regardless of which agent workspace runs the script. Added _sync_script_symlink() helper with: - Idempotent re-runs (skip if link already correct) - Automatic cleanup of stale physical copies and broken symlinks - Full test suite (10 tests) covering creation, idempotency, replacement of physical copies, broken symlinks, __file__ resolution, etc. Closes #56 Co-authored-by: cft0808 <41196455+cft0808@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
同步 openclaw.json 中的 agent 配置 → data/agent_config.json
|
||||
支持自动发现 agent workspace 下的 Skills 目录
|
||||
"""
|
||||
import json, pathlib, datetime, logging
|
||||
import json, os, pathlib, datetime, logging
|
||||
from file_lock import atomic_json_write
|
||||
|
||||
log = logging.getLogger('sync_agent_config')
|
||||
@@ -210,8 +210,35 @@ _SOUL_DEPLOY_MAP = {
|
||||
'zaochao': 'zaochao',
|
||||
}
|
||||
|
||||
def _sync_script_symlink(src_file: pathlib.Path, dst_file: pathlib.Path) -> bool:
|
||||
"""Create a symlink dst_file → src_file (resolved).
|
||||
|
||||
Using symlinks instead of physical copies ensures that ``__file__`` in
|
||||
each script always resolves back to the project ``scripts/`` directory,
|
||||
so relative-path computations like ``Path(__file__).resolve().parent.parent``
|
||||
point to the correct project root regardless of which workspace runs the
|
||||
script. (Fixes #56 — kanban data-path split)
|
||||
|
||||
Returns True if the link was (re-)created, False if already up-to-date.
|
||||
"""
|
||||
src_resolved = src_file.resolve()
|
||||
# Already a correct symlink?
|
||||
if dst_file.is_symlink() and dst_file.resolve() == src_resolved:
|
||||
return False
|
||||
# Remove stale file / old physical copy / broken symlink
|
||||
if dst_file.exists() or dst_file.is_symlink():
|
||||
dst_file.unlink()
|
||||
os.symlink(src_resolved, dst_file)
|
||||
return True
|
||||
|
||||
|
||||
def sync_scripts_to_workspaces():
|
||||
"""将项目 scripts/ 目录同步到各 agent workspace(保持 kanban_update.py 等最新)"""
|
||||
"""将项目 scripts/ 目录同步到各 agent workspace(保持 kanban_update.py 等最新)
|
||||
|
||||
Uses symlinks so that ``__file__`` in workspace copies resolves to the
|
||||
project ``scripts/`` directory, keeping path-derived constants like
|
||||
``TASKS_FILE`` pointing to the canonical ``data/`` folder.
|
||||
"""
|
||||
scripts_src = BASE / 'scripts'
|
||||
if not scripts_src.is_dir():
|
||||
return
|
||||
@@ -224,16 +251,10 @@ def sync_scripts_to_workspaces():
|
||||
continue
|
||||
dst_file = ws_scripts / src_file.name
|
||||
try:
|
||||
src_text = src_file.read_bytes()
|
||||
if _sync_script_symlink(src_file, dst_file):
|
||||
synced += 1
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
dst_text = dst_file.read_bytes() if dst_file.exists() else b''
|
||||
except Exception:
|
||||
dst_text = b''
|
||||
if src_text != dst_text:
|
||||
dst_file.write_bytes(src_text)
|
||||
synced += 1
|
||||
# also sync to workspace-main for legacy compatibility
|
||||
ws_main_scripts = pathlib.Path.home() / '.openclaw/workspace-main/scripts'
|
||||
ws_main_scripts.mkdir(parents=True, exist_ok=True)
|
||||
@@ -242,15 +263,12 @@ def sync_scripts_to_workspaces():
|
||||
continue
|
||||
dst_file = ws_main_scripts / src_file.name
|
||||
try:
|
||||
src_text = src_file.read_bytes()
|
||||
dst_text = dst_file.read_bytes() if dst_file.exists() else b''
|
||||
if src_text != dst_text:
|
||||
dst_file.write_bytes(src_text)
|
||||
if _sync_script_symlink(src_file, dst_file):
|
||||
synced += 1
|
||||
except Exception:
|
||||
pass
|
||||
if synced:
|
||||
log.info(f'{synced} script files synced to workspaces')
|
||||
log.info(f'{synced} script symlinks synced to workspaces')
|
||||
|
||||
|
||||
def deploy_soul_files():
|
||||
|
||||
171
tests/test_sync_symlinks.py
Normal file
171
tests/test_sync_symlinks.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for symlink-based script sync (fix for issue #56).
|
||||
|
||||
Verifies that ``sync_scripts_to_workspaces()`` creates symlinks instead of
|
||||
physical copies, so ``__file__`` in workspace scripts resolves back to the
|
||||
project ``scripts/`` directory.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Bootstrap: make scripts/ importable ──────────────────────────
|
||||
SCRIPTS = pathlib.Path(__file__).resolve().parent.parent / 'scripts'
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import sync_agent_config as sac # noqa: E402
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Helper: patch BASE, _SOUL_DEPLOY_MAP, HOME to isolate tests
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch):
|
||||
"""Set up a minimal fake project tree and home dir."""
|
||||
proj = tmp_path / 'project'
|
||||
scripts = proj / 'scripts'
|
||||
scripts.mkdir(parents=True)
|
||||
data = proj / 'data'
|
||||
data.mkdir()
|
||||
(data / 'tasks_source.json').write_text('[]')
|
||||
|
||||
home = tmp_path / 'home'
|
||||
home.mkdir()
|
||||
|
||||
# Create a couple of dummy scripts
|
||||
(scripts / 'kanban_update.py').write_text('# kanban\n')
|
||||
(scripts / 'refresh_live_data.py').write_text('# refresh\n')
|
||||
(scripts / '__init__.py').write_text('') # should be skipped
|
||||
|
||||
# Patch module-level state
|
||||
monkeypatch.setattr(sac, 'BASE', proj)
|
||||
monkeypatch.setattr(sac, '_SOUL_DEPLOY_MAP', {'agent-a': 'aaa'})
|
||||
monkeypatch.setattr(pathlib.Path, 'home', staticmethod(lambda: home))
|
||||
|
||||
return types.SimpleNamespace(root=proj, scripts=scripts, data=data, home=home)
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────
|
||||
|
||||
class TestSyncScriptSymlink:
|
||||
"""Unit tests for the helper ``_sync_script_symlink``."""
|
||||
|
||||
def test_creates_symlink(self, tmp_path):
|
||||
src = tmp_path / 'src.py'
|
||||
src.write_text('hello')
|
||||
dst = tmp_path / 'dst.py'
|
||||
|
||||
created = sac._sync_script_symlink(src, dst)
|
||||
|
||||
assert created is True
|
||||
assert dst.is_symlink()
|
||||
assert dst.resolve() == src.resolve()
|
||||
assert dst.read_text() == 'hello'
|
||||
|
||||
def test_idempotent_when_up_to_date(self, tmp_path):
|
||||
src = tmp_path / 'src.py'
|
||||
src.write_text('hello')
|
||||
dst = tmp_path / 'dst.py'
|
||||
|
||||
sac._sync_script_symlink(src, dst)
|
||||
created = sac._sync_script_symlink(src, dst)
|
||||
|
||||
assert created is False # already correct
|
||||
|
||||
def test_replaces_physical_copy(self, tmp_path):
|
||||
"""A pre-existing physical copy should be replaced with a symlink."""
|
||||
src = tmp_path / 'src.py'
|
||||
src.write_text('v2')
|
||||
dst = tmp_path / 'dst.py'
|
||||
dst.write_text('v1') # physical copy, not a symlink
|
||||
|
||||
created = sac._sync_script_symlink(src, dst)
|
||||
|
||||
assert created is True
|
||||
assert dst.is_symlink()
|
||||
assert dst.resolve() == src.resolve()
|
||||
|
||||
def test_replaces_broken_symlink(self, tmp_path):
|
||||
src = tmp_path / 'src.py'
|
||||
src.write_text('ok')
|
||||
dst = tmp_path / 'dst.py'
|
||||
os.symlink('/no/such/file', dst) # broken link
|
||||
|
||||
created = sac._sync_script_symlink(src, dst)
|
||||
|
||||
assert created is True
|
||||
assert dst.is_symlink()
|
||||
assert dst.resolve() == src.resolve()
|
||||
|
||||
|
||||
class TestSyncScriptsToWorkspaces:
|
||||
"""Integration tests for the full ``sync_scripts_to_workspaces`` flow."""
|
||||
|
||||
def test_creates_symlinks_in_workspace(self, project):
|
||||
sac.sync_scripts_to_workspaces()
|
||||
|
||||
ws = project.home / '.openclaw' / 'workspace-aaa' / 'scripts'
|
||||
assert ws.is_dir()
|
||||
|
||||
kb = ws / 'kanban_update.py'
|
||||
assert kb.is_symlink(), 'expected symlink, got physical file'
|
||||
assert kb.resolve() == (project.scripts / 'kanban_update.py').resolve()
|
||||
|
||||
rf = ws / 'refresh_live_data.py'
|
||||
assert rf.is_symlink()
|
||||
|
||||
def test_skips_dunder_files(self, project):
|
||||
sac.sync_scripts_to_workspaces()
|
||||
|
||||
ws = project.home / '.openclaw' / 'workspace-aaa' / 'scripts'
|
||||
assert not (ws / '__init__.py').exists()
|
||||
|
||||
def test_legacy_workspace_main(self, project):
|
||||
sac.sync_scripts_to_workspaces()
|
||||
|
||||
ws_main = project.home / '.openclaw' / 'workspace-main' / 'scripts'
|
||||
assert ws_main.is_dir()
|
||||
|
||||
kb = ws_main / 'kanban_update.py'
|
||||
assert kb.is_symlink()
|
||||
assert kb.resolve() == (project.scripts / 'kanban_update.py').resolve()
|
||||
|
||||
def test_idempotent_rerun(self, project):
|
||||
sac.sync_scripts_to_workspaces()
|
||||
sac.sync_scripts_to_workspaces() # second run should be a no-op
|
||||
|
||||
ws = project.home / '.openclaw' / 'workspace-aaa' / 'scripts'
|
||||
kb = ws / 'kanban_update.py'
|
||||
assert kb.is_symlink()
|
||||
|
||||
def test_replaces_old_physical_copies(self, project):
|
||||
"""Simulate pre-existing physical copies (old behaviour) and verify
|
||||
they get replaced by symlinks on the next sync run."""
|
||||
ws = project.home / '.openclaw' / 'workspace-aaa' / 'scripts'
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
old_copy = ws / 'kanban_update.py'
|
||||
old_copy.write_text('# stale physical copy')
|
||||
|
||||
sac.sync_scripts_to_workspaces()
|
||||
|
||||
assert old_copy.is_symlink(), 'old physical copy should be replaced'
|
||||
assert old_copy.resolve() == (project.scripts / 'kanban_update.py').resolve()
|
||||
|
||||
def test_file_resolves_to_project_root(self, project):
|
||||
"""The whole point of #56: __file__ should resolve to project scripts/,
|
||||
so Path(__file__).resolve().parent.parent == project root."""
|
||||
sac.sync_scripts_to_workspaces()
|
||||
|
||||
ws = project.home / '.openclaw' / 'workspace-aaa' / 'scripts'
|
||||
ws_script = ws / 'kanban_update.py'
|
||||
|
||||
# Simulate what kanban_update.py does at import time
|
||||
resolved = ws_script.resolve()
|
||||
computed_base = resolved.parent.parent
|
||||
assert computed_base == project.root, (
|
||||
f'Expected {project.root}, got {computed_base}; '
|
||||
'symlink should resolve __file__ back to project root'
|
||||
)
|
||||
Reference in New Issue
Block a user