feat: relationship model (requires/repos/predicates) + git sync

A derived, mostly-computed model of how programs, deployments, and repos relate,
plus the git-sync surfaces that motivated it. See docs/relationships.md.

Core:
- `requires: [{kind, ref, version?, bind?}]` on programs + deployments — one
  precondition relation; `system_dependencies` is its `{kind: system}` alias.
  kind fixes meaning + check (system=installed, deployment=exists).
- relations.py: derives repos (git toplevel / monorepo), fan-in, and the
  predicates functional?/fresh?/deployed? — nothing stored.
- env is generated FROM a `{kind: deployment, bind}` requirement (target URL →
  consumer env), never scraped back into one; explicit defaults.env still wins.
- git.py: working-copy status/pull, repo toplevel + remote url.

Surfaces:
- `castle graph` + GET /graph — the relationship diagnostic.
- GET /repos, /repos/{key}/git|sync — repo-scoped sync (a repo is the sync unit;
  a monorepo backs several programs). GET /programs/{name}/git|sync + repo context.
- Dashboard: Graph screen, program-page git status + repo-aware Sync, and a
  monorepo banner on Programs.

Governing principle: predicates are derived; encode only the non-derivable, as a
node or edge property. Pull-only sync — converge stays a separate step.
This commit is contained in:
2026-07-05 10:55:42 -07:00
parent 3c566540aa
commit add356dcf2
24 changed files with 1669 additions and 4 deletions

View File

@@ -283,6 +283,27 @@ class Capability(BaseModel):
meta: dict[str, str] = Field(default_factory=dict)
class Requirement(BaseModel):
"""A precondition — something that must be true for a program/deployment to be
*functional*. The ``kind`` fixes both the meaning and how it's checked (there is
no separate purpose tag):
- ``system`` — a host package/binary must be installed (``ref`` = package).
- ``deployment`` — another deployment must exist/run (``ref`` = its name).
``version`` is reserved for a future constraint (unused now). ``bind`` (for a
``deployment`` requirement) names the env var castle projects the target's URL
into — env is derived *from* the requirement, never scraped back into it.
See docs/relationships.md. ``system_dependencies`` is the ``kind: system`` case.
"""
kind: Literal["system", "deployment"]
ref: str
version: str | None = None
bind: str | None = None
# ---------------------
# Defaults
# ---------------------
@@ -363,6 +384,10 @@ class ProgramSpec(BaseModel):
# Per-program dev verb overrides (declared verbs override the stack default).
commands: CommandsSpec | None = None
# `requires` is the general precondition relation (see docs/relationships.md).
# `system_dependencies` is kept as the `{kind: system}` alias/back-compat; both
# are merged when evaluating what a program requires.
requires: list[Requirement] = Field(default_factory=list)
system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list)
version: str | None = None
@@ -406,6 +431,9 @@ class DeploymentBase(BaseModel):
)
description: str | None = None
defaults: DefaultsSpec | None = None
# Runtime preconditions (e.g. another deployment that must exist). See
# docs/relationships.md; merged with the program's `requires` when evaluated.
requires: list[Requirement] = Field(default_factory=list)
# Declared on/off state. `castle apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served),
# disabled ones are deactivated but kept in the catalog. This is *desired