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

@@ -42,10 +42,22 @@ interface ProgramActionsProps {
name: string
actions: string[]
active?: boolean | null
behavior?: string | null
/** Names of services/jobs deploying this program — daemons activate via these. */
deployedAs?: string[]
compact?: boolean
onOutput?: (output: ActionOutput) => void
}
/** install/uninstall (activate) is meaningful only for tools (PATH) and static
* frontends (served). A daemon activates through a service/job, so it never
* shows install/uninstall here — its run controls live on the deployment page. */
function showsActivation(behavior: string | null | undefined, deployedAs: string[]): boolean {
if (behavior === "daemon") return false
if (behavior === "frontend") return deployedAs.length === 0 // self-serving frontend → its service
return true // tools (and unspecified)
}
function visibleActions(
actions: string[],
active: boolean | null | undefined,
@@ -78,11 +90,23 @@ function visibleActions(
return visible
}
export function ProgramActions({ name, actions, active, compact, onOutput }: ProgramActionsProps) {
export function ProgramActions({
name,
actions,
active,
behavior,
deployedAs = [],
compact,
onOutput,
}: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null)
const visible = visibleActions(actions, active, !!compact)
// Drop install/uninstall for behaviors that activate via a deployment.
const allowed = showsActivation(behavior, deployedAs)
? actions
: actions.filter((a) => a !== "install" && a !== "uninstall")
const visible = visibleActions(allowed, active, !!compact)
const handleAction = (action: string) => {
setRunningAction(action)