feat: Refactor tool management and update documentation for clarity
This commit is contained in:
@@ -3,6 +3,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SystemdInfo(BaseModel):
|
||||
"""Systemd unit information for a managed component."""
|
||||
|
||||
unit_name: str
|
||||
unit_path: str
|
||||
timer: bool = False
|
||||
|
||||
|
||||
class ComponentSummary(BaseModel):
|
||||
"""Summary of a single component."""
|
||||
|
||||
@@ -14,9 +22,8 @@ class ComponentSummary(BaseModel):
|
||||
health_path: str | None = None
|
||||
proxy_path: str | None = None
|
||||
managed: bool = False
|
||||
category: str | None = None
|
||||
systemd: SystemdInfo | None = None
|
||||
version: str | None = None
|
||||
tool_type: str | None = None
|
||||
source: str | None = None
|
||||
system_dependencies: list[str] = []
|
||||
schedule: str | None = None
|
||||
@@ -65,9 +72,7 @@ class ToolSummary(BaseModel):
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
source: str | None = None
|
||||
tool_type: str | None = None
|
||||
version: str | None = None
|
||||
runner: str | None = None
|
||||
system_dependencies: list[str] = []
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
@@ -15,12 +16,13 @@ from dashboard_api.models import (
|
||||
ComponentSummary,
|
||||
GatewayInfo,
|
||||
StatusResponse,
|
||||
SystemdInfo,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
|
||||
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a manifest."""
|
||||
port = None
|
||||
health_path = None
|
||||
@@ -35,6 +37,18 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
|
||||
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
|
||||
)
|
||||
|
||||
# Systemd info for managed components
|
||||
systemd_info: SystemdInfo | None = None
|
||||
if managed:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
has_timer = any(getattr(t, "type", None) == "schedule" for t in manifest.triggers)
|
||||
systemd_info = SystemdInfo(
|
||||
unit_name=unit_name,
|
||||
unit_path=unit_path,
|
||||
timer=has_timer,
|
||||
)
|
||||
|
||||
# Extract cron schedule from first schedule trigger, if any
|
||||
schedule = None
|
||||
for t in manifest.triggers:
|
||||
@@ -42,6 +56,15 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
|
||||
schedule = t.cron
|
||||
break
|
||||
|
||||
# Infer runner — from run block or from tool source
|
||||
runner = manifest.run.runner if manifest.run else None
|
||||
if runner is None and manifest.tool and manifest.tool.source:
|
||||
source_dir = root / manifest.tool.source
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python_uv_tool"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
# Check if tool is actually installed on PATH
|
||||
installed: bool | None = None
|
||||
if manifest.install and manifest.install.path:
|
||||
@@ -52,14 +75,13 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
|
||||
id=name,
|
||||
description=manifest.description,
|
||||
roles=[r.value for r in manifest.roles],
|
||||
runner=manifest.run.runner if manifest.run else None,
|
||||
runner=runner,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
proxy_path=proxy_path,
|
||||
managed=managed,
|
||||
category=manifest.tool.category if manifest.tool else None,
|
||||
systemd=systemd_info,
|
||||
version=manifest.tool.version if manifest.tool else None,
|
||||
tool_type=manifest.tool.tool_type.value if manifest.tool else None,
|
||||
source=manifest.tool.source if manifest.tool else None,
|
||||
system_dependencies=manifest.tool.system_dependencies if manifest.tool else [],
|
||||
schedule=schedule,
|
||||
@@ -72,7 +94,7 @@ def list_components() -> list[ComponentSummary]:
|
||||
"""List all registered components."""
|
||||
config = load_config(settings.castle_root)
|
||||
return [
|
||||
_summary_from_manifest(name, m)
|
||||
_summary_from_manifest(name, m, config.root)
|
||||
for name, m in config.components.items()
|
||||
]
|
||||
|
||||
@@ -87,7 +109,7 @@ def get_component(name: str) -> ComponentDetail:
|
||||
detail=f"Component '{name}' not found",
|
||||
)
|
||||
manifest = config.components[name]
|
||||
summary = _summary_from_manifest(name, manifest)
|
||||
summary = _summary_from_manifest(name, manifest, config.root)
|
||||
raw = manifest.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
|
||||
@@ -16,19 +16,27 @@ from dashboard_api.models import ToolCategory, ToolDetail, ToolSummary
|
||||
router = APIRouter(tags=["tools"])
|
||||
|
||||
|
||||
def _tool_summary(name: str, manifest: ComponentManifest) -> ToolSummary:
|
||||
def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = None) -> ToolSummary:
|
||||
"""Build a ToolSummary from a manifest that has a tool spec."""
|
||||
t = manifest.tool
|
||||
assert t is not None
|
||||
installed = bool(manifest.install and manifest.install.path and manifest.install.path.enable)
|
||||
|
||||
# Infer runner from run block or source directory
|
||||
runner = manifest.run.runner if manifest.run else None
|
||||
if runner is None and t.source and root:
|
||||
source_dir = root / t.source
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python_uv_tool"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
return ToolSummary(
|
||||
id=name,
|
||||
description=manifest.description,
|
||||
category=t.category,
|
||||
source=t.source,
|
||||
tool_type=t.tool_type.value,
|
||||
version=t.version,
|
||||
runner=manifest.run.runner if manifest.run else None,
|
||||
runner=runner,
|
||||
system_dependencies=t.system_dependencies,
|
||||
installed=installed,
|
||||
)
|
||||
@@ -38,7 +46,6 @@ def _find_md_for_tool(
|
||||
root: Path,
|
||||
source: str,
|
||||
tool_name: str,
|
||||
category: str | None = None,
|
||||
) -> Path | None:
|
||||
"""Find the .md documentation file for a tool source path."""
|
||||
source_path = root / source
|
||||
@@ -48,10 +55,10 @@ def _find_md_for_tool(
|
||||
return md
|
||||
elif source_path.is_dir():
|
||||
py_name = tool_name.replace("-", "_")
|
||||
if category:
|
||||
md = source_path / "src" / category / f"{py_name}.md"
|
||||
if md.exists():
|
||||
return md
|
||||
pkg_name = source_path.name
|
||||
md = source_path / "src" / pkg_name / f"{py_name}.md"
|
||||
if md.exists():
|
||||
return md
|
||||
return None
|
||||
|
||||
|
||||
@@ -66,18 +73,23 @@ def _strip_frontmatter(content: str) -> str:
|
||||
|
||||
@router.get("/tools", response_model=list[ToolCategory])
|
||||
def list_tools() -> list[ToolCategory]:
|
||||
"""List tools grouped by category."""
|
||||
"""List tools grouped by source directory."""
|
||||
config = load_config(settings.castle_root)
|
||||
tools = {k: v for k, v in config.components.items() if v.tool}
|
||||
|
||||
by_category: dict[str, list[ToolSummary]] = {}
|
||||
by_group: dict[str, list[ToolSummary]] = {}
|
||||
for name, manifest in tools.items():
|
||||
cat = manifest.tool.category or "uncategorized" # type: ignore[union-attr]
|
||||
by_category.setdefault(cat, []).append(_tool_summary(name, manifest))
|
||||
t = manifest.tool
|
||||
assert t is not None
|
||||
if t.source:
|
||||
group = Path(t.source).name
|
||||
else:
|
||||
group = "standalone"
|
||||
by_group.setdefault(group, []).append(_tool_summary(name, manifest, config.root))
|
||||
|
||||
return [
|
||||
ToolCategory(name=cat, tools=sorted(items, key=lambda t: t.id))
|
||||
for cat, items in sorted(by_category.items())
|
||||
ToolCategory(name=group, tools=sorted(items, key=lambda t: t.id))
|
||||
for group, items in sorted(by_group.items())
|
||||
]
|
||||
|
||||
|
||||
@@ -99,11 +111,11 @@ def get_tool(name: str) -> ToolDetail:
|
||||
detail=f"'{name}' is not a tool",
|
||||
)
|
||||
|
||||
summary = _tool_summary(name, manifest)
|
||||
summary = _tool_summary(name, manifest, config.root)
|
||||
docs: str | None = None
|
||||
t = manifest.tool
|
||||
if t.source:
|
||||
md_path = _find_md_for_tool(config.root, t.source, name, t.category)
|
||||
md_path = _find_md_for_tool(config.root, t.source, name)
|
||||
if md_path and md_path.exists():
|
||||
docs = _strip_frontmatter(md_path.read_text())
|
||||
if not docs:
|
||||
|
||||
@@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"description": "Test tool",
|
||||
"install": {"path": {"alias": "test-tool"}},
|
||||
"tool": {
|
||||
"category": "document",
|
||||
"source": "tools/document",
|
||||
"system_dependencies": ["pandoc"],
|
||||
},
|
||||
@@ -46,9 +45,8 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"test-tool-2": {
|
||||
"description": "Another test tool",
|
||||
"tool": {
|
||||
"category": "utility",
|
||||
"source": "tools/utility",
|
||||
"version": "2.0.0",
|
||||
"tool_type": "script",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ class TestToolsList:
|
||||
"""GET /tools endpoint tests."""
|
||||
|
||||
def test_returns_grouped_tools(self, client: TestClient) -> None:
|
||||
"""Returns tools grouped by category."""
|
||||
"""Returns tools grouped by source directory name."""
|
||||
response = client.get("/tools")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -17,8 +17,8 @@ class TestToolsList:
|
||||
assert "document" in names
|
||||
assert "utility" in names
|
||||
|
||||
def test_categories_sorted(self, client: TestClient) -> None:
|
||||
"""Categories are sorted alphabetically."""
|
||||
def test_groups_sorted(self, client: TestClient) -> None:
|
||||
"""Groups are sorted alphabetically."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
names = [cat["name"] for cat in data]
|
||||
@@ -28,27 +28,29 @@ class TestToolsList:
|
||||
"""Tool summary has expected fields."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
doc_cat = next(c for c in data if c["name"] == "document")
|
||||
tool = next(t for t in doc_cat["tools"] if t["id"] == "test-tool")
|
||||
doc_group = next(c for c in data if c["name"] == "document")
|
||||
tool = next(t for t in doc_group["tools"] if t["id"] == "test-tool")
|
||||
assert tool["description"] == "Test tool"
|
||||
assert tool["category"] == "document"
|
||||
assert tool["source"] == "tools/document"
|
||||
assert tool["system_dependencies"] == ["pandoc"]
|
||||
# tool_type and category should not be present
|
||||
assert "tool_type" not in tool
|
||||
assert "category" not in tool
|
||||
|
||||
def test_installed_flag(self, client: TestClient) -> None:
|
||||
"""Tool with install.path is marked as installed."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
doc_cat = next(c for c in data if c["name"] == "document")
|
||||
tool = next(t for t in doc_cat["tools"] if t["id"] == "test-tool")
|
||||
doc_group = next(c for c in data if c["name"] == "document")
|
||||
tool = next(t for t in doc_group["tools"] if t["id"] == "test-tool")
|
||||
assert tool["installed"] is True
|
||||
|
||||
def test_not_installed_flag(self, client: TestClient) -> None:
|
||||
"""Tool without install.path is not marked as installed."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
util_cat = next(c for c in data if c["name"] == "utility")
|
||||
tool = next(t for t in util_cat["tools"] if t["id"] == "test-tool-2")
|
||||
util_group = next(c for c in data if c["name"] == "utility")
|
||||
tool = next(t for t in util_group["tools"] if t["id"] == "test-tool-2")
|
||||
assert tool["installed"] is False
|
||||
|
||||
def test_service_excluded(self, client: TestClient) -> None:
|
||||
@@ -68,12 +70,12 @@ class TestToolDetail:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == "test-tool"
|
||||
assert data["category"] == "document"
|
||||
assert data["source"] == "tools/document"
|
||||
assert data["system_dependencies"] == ["pandoc"]
|
||||
|
||||
def test_docs_from_file(self, client: TestClient, castle_root: Path) -> None:
|
||||
"""Reads documentation from .md file."""
|
||||
# Create doc file matching the source lookup
|
||||
# Create doc file matching the source lookup (src/<dir_name>/<tool>.md)
|
||||
doc_dir = castle_root / "tools" / "document" / "src" / "document"
|
||||
doc_dir.mkdir(parents=True)
|
||||
(doc_dir / "test_tool.md").write_text(
|
||||
|
||||
Reference in New Issue
Block a user