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

@@ -0,0 +1,30 @@
"""The relationship model as JSON — repos, `requires` edges, derived status.
Read-only diagnostic (see docs/relationships.md). Everything is computed on the
fly: repos from git, predicates (functional/fresh) from config + git. Nothing
stored.
"""
from __future__ import annotations
import dataclasses
from fastapi import APIRouter
from castle_core.relations import build_model
from castle_api.config import get_config
graph_router = APIRouter(tags=["graph"])
@graph_router.get("/graph")
def get_graph() -> dict:
"""The whole relationship model: repos (with freshness), deployment nodes (with
`functional?`), and `requires` edges."""
model = build_model(get_config(), check=True, freshness=True)
return {
"repos": [dataclasses.asdict(r) for r in model.repos],
"nodes": [dataclasses.asdict(n) for n in model.nodes],
"edges": [dataclasses.asdict(e) for e in model.edges],
}

View File

@@ -17,6 +17,8 @@ from castle_api.agents import router as agents_router
from castle_api.config import get_registry, settings
from castle_api.config_editor import router as config_router
from castle_api.deploy_routes import router as deploy_router
from castle_api.graph import graph_router
from castle_api.repos import repos_router
from castle_api.logs import router as logs_router
from castle_api.routes import router as dashboard_router
from castle_api.secrets import router as secrets_router
@@ -127,6 +129,8 @@ app.include_router(services_router)
app.include_router(programs_router)
app.include_router(deploy_router)
app.include_router(agents_router)
app.include_router(graph_router)
app.include_router(repos_router)
@app.get("/health")

View File

@@ -2,10 +2,14 @@
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, HTTPException, status
from castle_core import git
from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream
from castle_api.config import get_config
programs_router = APIRouter(tags=["programs"])
@@ -17,6 +21,88 @@ def list_stacks() -> list[str]:
and keeps it in sync with the backend (no hardcoded frontend list)."""
return available_stacks()
# ---------------------------------------------------------------------------
# Git sync — pull a program's source working copy up to date (pull only; no
# build/apply/restart — converge stays an explicit, separate step). Declared
# BEFORE the generic /{action} route below so the literal `git`/`sync` segments
# win over the `{action}` path param (Starlette matches in declaration order).
# ---------------------------------------------------------------------------
def _program_source(name: str):
"""The (program, config) for a named program, or raise the standard 404/400s."""
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
return config.programs[name], config
@programs_router.get("/programs/{name}/git")
def program_git_status(name: str) -> dict:
"""Git status of a program's working copy (branch, dirty, ahead/behind).
Fetches from the remote first so ``behind`` is current. A program with no
source or a non-git source returns a benign ``{"is_repo": false}`` (not an
error) so the dashboard can simply hide the sync control."""
comp, config = _program_source(name)
if not comp.source:
return {"is_repo": False}
out = asdict(git.git_status(comp.source, fetch=True))
# Repo context: which repo this program's source lives in and who else shares it
# (a monorepo). Sync is a repo operation — the UI labels it and lists siblings.
from castle_core.relations import derive_repos
for key, repo in derive_repos(config).items():
if name in repo.programs:
out["repo"] = {
"key": key,
"programs": repo.programs,
"multi": repo.multi,
"deployments": repo.deployments,
}
break
return out
@programs_router.post("/programs/{name}/sync")
async def program_sync(name: str) -> dict:
"""Fast-forward a program's working copy (``git pull --ff-only``).
Pull-only: it updates the source on disk and reports which deployments may now
need a restart/apply, but does not build, apply, or restart anything itself."""
comp, config = _program_source(name)
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
if not git.is_git_repo(comp.source):
raise HTTPException(
status_code=400, detail=f"'{name}' source is not a git repository"
)
before = git.head(comp.source)
ok, output = git.pull(comp.source)
if not ok:
raise HTTPException(status_code=500, detail=output or "git pull failed")
pulled = git.head(comp.source) != before
deployments = [dname for dname, _ in config.deployments_of(name)]
if pulled:
# Nudge other clients to refresh this program's git status.
await stream.broadcast(
"program-sync", {"program": name, "deployments": deployments}
)
return {
"program": name,
"status": "ok",
"output": output,
"pulled": pulled,
"deployments": deployments if pulled else [],
}
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,83 @@
"""Repo endpoints — a repo (git working copy) is the unit of sync. A monorepo backs
several programs; an adopted program is a repo of one. See docs/relationships.md.
Sync is pull-only (fast-forward); converge (build/apply/restart) stays a separate,
explicit step.
"""
from __future__ import annotations
import dataclasses
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core import git
from castle_core.relations import Repo, derive_repos
from castle_api import stream
from castle_api.config import get_config
repos_router = APIRouter(tags=["repos"])
def _resolve(key: str) -> tuple[Repo, object]:
config = get_config()
repos = derive_repos(config)
if key not in repos:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"repo '{key}' not found"
)
return repos[key], config
@repos_router.get("/repos")
def list_repos() -> list[dict]:
"""Every repo with members and last-known git state (no fetch — fast)."""
out: list[dict] = []
for repo in derive_repos(get_config()).values():
st = git.git_status(Path(repo.path), fetch=False)
out.append(
{
**dataclasses.asdict(repo),
"branch": st.branch,
"behind": st.behind,
"dirty": st.dirty,
}
)
return out
@repos_router.get("/repos/{key}/git")
def repo_git(key: str) -> dict:
"""A repo's git status (fetches, so ``behind`` is current) plus its members."""
repo, _ = _resolve(key)
return {
**dataclasses.asdict(git.git_status(Path(repo.path), fetch=True)),
"key": key,
"programs": repo.programs,
"deployments": repo.deployments,
}
@repos_router.post("/repos/{key}/sync")
async def repo_sync(key: str) -> dict:
"""Fast-forward the repo's working copy. Pull-only — reports which deployments
may now need a restart/apply, but does not converge them."""
repo, _ = _resolve(key)
before = git.head(Path(repo.path))
ok, output = git.pull(Path(repo.path))
if not ok:
raise HTTPException(status_code=500, detail=output or "git pull failed")
pulled = git.head(Path(repo.path)) != before
if pulled:
await stream.broadcast(
"repo-sync", {"repo": key, "deployments": repo.deployments}
)
return {
"repo": key,
"status": "ok",
"output": output,
"pulled": pulled,
"deployments": repo.deployments if pulled else [],
}