refactor(skill): remove all local-filesystem fallbacks; Box is the sole source

Skills now flow exclusively through the Box runtime. Every read and write
method funnels through ``_box_service()``; when Box is unavailable
(disabled in config, connection failed, or simply not installed) the
operation either returns an empty surface (``list_skills`` → []) or
raises with a clear ``Box runtime ... not initialised / disabled /
unavailable: ...`` message via the new ``_require_box(action)`` helper.

Why: the legacy local-fallback path scanned ``data/skills/``, but Box
manages its own ``box.local.skills_root`` (default ``data/box/skills/``).
The two diverging directories caused stale / phantom skill lists when
Box flapped, and the local-fallback writes silently bypassed all the
sandboxing the operator had configured.

SkillService (``api/http/service/skill.py``):
- New ``_require_box(action)`` returns the box service or raises a
  structured ValueError. ``_require_box_for_write`` kept as alias
- ``list_skills`` → returns [] when Box is down so the UI can render
  the disabled banner cleanly
- ``get_skill`` / ``get_skill_by_name`` → return None
- All read-file / write-file / scan-dir / create / update / delete /
  install / preview methods → ``_require_box`` then box delegate.
  Local fallback bodies (shutil.copytree, tempfile.mkdtemp, preview
  pipelines) removed entirely

SkillManager (``pkg/skill/manager.py``):
- ``reload_skills`` returns early with empty cache when Box is down.
  data/skills/ discovery loop removed
- ``refresh_skill_from_disk`` now just reports cache presence; the
  on-disk re-parse is gone since Box is the only writer

Tests:
- Drop 11 obsolete test_skill_service.py tests that exercised the
  removed local-fallback paths (create/install/file/delete/update)
- Add list-empty + read-refused tests; flip the legacy-allow test to
  legacy-refuses-too
- Rewrite refresh_skill_from_disk test to match the new behaviour

Several helper methods (_managed_skill_path, _resolve_skill_path,
_preview_skill_candidates, _install_preview_candidates, etc.) are now
unreachable; a follow-up commit will prune them so this diff stays
reviewable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-05-20 22:07:23 +08:00
parent 49064ffc2d
commit cc072be7f7
4 changed files with 154 additions and 786 deletions
+13 -17
View File
@@ -54,29 +54,25 @@ class TestSkillManagerPackageLoading:
assert skill_data['instructions'] == '# Test Skill\nDo things.'
assert skill_data['description'] == 'Test skill'
def test_refresh_skill_from_disk_updates_cached_dict_in_place(self):
def test_refresh_skill_from_disk_reports_cache_presence(self):
"""Box is the only source of truth for skill content. refresh_skill_from_disk
now just reports whether the skill is still in the in-memory cache —
the actual content refresh is driven by SkillService awaiting
``reload_skills`` after every Box mutation."""
from langbot.pkg.skill.manager import SkillManager
ap = _make_ap()
mgr = SkillManager(ap)
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = os.path.join(tmpdir, 'SKILL.md')
with open(skill_md, 'w', encoding='utf-8') as f:
f.write('---\ndescription: First\n---\n\nOriginal instructions')
# Empty cache → returns False
assert mgr.refresh_skill_from_disk('test-skill') is False
skill_data = _make_skill_data(name='test-skill', package_root=tmpdir)
assert mgr._load_skill_file(skill_data) is True
mgr.skills['test-skill'] = skill_data
with open(skill_md, 'w', encoding='utf-8') as f:
f.write('---\ndescription: Second\n---\n\nUpdated instructions')
assert mgr.refresh_skill_from_disk('test-skill') is True
assert mgr.skills['test-skill'] is skill_data
assert skill_data['instructions'] == 'Updated instructions'
assert skill_data['description'] == 'Second'
# Cache populated → returns True; method does NOT mutate the cache
cached = _make_skill_data(name='test-skill', instructions='Cached')
mgr.skills['test-skill'] = cached
assert mgr.refresh_skill_from_disk('test-skill') is True
assert mgr.skills['test-skill'] is cached
assert mgr.refresh_skill_from_disk('') is False
@pytest.mark.asyncio
async def test_reload_skills_drops_box_skills_with_missing_package_root(self):