Stage 3: App — program/deployment model & controls

- ProgramActions: install/uninstall (activate) shown only for tools and
  no-service frontends. Daemons activate via a service/job, so the program
  page no longer offers a misleading Install/Uninstall (which secretly
  enabled/disabled the service).
- Program page: new Deployments section listing the services/jobs that run
  the program, as links (a program → 0-N services/jobs).
- Service page: 'Runs' row from the run_target summary field (the old code
  dug into manifest.run, which was empty pre-Stage-1), a Host row for
  proxy_host, and a convenience link to the referenced program.
- lifecycle.activate on a daemon with no service now errors toward
  'castle expose' instead of silently installing its binary to PATH.

core 94 green; ruff + app build clean.
This commit is contained in:
2026-06-14 15:37:20 -07:00
parent 73698fafe7
commit dea585b15b
6 changed files with 132 additions and 13 deletions

View File

@@ -155,6 +155,17 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
if comp is not None and comp.behavior == "frontend":
return await run_action("install", name, comp, root)
# A daemon with no service can't be "activated" — installing its binary to
# PATH doesn't run it. Direct the user to declare a service instead.
if comp is not None and comp.behavior == "daemon":
return ActionResult(
name,
"activate",
"error",
f"'{name}' is a daemon with no service. Run "
f"'castle expose {name} --port <PORT>' to deploy it as a service.",
)
# Tool: install to PATH.
if comp is not None:
return await run_action("install", name, comp, root)