refactor: Update component structure to use behavior and stack attributes, enhancing clarity and functionality across services, tools, and frontends

This commit is contained in:
2026-02-23 16:32:55 -08:00
parent 3343e955fd
commit 56b9c8ddf1
45 changed files with 698 additions and 667 deletions

View File

@@ -16,7 +16,8 @@ class ComponentSummary(BaseModel):
id: str
description: str | None = None
category: str
behavior: str | None = None
stack: str | None = None
runner: str | None = None
port: int | None = None
health_path: str | None = None

View File

@@ -44,8 +44,10 @@ def _registry_to_json(registry: NodeRegistry) -> str:
for name, comp in registry.deployed.items():
entry: dict = {
"runner": comp.runner,
"category": comp.category,
"behavior": comp.behavior,
}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
if comp.port is not None:
@@ -79,7 +81,8 @@ def _json_to_registry(payload: str) -> NodeRegistry:
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
category=comp_data.get("category", "service"),
behavior=comp_data.get("behavior", "daemon"),
stack=comp_data.get("stack"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),

View File

@@ -47,7 +47,8 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSum
ComponentSummary(
id=name,
description=d.description,
category=d.category,
behavior=d.behavior,
stack=d.stack,
runner=d.runner,
port=d.port,
health_path=d.health_path,

View File

@@ -44,13 +44,14 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
# Check if tool is installed on PATH
installed: bool | None = None
if deployed.category == "tool":
if deployed.behavior == "tool":
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
description=deployed.description,
category=deployed.category,
behavior=deployed.behavior,
stack=deployed.stack,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
@@ -83,18 +84,21 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.components:
comp = config.components[svc.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
runner = svc.run.runner
return ComponentSummary(
id=name,
description=description,
category="service",
behavior="daemon",
stack=stack,
runner=runner,
port=port,
health_path=health_path,
@@ -117,16 +121,19 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
description = job.description
source = None
stack = None
if job.component and job.component in config.components:
comp = config.components[job.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return ComponentSummary(
id=name,
description=description,
category="job",
behavior="tool",
stack=stack,
runner=job.run.runner,
managed=managed,
systemd=systemd_info,
@@ -137,16 +144,16 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
# Determine category
# Determine behavior
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
category = "tool"
behavior = "tool"
elif is_frontend:
category = "frontend"
behavior = "frontend"
else:
category = "component"
behavior = None
source = comp.source
@@ -167,7 +174,8 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
return ComponentSummary(
id=name,
description=comp.description,
category=category,
behavior=behavior,
stack=comp.stack,
runner=runner,
version=comp.tool.version if comp.tool else None,
source=source,
@@ -232,16 +240,19 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
if ref and ref in config.components:
s.source = config.components[ref].source
# Components (tools/frontends) — always listed, even if a
# service/job with the same name exists. A component is
# "what software exists", services/jobs are "how it runs".
# Components (tools/frontends) not already represented by a
# service or job entry. Skip if the component has no
# distinct behavior (e.g. it's just the software identity
# behind a service).
for name, comp in config.components.items():
if name in seen:
continue
summary = _summary_from_component(name, comp, root)
if summary.behavior is None:
continue
summary.node = local_hostname
# Skip if this exact category is already represented
# (e.g. a deployed tool already in the list)
if not any(s.id == name and s.category == summary.category for s in summaries):
summaries.append(summary)
summaries.append(summary)
seen.add(name)
except FileNotFoundError:
pass
@@ -254,7 +265,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
ComponentSummary(
id=name,
description=d.description,
category=d.category,
behavior=d.behavior,
stack=d.stack,
runner=d.runner,
port=d.port,
health_path=d.health_path,
@@ -306,7 +318,8 @@ def get_component(name: str) -> ComponentDetail:
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"category": deployed.category,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return ComponentDetail(**summary.model_dump(), manifest=raw)

View File

@@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
},
description="Test service",
category="service",
behavior="daemon",
port=19000,
health_path="/health",
proxy_path="/test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True
assert svc["category"] == "service"
assert svc["behavior"] == "daemon"
def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port."""
@@ -42,14 +42,14 @@ class TestComponents:
data = response.json()
tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None
assert tool["category"] == "tool"
assert tool["behavior"] == "tool"
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule."""
response = client.get("/components")
data = response.json()
job = next(c for c in data if c["id"] == "test-job")
assert job["category"] == "job"
assert job["behavior"] == "tool"
assert job["schedule"] == "0 2 * * *"

View File

@@ -16,7 +16,8 @@ def _make_registry() -> NodeRegistry:
run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service",
category="service",
behavior="daemon",
stack="python-fastapi",
port=9001,
health_path="/health",
proxy_path="/my-svc",
@@ -25,7 +26,8 @@ def _make_registry() -> NodeRegistry:
"my-job": DeployedComponent(
runner="command",
run_cmd=["my-job"],
category="job",
behavior="tool",
stack="python-cli",
schedule="0 2 * * *",
),
},
@@ -54,6 +56,8 @@ class TestRegistrySerialization:
assert svc.health_path == "/health"
assert svc.proxy_path == "/my-svc"
assert svc.managed is True
assert svc.behavior == "daemon"
assert svc.stack == "python-fastapi"
def test_job_fields_preserved(self) -> None:
original = _make_registry()
@@ -63,7 +67,8 @@ class TestRegistrySerialization:
job = restored.deployed["my-job"]
assert job.runner == "command"
assert job.schedule == "0 2 * * *"
assert job.category == "job"
assert job.behavior == "tool"
assert job.stack == "python-cli"
def test_optional_fields_omitted(self) -> None:
"""Fields like port, health_path are None when not set."""

View File

@@ -45,7 +45,7 @@ class TestNodesList:
runner="python",
run_cmd=["svc"],
port=9050,
category="service",
behavior="daemon",
),
},
)