From 2069e30353d5adc8643117d61c921b23d23aa894 Mon Sep 17 00:00:00 2001 From: payneio Date: Mon, 27 Apr 2026 21:06:57 -0700 Subject: [PATCH] refactor: Remove dedicated /tools endpoints, use /programs?behavior=tool instead Eliminates inconsistency where tools had their own filtered view but daemons and frontends didn't. Changes: - API: Removed tools router and ToolSummary/ToolDetail models. Added optional behavior query parameter to GET /programs for filtering by program type (tool, daemon, frontend). - Frontend: Updated hooks to pass behavior param to usePrograms instead of separate useTools. Updated components to use ProgramSummary type. Removed useToolDetail hook. - Docs: Updated API documentation to reflect program behavior filtering. --- README.md | 11 ++--- app/src/components/ToolCard.tsx | 4 +- app/src/pages/ComponentDetail.tsx | 29 ++++------- app/src/pages/Tools.tsx | 4 +- app/src/services/api/hooks.ts | 24 ++------- app/src/types/index.ts | 12 ----- castle-api/src/castle_api/main.py | 3 +- castle-api/src/castle_api/models.py | 18 ------- castle-api/src/castle_api/routes.py | 34 ++++++++----- castle-api/src/castle_api/tools.py | 75 +---------------------------- 10 files changed, 46 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 42f0635..67b48a2 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,11 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist |----------|-------------| | `GET /health` | Health check | | `GET /stream` | SSE stream (health, service-action, mesh events) | -| **Programs & Components** | | -| `GET /programs` | List all programs | +| **Programs** | | +| `GET /programs` | List all programs (`?behavior=tool\|daemon\|frontend` to filter) | | `GET /programs/{name}` | Program detail | -| `GET /components` | Unified view of all components (`?include_remote=true` for cross-node) | +| `POST /programs/{name}/{action}` | Run a lifecycle action (build, test, lint, install, etc.) | +| `GET /components` | Unified view across nodes (`?include_remote=true` for cross-node) | | `GET /components/{name}` | Component detail | | **Services** | | | `GET /services` | List all services with status | @@ -206,10 +207,6 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist | `PUT /config/services/{name}` | Update a service entry | | `PUT /config/jobs/{name}` | Update a job entry | | `POST /config/apply` | Apply config changes (deploy + reload) | -| **Tools** | | -| `GET /tools` | List all tools | -| `GET /tools/{name}` | Tool detail | -| `POST /programs/{name}/{action}` | Run a program action (build, test, lint, install) | | **Secrets** | | | `GET /secrets` | List secrets | | `GET /secrets/{name}` | Read a secret | diff --git a/app/src/components/ToolCard.tsx b/app/src/components/ToolCard.tsx index f1455c7..0d9f1b2 100644 --- a/app/src/components/ToolCard.tsx +++ b/app/src/components/ToolCard.tsx @@ -1,10 +1,10 @@ import { Link } from "react-router-dom" import { Terminal } from "lucide-react" -import type { ToolSummary } from "@/types" +import type { ProgramSummary } from "@/types" import { runnerLabel } from "@/lib/labels" interface ToolCardProps { - tool: ToolSummary + tool: ProgramSummary } export function ToolCard({ tool }: ToolCardProps) { diff --git a/app/src/pages/ComponentDetail.tsx b/app/src/pages/ComponentDetail.tsx index 3e5ffc4..4a502b0 100644 --- a/app/src/pages/ComponentDetail.tsx +++ b/app/src/pages/ComponentDetail.tsx @@ -1,6 +1,6 @@ import { useState } from "react" import { useParams } from "react-router-dom" -import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks" +import { useProgram, useEventStream } from "@/services/api/hooks" import { runnerLabel } from "@/lib/labels" import { DetailHeader } from "@/components/detail/DetailHeader" import { ConfigPanel } from "@/components/detail/ConfigPanel" @@ -11,7 +11,6 @@ export function ComponentDetailPage() { const { name } = useParams<{ name: string }>() const { data: component, isLoading, error, refetch } = useProgram(name ?? "") const isTool = component?.behavior === "tool" - const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const [actionOutput, setActionOutput] = useState(null) if (isLoading) { @@ -52,7 +51,7 @@ export function ComponentDetailPage() {

