Derive program behavior from deployments; stop persisting it

behavior (tool/daemon/frontend) is now a derived display label computed
from a program's deployments — path->tool, static->frontend, process->daemon
(behavior_for_runner). load_config populates it so every reader gets the live
value; _spec_to_yaml_dict excludes it so it is never written to disk.
_summary_from_service derives it from the runner instead of hardcoding daemon.
Fixtures gain path/python deployments so tool/daemon behaviors derive.
This commit is contained in:
2026-07-01 08:46:54 -07:00
parent fa65ae3d1c
commit 00e8d58c6a
6 changed files with 65 additions and 18 deletions

View File

@@ -137,14 +137,24 @@ class CastleConfig:
services: dict[str, ServiceSpec]
jobs: dict[str, JobSpec]
def behavior_of(self, name: str) -> str | None:
"""A program's *derived* behavior label, from how it's deployed:
static service → frontend, path service → tool, process service → daemon,
job-only → tool, no deployment → None. Never a stored value."""
from castle_core.manifest import behavior_for_runner
for svc_name, svc in self.services.items():
if svc_name == name or svc.program == name:
return behavior_for_runner(svc.run.runner)
for job_name, job in self.jobs.items():
if job_name == name or job.program == name:
return "tool" # a program a timer invokes is a tool
return None
@property
def tools(self) -> dict[str, ProgramSpec]:
"""Programs deployed as a PATH tool (a `runner: path` service) — derived
from deployments, not the `behavior` label."""
tool_programs = {
s.program or n for n, s in self.services.items() if s.run.runner == "path"
}
return {k: v for k, v in self.programs.items() if k in tool_programs}
"""Programs deployed as a PATH tool — derived, not a stored label."""
return {k: v for k, v in self.programs.items() if self.behavior_of(k) == "tool"}
@property
def frontends(self) -> dict[str, ProgramSpec]:
@@ -304,7 +314,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
for name, job_data in _load_resource_dir(root / "jobs").items():
jobs[name] = _parse_job(name, job_data)
return CastleConfig(
config = CastleConfig(
root=root,
repo=repo_path,
gateway=gateway,
@@ -312,6 +322,11 @@ def load_config(root: Path | None = None) -> CastleConfig:
services=services,
jobs=jobs,
)
# `behavior` is derived from deployments, never stored — populate it so every
# reader of `program.behavior` gets the live, accurate label.
for pname, prog in config.programs.items():
prog.behavior = config.behavior_of(pname)
return config
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
@@ -348,7 +363,8 @@ _STRUCTURAL_KEYS = {
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
exclude_fields = {"id"}
# `behavior` is derived at load time from deployments — never persisted.
exclude_fields = {"id", "behavior"} if isinstance(spec, ProgramSpec) else {"id"}
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
minimal = spec.model_dump(
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
@@ -380,7 +396,8 @@ def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
return None
result = merge(full, minimal)
return _clean_for_yaml(result)
cleaned = _clean_for_yaml(result)
return cleaned if isinstance(cleaned, dict) else {}
def _program_to_yaml_dict(spec: ProgramSpec, config: CastleConfig) -> dict:

View File

@@ -41,7 +41,7 @@ from castle_core.generators.systemd import (
unit_env_file,
unit_name,
)
from castle_core.manifest import JobSpec, ServiceSpec, manager_for
from castle_core.manifest import JobSpec, ServiceSpec, behavior_for_runner, manager_for
from castle_core.registry import (
REGISTRY_PATH,
Deployment,
@@ -401,7 +401,7 @@ def _build_deployed_service(
env=env,
secret_env_keys=sorted(secret_env),
description=_resolve_description(config, svc),
behavior="daemon",
behavior=behavior_for_runner(run.runner),
stack=stack,
port=port,
health_path=health_path,

View File

@@ -139,6 +139,17 @@ def manager_for(runner: str) -> str:
return _RUNNER_MANAGER.get(runner, "systemd")
# `behavior` (tool/daemon/frontend) is a *derived* descriptive label, computed
# from how a program is deployed — never stored/edited. A static service → a
# frontend; a path install → a tool; anything else running a process → a daemon.
_RUNNER_BEHAVIOR: dict[str, str] = {"static": "frontend", "path": "tool"}
def behavior_for_runner(runner: str) -> str:
"""The display behavior implied by a service's runner."""
return _RUNNER_BEHAVIOR.get(runner, "daemon")
# ---------------------
# Systemd management
# ---------------------