{component.description}

)} - {toolDetail && ( + {isTool && (

Tool Info @@ -61,32 +60,32 @@ export function ComponentDetailPage() { How this tool is packaged and what it depends on.

- {toolDetail.source && ( + {component.source && ( <> Source - {toolDetail.source} + {component.source} )} - {toolDetail.version && ( + {component.version && ( <> Version - {toolDetail.version} + {component.version} )} - {toolDetail.runner && ( + {component.runner && ( <> Runner - {runnerLabel(toolDetail.runner)} + {runnerLabel(component.runner)} )} Installed {component.installed ? "Yes" : "No"}
- {toolDetail.system_dependencies.length > 0 && ( + {component.system_dependencies.length > 0 && (
System Dependencies
- {toolDetail.system_dependencies.map((dep) => ( + {component.system_dependencies.map((dep) => (
)} - {toolDetail.docs && ( -
- Documentation -
-                {toolDetail.docs}
-              
-
- )}
)} diff --git a/app/src/pages/Tools.tsx b/app/src/pages/Tools.tsx index b4fb73f..9edea79 100644 --- a/app/src/pages/Tools.tsx +++ b/app/src/pages/Tools.tsx @@ -1,10 +1,10 @@ import { Link } from "react-router-dom" import { ArrowLeft } from "lucide-react" -import { useTools } from "@/services/api/hooks" +import { usePrograms } from "@/services/api/hooks" import { ToolCard } from "@/components/ToolCard" export function ToolsPage() { - const { data: tools, isLoading } = useTools() + const { data: tools, isLoading } = usePrograms("tool") return (
diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index ddd2d45..9b553d7 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -16,8 +16,6 @@ import type { MeshStatus, NodeSummary, NodeDetail, - ToolSummary, - ToolDetail, } from "@/types" // Legacy compat hook — used by ConfigEditorPage and ComponentRedirect @@ -59,10 +57,11 @@ export function useJob(name: string) { }) } -export function usePrograms() { +export function usePrograms(behavior?: string) { + const params = behavior ? `?behavior=${behavior}` : "" return useQuery({ - queryKey: ["programs"], - queryFn: () => apiClient.get("/programs"), + queryKey: ["programs", behavior ?? "all"], + queryFn: () => apiClient.get(`/programs${params}`), }) } @@ -187,21 +186,6 @@ export function useMeshStatus() { }) } -export function useTools() { - return useQuery({ - queryKey: ["tools"], - queryFn: () => apiClient.get("/tools"), - }) -} - -export function useToolDetail(name: string) { - return useQuery({ - queryKey: ["tools", name], - queryFn: () => apiClient.get(`/tools/${name}`), - enabled: !!name, - }) -} - export function useEventStream() { const qc = useQueryClient() diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 796bce6..eaa9211 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -152,16 +152,4 @@ export interface MeshStatus { peers: string[] } -export interface ToolSummary { - id: string - description: string | null - source: string | null - version: string | null - runner: string | null - system_dependencies: string[] - installed: boolean -} -export interface ToolDetail extends ToolSummary { - docs: string | null -} diff --git a/castle-api/src/castle_api/main.py b/castle-api/src/castle_api/main.py index 7521d1d..3515d42 100644 --- a/castle-api/src/castle_api/main.py +++ b/castle-api/src/castle_api/main.py @@ -25,7 +25,7 @@ from castle_api.stream import ( unsubscribe, ) from castle_api.nodes import router as nodes_router -from castle_api.tools import programs_router, router as tools_router +from castle_api.tools import programs_router logger = logging.getLogger(__name__) @@ -120,7 +120,6 @@ app.include_router(logs_router) app.include_router(nodes_router) app.include_router(secrets_router) app.include_router(services_router) -app.include_router(tools_router) app.include_router(programs_router) diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 760ceac..9c39ee3 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -174,21 +174,3 @@ class ServiceActionResponse(BaseModel): component: str action: str status: str - - -class ToolSummary(BaseModel): - """Summary of a single tool.""" - - id: str - description: str | None = None - source: str | None = None - version: str | None = None - runner: str | None = None - system_dependencies: list[str] = [] - installed: bool = False - - -class ToolDetail(ToolSummary): - """Full detail for a single tool, including documentation.""" - - docs: str | None = None diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index f780c79..2d4e0ca 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -73,7 +73,9 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: ) -def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary: +def _summary_from_service( + name: str, svc: ServiceSpec, config: object +) -> ComponentSummary: """Build a ComponentSummary from a ServiceSpec (non-deployed).""" port = None health_path = None @@ -90,7 +92,9 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon if managed: unit_name = f"castle-{name}.service" unit_path = str(Path("~/.config/systemd/user") / unit_name) - systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False) + systemd_info = SystemdInfo( + unit_name=unit_name, unit_path=unit_path, timer=False + ) description = svc.description source = None @@ -226,9 +230,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: ) -def _service_from_spec( - name: str, svc: ServiceSpec, config: object -) -> ServiceSummary: +def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary: """Build a ServiceSummary from a ServiceSpec.""" port = None health_path = None @@ -268,9 +270,7 @@ def _service_from_spec( def _job_from_deployed(name: str, deployed: object) -> JobSummary: """Build a JobSummary from a DeployedComponent.""" - systemd_info = ( - _make_systemd_info(name, timer=True) if deployed.managed else None - ) + systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None return JobSummary( id=name, description=deployed.description, @@ -553,8 +553,11 @@ def get_job(name: str) -> JobDetail: @router.get("/programs", response_model=list[ProgramSummary], tags=["programs"]) -def list_programs() -> list[ProgramSummary]: - """List all programs from the software catalog (castle.yaml programs section).""" +def list_programs(behavior: str | None = None) -> list[ProgramSummary]: + """List all programs from the software catalog (castle.yaml programs section). + + Optionally filter by behavior: daemon, tool, or frontend. + """ root = get_castle_root() if not root: return [] @@ -573,6 +576,8 @@ def list_programs() -> list[ProgramSummary]: summary = _program_from_spec(name, comp, root, config) if summary.behavior is None: continue + if behavior and summary.behavior != behavior: + continue summary.node = hostname summaries.append(summary) @@ -840,11 +845,16 @@ async def reload_gateway() -> dict[str, str]: # Include remote registries for cross-node routing remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()} caddyfile_path.write_text( - generate_caddyfile_from_registry(registry, remote_registries=remote_regs or None) + generate_caddyfile_from_registry( + registry, remote_registries=remote_regs or None + ) ) proc = await asyncio.create_subprocess_exec( - "systemctl", "--user", "reload", "castle-castle-gateway.service", + "systemctl", + "--user", + "reload", + "castle-castle-gateway.service", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) diff --git a/castle-api/src/castle_api/tools.py b/castle-api/src/castle_api/tools.py index 37dfc0f..e4f2249 100644 --- a/castle-api/src/castle_api/tools.py +++ b/castle-api/src/castle_api/tools.py @@ -1,88 +1,15 @@ -"""Tools router and program actions.""" +"""Program action endpoints.""" from __future__ import annotations -import shutil -from pathlib import Path - from fastapi import APIRouter, HTTPException, status -from castle_core.manifest import ProgramSpec from castle_core.stacks import available_actions, get_handler from castle_api.config import get_config -from castle_api.models import ToolDetail, ToolSummary -router = APIRouter(tags=["tools"]) programs_router = APIRouter(tags=["programs"]) - -def _is_tool(comp: ProgramSpec) -> bool: - """Check if a component is a tool.""" - return comp.behavior == "tool" - - -def _tool_summary( - name: str, comp: ProgramSpec, root: Path | None = None -) -> ToolSummary: - """Build a ToolSummary from a ProgramSpec that is a tool.""" - installed = shutil.which(name) is not None - - # Infer runner from source directory - runner = None - source = comp.source - if source: - source_dir = Path(source) - if (source_dir / "pyproject.toml").exists(): - runner = "python" - elif source_dir.is_file(): - runner = "command" - - return ToolSummary( - id=name, - description=comp.description, - source=source, - version=comp.version, - runner=runner, - system_dependencies=comp.system_dependencies, - installed=installed, - ) - - -@router.get("/tools", response_model=list[ToolSummary]) -def list_tools() -> list[ToolSummary]: - """List all registered tools (requires repo access).""" - config = get_config() - tools = {k: v for k, v in config.programs.items() if _is_tool(v)} - - return sorted( - [_tool_summary(name, comp, config.root) for name, comp in tools.items()], - key=lambda t: t.id, - ) - - -@router.get("/tools/{name}", response_model=ToolDetail) -def get_tool(name: str) -> ToolDetail: - """Get detailed info for a single tool.""" - config = get_config() - - if name not in config.programs: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Component '{name}' not found", - ) - - comp = config.programs[name] - if not _is_tool(comp): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"'{name}' is not a tool", - ) - - summary = _tool_summary(name, comp, config.root) - return ToolDetail(**summary.model_dump()) - - # --------------------------------------------------------------------------- # Unified program action endpoint # ---------------------------------------------------------------------------