Loading...
@@ -85,6 +111,18 @@ export function ProgramDetailPage() {
>
)}
+ {deployment.repo && git.data?.is_repo && (
+ <>
+
+
+
{isLoading ? (
Loading...
) : programs && programs.length > 0 ? (
diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx
index 29ebb9a..5d33bcc 100644
--- a/app/src/router/routes.tsx
+++ b/app/src/router/routes.tsx
@@ -7,6 +7,7 @@ import { Tools } from "@/pages/Tools"
import { Programs } from "@/pages/Programs"
import { GatewayPage } from "@/pages/GatewayPage"
import { MeshPage } from "@/pages/MeshPage"
+import { GraphPage } from "@/pages/GraphPage"
import { ServiceDetailPage } from "@/pages/ServiceDetail"
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
import { ToolDetailPage } from "@/pages/ToolDetail"
@@ -26,6 +27,7 @@ export const router = createBrowserRouter([
{ path: "programs", element:
},
{ path: "gateway", element:
},
{ path: "mesh", element:
},
+ { path: "graph", element:
},
{ path: "services/:name", element:
},
{ path: "jobs/:name", element:
},
{ path: "tools/:name", element:
},
diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts
index 7a18157..d063490 100644
--- a/app/src/services/api/hooks.ts
+++ b/app/src/services/api/hooks.ts
@@ -9,6 +9,10 @@ import type {
JobDetail,
ProgramSummary,
ProgramDetail,
+ GitStatus,
+ GraphModel,
+ RepoSummary,
+ ProgramSyncResponse,
StatusResponse,
GatewayInfo,
GatewayConfigRequest,
@@ -226,6 +230,67 @@ export function useProgramAction() {
})
}
+// Git status of a program's working copy. The backend fetches from the remote,
+// so this call is comparatively slow — hence its own query (not part of the
+// program detail) with a short staleTime. `enabled` lets the caller skip it for
+// programs with no repo.
+export function useProgramGit(name: string, enabled = true) {
+ return useQuery({
+ queryKey: ["programs", name, "git"],
+ queryFn: () => apiClient.get
(`/programs/${name}/git`),
+ enabled: enabled && !!name,
+ staleTime: 30_000,
+ })
+}
+
+// Fast-forward a program's source (git pull). Pull-only — converge (restart/apply)
+// stays a separate, explicit step. Refreshes the program and its git status.
+export function useProgramSync() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (name: string) =>
+ apiClient.post(`/programs/${name}/sync`),
+ onSuccess: (_data, name) => {
+ qc.invalidateQueries({ queryKey: ["programs", name, "git"] })
+ qc.invalidateQueries({ queryKey: ["programs"] })
+ },
+ })
+}
+
+export function useRepos() {
+ return useQuery({
+ queryKey: ["repos"],
+ queryFn: () => apiClient.get("/repos"),
+ staleTime: 30_000,
+ })
+}
+
+// Fast-forward a whole repo (git pull the working copy). Pull-only — converge is
+// separate. Refreshes repos, programs, and their git status.
+export function useRepoSync() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (key: string) =>
+ apiClient.post(`/repos/${key}/sync`),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["repos"] })
+ qc.invalidateQueries({ queryKey: ["programs"] })
+ qc.invalidateQueries({ queryKey: ["graph"] })
+ },
+ })
+}
+
+// The derived relationship model (repos, requires edges, functional/fresh status).
+// Fetches git status per repo server-side, so it's comparatively slow — its own
+// query with a modest staleTime.
+export function useGraph() {
+ return useQuery({
+ queryKey: ["graph"],
+ queryFn: () => apiClient.get("/graph"),
+ staleTime: 30_000,
+ })
+}
+
export function useSaveGatewayConfig() {
const qc = useQueryClient()
return useMutation({
@@ -312,6 +377,12 @@ export function useEventStream() {
qc.invalidateQueries({ queryKey: ["jobs"] })
})
+ es.addEventListener("program-sync", () => {
+ // A program's source was pulled (possibly by another client) — refresh
+ // programs and their git status.
+ qc.invalidateQueries({ queryKey: ["programs"] })
+ })
+
es.addEventListener("mesh", () => {
// A remote node updated or went offline — refresh mesh, nodes, and gateway
qc.invalidateQueries({ queryKey: ["mesh"] })
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index c6de932..26e8e5d 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -74,6 +74,85 @@ export interface ProgramDetail extends ProgramSummary {
manifest: Record
}
+// Git state of a program's source working copy (GET /programs/{name}/git).
+// ahead/behind are relative to the upstream tracking branch (null = no upstream);
+// behind reflects the fetch the status call performed.
+export interface GitStatus {
+ is_repo: boolean
+ branch: string | null
+ upstream: string | null
+ dirty: boolean
+ ahead: number | null
+ behind: number | null
+ detached: boolean
+ error: string | null
+ // The repo (git working copy) this program's source lives in. `multi` marks a
+ // monorepo shared by several programs — sync operates on the whole repo.
+ repo?: {
+ key: string
+ programs: string[]
+ multi: boolean
+ deployments: string[]
+ } | null
+}
+
+// GET /repos — a repo (git working copy) with its members and last-known git state.
+export interface RepoSummary {
+ key: string
+ path: string
+ url: string | null
+ ref: string | null
+ programs: string[]
+ deployments: string[]
+ branch: string | null
+ behind: number | null
+ dirty: boolean
+}
+
+// GET /graph — the derived relationship model (docs/relationships.md).
+export interface GraphRepo {
+ key: string
+ path: string
+ url: string | null
+ ref: string | null
+ programs: string[]
+ deployments: string[]
+ behind: number | null
+ dirty: boolean
+ fresh: boolean | null
+}
+export interface GraphNode {
+ name: string
+ program: string | null
+ kind: string
+ repo: string | null
+ depended_on_by: number
+ unmet: string[]
+ functional: boolean
+ fresh: boolean | null
+ deployed: boolean | null
+}
+export interface GraphEdge {
+ src: string
+ dst: string
+ kind: "system" | "deployment"
+ bind: string | null
+}
+export interface GraphModel {
+ repos: GraphRepo[]
+ nodes: GraphNode[]
+ edges: GraphEdge[]
+}
+
+// POST /programs/{name}/sync — a fast-forward pull (no build/apply/restart).
+export interface ProgramSyncResponse {
+ program: string
+ status: string
+ output: string
+ pulled: boolean
+ deployments: string[] // affected deployments that may need restart/apply
+}
+
// Union for the shared ConfigPanel (ProgramFields / ServiceFields / JobFields)
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
diff --git a/castle-api/src/castle_api/graph.py b/castle-api/src/castle_api/graph.py
new file mode 100644
index 0000000..d74afd3
--- /dev/null
+++ b/castle-api/src/castle_api/graph.py
@@ -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],
+ }
diff --git a/castle-api/src/castle_api/main.py b/castle-api/src/castle_api/main.py
index 9487192..7a71039 100644
--- a/castle-api/src/castle_api/main.py
+++ b/castle-api/src/castle_api/main.py
@@ -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")
diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py
index 77abadd..5c1ad3a 100644
--- a/castle-api/src/castle_api/programs.py
+++ b/castle-api/src/castle_api/programs.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/castle-api/src/castle_api/repos.py b/castle-api/src/castle_api/repos.py
new file mode 100644
index 0000000..b18a016
--- /dev/null
+++ b/castle-api/src/castle_api/repos.py
@@ -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 [],
+ }
diff --git a/castle-api/tests/test_graph_repos.py b/castle-api/tests/test_graph_repos.py
new file mode 100644
index 0000000..7dd5d29
--- /dev/null
+++ b/castle-api/tests/test_graph_repos.py
@@ -0,0 +1,63 @@
+"""Tests for the /graph diagnostic and /repos (repo-scoped sync) endpoints."""
+
+import os
+import subprocess
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+_ENV = {
+ "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
+ "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
+ "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
+}
+
+
+def _git(cwd: Path, *args: str) -> None:
+ subprocess.run(["git", "-C", str(cwd), *args], check=True,
+ capture_output=True, text=True, env={**os.environ, **_ENV})
+
+
+def _commit(cwd: Path, fname: str) -> None:
+ (cwd / fname).write_text(fname)
+ _git(cwd, "add", fname)
+ _git(cwd, "commit", "-m", f"add {fname}")
+
+
+def _setup(work: Path) -> None:
+ upstream = work.parent / f"{work.name}-upstream"
+ upstream.mkdir()
+ _git(upstream, "init", "-q", "-b", "main")
+ _commit(upstream, "a.txt")
+ _git(work.parent, "clone", "-q", str(upstream), str(work))
+
+
+class TestGraph:
+ def test_graph_shape(self, client: TestClient) -> None:
+ resp = client.get("/graph")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert {"repos", "nodes", "edges"} <= body.keys()
+ # every node carries the derived predicates
+ assert all("functional" in n for n in body["nodes"])
+
+
+class TestRepos:
+ def test_list_and_sync(self, client: TestClient, castle_root: Path) -> None:
+ _setup(castle_root / "wired-in") # program `wired-in` source → /wired-in
+ _commit(castle_root / "wired-in-upstream", "b.txt") # advance remote
+
+ repos = {r["key"]: r for r in client.get("/repos").json()}
+ assert "wired-in" in repos
+ assert "wired-in" in repos["wired-in"]["programs"]
+
+ gitinfo = client.get("/repos/wired-in/git").json()
+ assert gitinfo["is_repo"] is True and gitinfo["behind"] == 1
+
+ synced = client.post("/repos/wired-in/sync").json()
+ assert synced["pulled"] is True and "wired-in" in synced["deployments"]
+ assert (castle_root / "wired-in" / "b.txt").exists()
+
+ def test_unknown_repo_404(self, client: TestClient) -> None:
+ assert client.get("/repos/nope/git").status_code == 404
+ assert client.post("/repos/nope/sync").status_code == 404
diff --git a/castle-api/tests/test_programs_git.py b/castle-api/tests/test_programs_git.py
new file mode 100644
index 0000000..427c2a4
--- /dev/null
+++ b/castle-api/tests/test_programs_git.py
@@ -0,0 +1,79 @@
+"""Tests for the program git-status / sync endpoints."""
+
+import os
+import subprocess
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+_ENV = {
+ "GIT_AUTHOR_NAME": "t",
+ "GIT_AUTHOR_EMAIL": "t@t",
+ "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@t",
+ "GIT_CONFIG_GLOBAL": "/dev/null",
+ "GIT_CONFIG_SYSTEM": "/dev/null",
+}
+
+
+def _git(cwd: Path, *args: str) -> None:
+ subprocess.run(
+ ["git", "-C", str(cwd), *args],
+ check=True,
+ capture_output=True,
+ text=True,
+ env={**os.environ, **_ENV},
+ )
+
+
+def _commit(cwd: Path, fname: str) -> None:
+ (cwd / fname).write_text(fname)
+ _git(cwd, "add", fname)
+ _git(cwd, "commit", "-m", f"add {fname}")
+
+
+def _setup_git_program(work: Path) -> None:
+ """Make `work` a git clone tracking an `-upstream` repo with one commit."""
+ upstream = work.parent / f"{work.name}-upstream"
+ upstream.mkdir()
+ _git(upstream, "init", "-q", "-b", "main")
+ _commit(upstream, "a.txt")
+ _git(work.parent, "clone", "-q", str(upstream), str(work))
+
+
+class TestProgramGit:
+ def test_non_git_source_is_benign(self, client: TestClient) -> None:
+ """A program whose source isn't a git repo returns is_repo:false, not 500."""
+ resp = client.get("/programs/test-tool/git")
+ assert resp.status_code == 200
+ assert resp.json()["is_repo"] is False
+
+ def test_status_behind_then_sync_pulls(
+ self, client: TestClient, castle_root: Path
+ ) -> None:
+ work = castle_root / "wired-in" # source: "wired-in" → /wired-in
+ _setup_git_program(work)
+ _commit(work.parent / "wired-in-upstream", "b.txt") # advance the remote
+
+ status = client.get("/programs/wired-in/git")
+ assert status.status_code == 200
+ body = status.json()
+ assert body["is_repo"] is True and body["branch"] == "main"
+ assert body["behind"] == 1 and body["ahead"] == 0
+
+ synced = client.post("/programs/wired-in/sync")
+ assert synced.status_code == 200
+ s = synced.json()
+ assert s["status"] == "ok" and s["pulled"] is True
+ assert "wired-in" in s["deployments"] # affected deployment surfaced
+ assert (work / "b.txt").exists()
+
+ # A second sync is a no-op: nothing pulled, no affected deployments.
+ again = client.post("/programs/wired-in/sync").json()
+ assert again["pulled"] is False and again["deployments"] == []
+
+ def test_sync_unknown_program_404(self, client: TestClient) -> None:
+ assert client.post("/programs/nope/sync").status_code == 404
+
+ def test_sync_non_git_source_400(self, client: TestClient) -> None:
+ assert client.post("/programs/test-tool/sync").status_code == 400
diff --git a/cli/src/castle_cli/commands/graph.py b/cli/src/castle_cli/commands/graph.py
new file mode 100644
index 0000000..ba77b67
--- /dev/null
+++ b/cli/src/castle_cli/commands/graph.py
@@ -0,0 +1,68 @@
+"""castle graph — the relationship model: repos, `requires` edges, derived status.
+
+A read-only diagnostic (see docs/relationships.md). Nothing here is stored — repos
+come from git, predicates (functional/fresh/deployed) are computed on the fly.
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import json
+
+from castle_cli.config import load_config
+
+BOLD, DIM, RESET = "\033[1m", "\033[2m", "\033[0m"
+GREEN, RED, YELLOW, CYAN = "\033[32m", "\033[31m", "\033[33m", "\033[36m"
+
+
+def run_graph(args: argparse.Namespace) -> int:
+ from castle_core.relations import build_model
+
+ config = load_config()
+ model = build_model(config, check=True, freshness=True)
+
+ if getattr(args, "json", False):
+ print(
+ json.dumps(
+ {
+ "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],
+ },
+ indent=2,
+ )
+ )
+ return 0
+
+ monos = [r for r in model.repos if r.multi]
+ print(f"{BOLD}Repos{RESET} ({len(model.repos)}, {len(monos)} monorepo)")
+ for r in monos:
+ fresh = (
+ ""
+ if r.fresh is None
+ else (f" {GREEN}fresh{RESET}" if r.fresh else f" {YELLOW}stale{RESET}")
+ )
+ print(f" {CYAN}{r.key}{RESET}{fresh} {DIM}→ {', '.join(r.programs)}{RESET}")
+
+ edges = [e for e in model.edges if e.kind == "deployment"]
+ print(f"\n{BOLD}requires{RESET} (deployment → deployment): {len(edges)}")
+ for e in edges:
+ bind = f" {DIM}→ ${e.bind}{RESET}" if e.bind else ""
+ print(f" {e.src} {DIM}requires{RESET} {e.dst}{bind}")
+ if not edges:
+ print(f" {DIM}(none declared — front-end/back-end deps have no encoded edge yet){RESET}")
+
+ unhealthy = [n for n in model.nodes if not n.functional]
+ print(f"\n{BOLD}functional?{RESET} — {len(unhealthy)} with unmet requirements")
+ for n in unhealthy:
+ print(f" {RED}✗{RESET} {n.name} {DIM}unmet: {', '.join(n.unmet)}{RESET}")
+ if not unhealthy:
+ print(f" {GREEN}✓ all functional{RESET}")
+
+ depended = sorted((n for n in model.nodes if n.depended_on_by), key=lambda n: -n.depended_on_by)
+ if depended:
+ print(f"\n{BOLD}widely depended-on{RESET}")
+ for n in depended:
+ print(f" {n.name} {DIM}← {n.depended_on_by} dependent(s){RESET}")
+ return 0
diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py
index c6ad78d..109772a 100644
--- a/cli/src/castle_cli/main.py
+++ b/cli/src/castle_cli/main.py
@@ -193,6 +193,12 @@ def build_parser() -> argparse.ArgumentParser:
"doctor", help="Diagnose setup + runtime health, with next-step hints"
)
+ # Relationship model — repos, requires edges, and derived status.
+ p = subparsers.add_parser(
+ "graph", help="Show how programs/deployments relate (repos, requires, status)"
+ )
+ p.add_argument("--json", action="store_true", help="Output as JSON")
+
# Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, jobs, and tools")
p.add_argument(
@@ -335,6 +341,10 @@ def main() -> int:
from castle_cli.commands.doctor import run_doctor
return run_doctor(args)
+ if cmd == "graph":
+ from castle_cli.commands.graph import run_graph
+
+ return run_graph(args)
if cmd == "list":
from castle_cli.commands.list_cmd import run_list
diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py
index 537b17f..5f31233 100644
--- a/core/src/castle_core/deploy.py
+++ b/core/src/castle_core/deploy.py
@@ -244,7 +244,9 @@ def apply(
return "restart"
return "unchanged"
- result = ApplyResult(registry=NodeRegistry(node=_node_config(config), deployed=desired))
+ result = ApplyResult(
+ registry=NodeRegistry(node=_node_config(config), deployed=desired)
+ )
if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string
@@ -469,6 +471,36 @@ def _public_url(
return None
+def _target_url(config: CastleConfig, target_name: str) -> str | None:
+ """The base URL another deployment is reachable at — how a ``{kind: deployment,
+ bind: VAR}`` requirement projects its target into the consumer's env."""
+ dep = config.deployments.get(target_name)
+ if dep is None:
+ return None
+ expose = getattr(dep, "expose", None)
+ http = getattr(expose, "http", None) if expose else None
+ tport = http.internal.port if http else None
+ return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
+
+
+def _requires_env(config: CastleConfig, name: str, config_key: str) -> dict[str, str]:
+ """Env generated FROM a deployment's ``requires`` — a ``{kind: deployment,
+ bind: VAR}`` requirement sets ``VAR`` to the target's URL. Env is derived from
+ the dependency, never scraped back into one (see docs/relationships.md)."""
+ dep = config.deployments[name]
+ prog = config.programs.get(config_key)
+ reqs = list(getattr(dep, "requires", []) or [])
+ if prog:
+ reqs += list(prog.requires)
+ out: dict[str, str] = {}
+ for r in reqs:
+ if r.kind == "deployment" and r.bind:
+ url = _target_url(config, r.ref)
+ if url:
+ out[r.bind] = url
+ return out
+
+
def _supabase_app_schemas(config: CastleConfig) -> str:
"""The ``${supabase_app_schemas}`` placeholder: each registered supabase app's
own schema, comma-prefixed and joined (or '' when there are none).
@@ -621,8 +653,14 @@ def _build_deployed(
# names to castle's computed values. Secret-bearing vars split out to a
# mode-0600 file (never in the unit or argv).
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
+ # Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
+ # An explicit defaults.env value always wins — a hand-set var is never clobbered.
+ for var, url in _requires_env(config, name, config_key).items():
+ raw_env.setdefault(var, url)
public_url = _public_url(config, name, expose, port)
- ctx = _env_context(name, config_key, port, public_url, _supabase_app_schemas(config))
+ ctx = _env_context(
+ name, config_key, port, public_url, _supabase_app_schemas(config)
+ )
# ${tls_*}: paths to castle-materialized cert files for a TLS-material TCP
# service. The deployment maps them into its own config (mount ${tls_dir} for a
# container, or reference ${tls_cert}/${tls_key} directly for a native service).
@@ -647,7 +685,12 @@ def _build_deployed(
_ensure_python_tool(config, dep.program, messages)
run_cmd = _build_run_cmd(
- name, run, env, messages, source_dir, secret_env_file=secret_env_file,
+ name,
+ run,
+ env,
+ messages,
+ source_dir,
+ secret_env_file=secret_env_file,
placeholders=ctx,
)
stop_cmd = _build_stop_cmd(name, run, source_dir)
diff --git a/core/src/castle_core/git.py b/core/src/castle_core/git.py
new file mode 100644
index 0000000..c328234
--- /dev/null
+++ b/core/src/castle_core/git.py
@@ -0,0 +1,159 @@
+"""Git working-copy status and sync for programs whose source is a git repo.
+
+Programs that declare a ``repo:`` URL are cloned once (``castle program clone``);
+this module lets a running castle *see how far behind* a working copy is and pull
+later updates. It is intentionally pull-only — it touches files on disk and never
+builds, applies, or restarts anything. Making the running artifact reflect the new
+code (rebuild a frontend, restart a service) stays an explicit, separate step via
+``castle apply`` / ``castle restart``.
+
+Plain ``git`` via subprocess (matching ``castle program clone``); no GitPython.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+
+# Bound network calls (fetch/pull) so an unreachable remote can't hang a request.
+_FETCH_TIMEOUT = 20.0
+_PULL_TIMEOUT = 60.0
+
+
+@dataclass
+class GitStatus:
+ """A program working copy's git state. ``ahead``/``behind`` are relative to the
+ upstream tracking branch and reflect the *last fetch* (``git_status(fetch=True)``
+ refreshes them). ``None`` counts mean "no upstream to compare against"."""
+
+ is_repo: bool
+ branch: str | None = None
+ upstream: str | None = None
+ dirty: bool = False
+ ahead: int | None = None
+ behind: int | None = None
+ detached: bool = False
+ error: str | None = None
+
+
+def _git(
+ source: Path, *args: str, timeout: float | None = None
+) -> subprocess.CompletedProcess[str]:
+ """Run ``git -C `` capturing text output."""
+ return subprocess.run(
+ ["git", "-C", str(source), *args],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+
+
+def is_git_repo(source: Path | None) -> bool:
+ """True when ``source`` is inside a git working tree."""
+ if not source or not Path(source).is_dir():
+ return False
+ try:
+ r = _git(
+ Path(source), "rev-parse", "--is-inside-work-tree", timeout=_FETCH_TIMEOUT
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return r.returncode == 0 and r.stdout.strip() == "true"
+
+
+def toplevel(source: Path | None) -> str | None:
+ """The absolute path of the git working copy ``source`` lives in, or None.
+
+ The natural identity of a *repo*: several programs whose sources share a
+ toplevel are the same working copy (a monorepo). Adopted single-program repos
+ are their own toplevel — the N=1 case."""
+ if not source or not Path(source).is_dir():
+ return None
+ try:
+ r = _git(Path(source), "rev-parse", "--show-toplevel", timeout=_FETCH_TIMEOUT)
+ except (OSError, subprocess.SubprocessError):
+ return None
+ return r.stdout.strip() or None if r.returncode == 0 else None
+
+
+def remote_url(source: Path | None) -> str | None:
+ """The ``origin`` remote URL of the working copy, or None (no remote)."""
+ if not is_git_repo(source):
+ return None
+ r = _git(Path(source), "remote", "get-url", "origin") # type: ignore[arg-type]
+ return r.stdout.strip() or None if r.returncode == 0 else None
+
+
+def git_status(source: Path | None, fetch: bool = True) -> GitStatus:
+ """The working copy's branch/dirty/ahead/behind state.
+
+ ``fetch=True`` runs ``git fetch`` first (bounded, tolerant of an offline remote)
+ so ``behind`` reflects the real remote; on fetch failure the counts fall back to
+ the last-known values and ``error`` carries the reason. Never raises — a
+ non-repo returns ``GitStatus(is_repo=False)`` so callers can just hide the UI.
+ """
+ if not is_git_repo(source):
+ return GitStatus(is_repo=False)
+ src = Path(source) # type: ignore[arg-type]
+ st = GitStatus(is_repo=True)
+
+ # Branch (or detached HEAD).
+ branch = _git(src, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
+ if branch == "HEAD":
+ st.detached = True
+ else:
+ st.branch = branch
+
+ # Dirty working tree (staged, unstaged, or untracked).
+ st.dirty = bool(_git(src, "status", "--porcelain").stdout.strip())
+
+ # Best-effort refresh from the remote; failure is non-fatal (offline, no remote).
+ if fetch:
+ try:
+ fr = _git(src, "fetch", "--quiet", timeout=_FETCH_TIMEOUT)
+ if fr.returncode != 0:
+ st.error = (fr.stderr or fr.stdout).strip() or "git fetch failed"
+ except subprocess.TimeoutExpired:
+ st.error = "git fetch timed out"
+ except (OSError, subprocess.SubprocessError) as e:
+ st.error = str(e)
+
+ # Upstream tracking branch, then the ahead/behind split against it.
+ up = _git(src, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
+ if up.returncode == 0 and up.stdout.strip():
+ st.upstream = up.stdout.strip()
+ counts = _git(src, "rev-list", "--left-right", "--count", "@{u}...HEAD")
+ if counts.returncode == 0:
+ parts = counts.stdout.split()
+ if len(parts) == 2:
+ st.behind, st.ahead = int(parts[0]), int(parts[1])
+ return st
+
+
+def head(source: Path | None) -> str | None:
+ """The working copy's current commit sha, or None if unavailable. Lets a caller
+ tell whether a ``pull`` actually advanced the tree (before != after)."""
+ if not is_git_repo(source):
+ return None
+ r = _git(Path(source), "rev-parse", "HEAD") # type: ignore[arg-type]
+ return r.stdout.strip() or None if r.returncode == 0 else None
+
+
+def pull(source: Path | None) -> tuple[bool, str]:
+ """Fast-forward the working copy to its upstream (``git pull --ff-only``).
+
+ ``--ff-only`` is deliberate: it refuses to merge, so a dirty or diverged tree
+ fails cleanly with git's own message instead of creating a merge commit. Returns
+ ``(ok, combined_output)``.
+ """
+ if not is_git_repo(source):
+ return False, "not a git repository"
+ try:
+ r = _git(Path(source), "pull", "--ff-only", timeout=_PULL_TIMEOUT) # type: ignore[arg-type]
+ except subprocess.TimeoutExpired:
+ return False, "git pull timed out"
+ except (OSError, subprocess.SubprocessError) as e:
+ return False, str(e)
+ out = (r.stdout + r.stderr).strip()
+ return r.returncode == 0, out
diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py
index a4f87a3..ad60ff3 100644
--- a/core/src/castle_core/manifest.py
+++ b/core/src/castle_core/manifest.py
@@ -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
diff --git a/core/src/castle_core/relations.py b/core/src/castle_core/relations.py
new file mode 100644
index 0000000..e557515
--- /dev/null
+++ b/core/src/castle_core/relations.py
@@ -0,0 +1,204 @@
+"""The relationship model — derived, never stored. See docs/relationships.md.
+
+Entities: **program**, **deployment**, **repo** (a repo is a git working copy;
+programs sharing a toplevel form a monorepo). One encoded relation, **`requires`**
+(a precondition, typed by ``kind``: ``system`` = must be installed, ``deployment``
+= must exist). Everything else — repos, env wiring, fan-in, and the predicates
+``functional?`` / ``fresh?`` / ``deployed?`` — is computed here on demand.
+
+Governing rule: *predicates are derived; we encode only the non-derivable.* So this
+module reads the encoded ``requires`` (plus ``system_dependencies`` as its
+``kind: system`` alias) and derives the rest. It does **not** scrape env for
+dependencies — env is generated *from* requirements, not the reverse.
+"""
+
+from __future__ import annotations
+
+import shutil
+from collections import Counter
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from castle_core import git
+from castle_core.config import CastleConfig
+from castle_core.manifest import Requirement
+
+
+@dataclass
+class Repo:
+ key: str # url-safe slug (basename of the working copy)
+ path: str # git toplevel
+ url: str | None
+ ref: str | None
+ programs: list[str]
+ deployments: list[str]
+ behind: int | None = None # commits behind upstream (None = unknown/no upstream)
+ dirty: bool = False
+ fresh: bool | None = None # derived: at latest and clean (None = not evaluated)
+
+ @property
+ def multi(self) -> bool:
+ """A monorepo — more than one program shares this working copy."""
+ return len(self.programs) > 1
+
+
+@dataclass
+class Edge:
+ src: str # deployment name
+ dst: str # target: a package (system) or another deployment
+ kind: str # "system" | "deployment"
+ bind: str | None = None # env var to project the target URL into (deployment)
+
+
+@dataclass
+class Node:
+ name: str # deployment name
+ program: str | None
+ kind: str # service|job|tool|static|reference
+ repo: str | None
+ depended_on_by: int # distinct deployments that require this one (fan-in)
+ unmet: list[str] = field(default_factory=list) # unsatisfied requirements
+ functional: bool = True # derived: all requirements satisfied
+ fresh: bool | None = None # derived: its repo is at latest + clean
+ deployed: bool | None = None # derived: active in the registry (None = unknown)
+
+
+@dataclass
+class Model:
+ repos: list[Repo] = field(default_factory=list)
+ nodes: list[Node] = field(default_factory=list)
+ edges: list[Edge] = field(default_factory=list)
+
+
+def _program_of(name: str, dep: object) -> str:
+ return getattr(dep, "program", None) or name
+
+
+def _slug(name: str, used: set[str]) -> str:
+ base = name or "repo"
+ key, n = base, 2
+ while key in used:
+ key, n = f"{base}-{n}", n + 1
+ used.add(key)
+ return key
+
+
+def derive_repos(config: CastleConfig) -> dict[str, Repo]:
+ """Group programs by the git working copy their source lives in."""
+ by_top: dict[str, list[str]] = {}
+ for pname, prog in config.programs.items():
+ top = git.toplevel(prog.source) if prog.source else None
+ if top:
+ by_top.setdefault(top, []).append(pname)
+
+ used: set[str] = set()
+ repos: dict[str, Repo] = {}
+ for top, progs in sorted(by_top.items()):
+ progs = sorted(progs)
+ url = next(
+ (config.programs[p].repo for p in progs if config.programs[p].repo), None
+ ) or git.remote_url(Path(top))
+ ref = (
+ next(
+ (config.programs[p].ref for p in progs if config.programs[p].ref), None
+ )
+ or git.git_status(Path(top), fetch=False).branch
+ )
+ deps = sorted(
+ d for d, dep in config.deployments.items() if _program_of(d, dep) in progs
+ )
+ repos[_slug(Path(top).name, used)] = Repo("", top, url, ref, progs, deps)
+ for key, repo in repos.items():
+ repo.key = key
+ return repos
+
+
+def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
+ """The full requirement set for a deployment: its own ``requires`` plus its
+ program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias),
+ de-duplicated by (kind, ref)."""
+ dep = config.deployments[dep_name]
+ prog = config.programs.get(_program_of(dep_name, dep))
+ reqs: list[Requirement] = list(getattr(dep, "requires", []) or [])
+ if prog:
+ reqs += list(prog.requires)
+ reqs += [
+ Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
+ ]
+ seen: set[tuple[str, str]] = set()
+ out: list[Requirement] = []
+ for r in reqs:
+ if (r.kind, r.ref) not in seen:
+ seen.add((r.kind, r.ref))
+ out.append(r)
+ return out
+
+
+def _check(config: CastleConfig, req: Requirement) -> bool:
+ """Is a single requirement satisfied? (The check is fixed by its kind.)"""
+ if req.kind == "system":
+ return shutil.which(req.ref) is not None
+ if req.kind == "deployment":
+ return req.ref in config.deployments
+ return True
+
+
+def build_model(
+ config: CastleConfig,
+ check: bool = True,
+ active: set[str] | None = None,
+ freshness: bool = False,
+) -> Model:
+ """Compute the relationship model.
+
+ - ``check`` (default): evaluate ``functional?`` (unmet requirements) via a live
+ ``which`` / registry probe. ``check=False`` → pure structural model.
+ - ``active``: names of currently-active deployments → the ``deployed?``
+ predicate (left ``None`` when the caller has no runtime view).
+ - ``freshness``: also evaluate ``fresh?`` per repo (a ``git status``, no fetch —
+ last-known — so it stays a local, network-free probe over many repos)."""
+ from castle_core.manifest import kind_for
+
+ repos = derive_repos(config)
+ if freshness:
+ for repo in repos.values():
+ st = git.git_status(Path(repo.path), fetch=False)
+ repo.behind = st.behind
+ repo.dirty = st.dirty
+ repo.fresh = (st.behind == 0 or st.behind is None) and not st.dirty
+ repo_of = {p: key for key, r in repos.items() for p in r.programs}
+ fresh_of = {key: r.fresh for key, r in repos.items()}
+
+ edges: list[Edge] = []
+ for name in config.deployments:
+ for r in requirements_of(config, name):
+ edges.append(Edge(name, r.ref, r.kind, r.bind))
+
+ fan_in = Counter(e.dst for e in edges if e.kind == "deployment")
+
+ nodes: list[Node] = []
+ for name, dep in config.deployments.items():
+ unmet = (
+ [
+ f"{r.kind}:{r.ref}"
+ for r in requirements_of(config, name)
+ if not _check(config, r)
+ ]
+ if check
+ else []
+ )
+ repo_key = repo_of.get(_program_of(name, dep))
+ nodes.append(
+ Node(
+ name=name,
+ program=_program_of(name, dep),
+ kind=kind_for(dep),
+ repo=repo_key,
+ depended_on_by=fan_in.get(name, 0),
+ unmet=unmet,
+ functional=not unmet,
+ fresh=fresh_of.get(repo_key) if (freshness and repo_key) else None,
+ deployed=(name in active) if active is not None else None,
+ )
+ )
+ return Model(repos=list(repos.values()), nodes=nodes, edges=edges)
diff --git a/core/tests/test_git.py b/core/tests/test_git.py
new file mode 100644
index 0000000..149c3e1
--- /dev/null
+++ b/core/tests/test_git.py
@@ -0,0 +1,123 @@
+"""Tests for git working-copy status/sync (core/src/castle_core/git.py)."""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from castle_core import git as G
+
+# Identity so commits succeed without touching the user's global git config.
+_ENV = {
+ "GIT_AUTHOR_NAME": "t",
+ "GIT_AUTHOR_EMAIL": "t@t",
+ "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@t",
+ "GIT_CONFIG_GLOBAL": "/dev/null",
+ "GIT_CONFIG_SYSTEM": "/dev/null",
+}
+
+
+def _git(cwd: Path, *args: str) -> None:
+ subprocess.run(
+ ["git", "-C", str(cwd), *args],
+ check=True,
+ capture_output=True,
+ text=True,
+ env={**_base_env(), **_ENV},
+ )
+
+
+def _base_env() -> dict[str, str]:
+ import os
+
+ return dict(os.environ)
+
+
+def _commit(cwd: Path, fname: str, text: str) -> None:
+ (cwd / fname).write_text(text)
+ _git(cwd, "add", fname)
+ _git(cwd, "commit", "-m", f"add {fname}")
+
+
+@pytest.fixture
+def repos(tmp_path: Path):
+ """An `upstream` repo and a `work` clone of it (tracking upstream/main)."""
+ upstream = tmp_path / "upstream"
+ upstream.mkdir()
+ _git(upstream, "init", "-q", "-b", "main")
+ _commit(upstream, "a.txt", "one")
+ work = tmp_path / "work"
+ _git(tmp_path, "clone", "-q", str(upstream), str(work))
+ return upstream, work
+
+
+def test_non_repo_is_benign(tmp_path: Path) -> None:
+ assert G.is_git_repo(tmp_path / "nope") is False
+ st = G.git_status(tmp_path / "nope")
+ assert st.is_repo is False and st.branch is None
+ ok, out = G.pull(tmp_path / "nope")
+ assert ok is False and "not a git" in out
+
+
+def test_status_clean_and_up_to_date(repos) -> None:
+ _, work = repos
+ st = G.git_status(work, fetch=True)
+ assert st.is_repo and st.branch == "main"
+ assert st.dirty is False
+ assert st.behind == 0 and st.ahead == 0
+ assert st.upstream and st.upstream.endswith("main")
+
+
+def test_behind_then_pull_fast_forwards(repos) -> None:
+ upstream, work = repos
+ _commit(upstream, "b.txt", "two") # advance the remote
+ st = G.git_status(work, fetch=True)
+ assert st.behind == 1 and st.ahead == 0
+
+ ok, out = G.pull(work)
+ assert ok is True, out
+ assert (work / "b.txt").exists()
+
+ after = G.git_status(work, fetch=True)
+ assert after.behind == 0 and after.dirty is False
+
+
+def test_conflicting_dirty_tree_blocks_pull(repos) -> None:
+ """A pull that would overwrite a locally-modified file is refused (ff-only never
+ merges), leaving the working copy untouched with git's own message."""
+ upstream, work = repos
+ _commit(upstream, "a.txt", "upstream change") # remote touches a.txt...
+ (work / "a.txt").write_text("local uncommitted change") # ...so does the work tree
+ assert G.git_status(work, fetch=False).dirty is True
+
+ ok, out = G.pull(work)
+ assert ok is False and out # "local changes would be overwritten"
+ assert (work / "a.txt").read_text() == "local uncommitted change"
+
+
+def test_diverged_branch_blocks_ff_pull(repos) -> None:
+ """Local commits the remote doesn't have → --ff-only refuses (no merge commit)."""
+ upstream, work = repos
+ _commit(upstream, "b.txt", "remote two")
+ _commit(work, "c.txt", "local two") # work now has a commit upstream lacks
+ st = G.git_status(work, fetch=True)
+ assert st.behind == 1 and st.ahead == 1
+
+ ok, out = G.pull(work)
+ assert ok is False and out
+
+
+def test_detached_head_reported(repos) -> None:
+ _, work = repos
+ head = subprocess.run(
+ ["git", "-C", str(work), "rev-parse", "HEAD"],
+ capture_output=True,
+ text=True,
+ env={**_base_env(), **_ENV},
+ ).stdout.strip()
+ _git(work, "checkout", "-q", head)
+ st = G.git_status(work, fetch=False)
+ assert st.detached is True and st.branch is None
diff --git a/core/tests/test_relations.py b/core/tests/test_relations.py
new file mode 100644
index 0000000..45f1a11
--- /dev/null
+++ b/core/tests/test_relations.py
@@ -0,0 +1,106 @@
+"""Tests for the relationship model (core/src/castle_core/relations.py)."""
+
+from __future__ import annotations
+
+import pytest
+
+import castle_core.config as C
+from castle_core import relations as R
+from castle_core.manifest import ProgramSpec, Requirement, SystemdDeployment
+
+
+def _dep(program: str) -> SystemdDeployment:
+ return SystemdDeployment.model_validate(
+ {
+ "manager": "systemd",
+ "program": program,
+ "run": {"launcher": "command", "argv": [program]},
+ }
+ )
+
+
+def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
+ return C.CastleConfig(
+ root=None,
+ gateway=C.GatewayConfig(port=9000),
+ repo=None,
+ programs=programs,
+ deployments=deployments,
+ )
+
+
+def test_system_dependencies_is_the_system_requirement_alias() -> None:
+ """`system_dependencies` surfaces as a {kind: system} requirement."""
+ cfg = _cfg(
+ {"t": ProgramSpec(id="t", system_dependencies=["pandoc"])}, {"t": _dep("t")}
+ )
+ reqs = R.requirements_of(cfg, "t")
+ assert [(r.kind, r.ref) for r in reqs] == [("system", "pandoc")]
+
+
+def test_requirements_merge_program_and_deployment_deduped() -> None:
+ prog = ProgramSpec(
+ id="web",
+ system_dependencies=["pandoc"],
+ requires=[Requirement(kind="deployment", ref="api", bind="API_URL")],
+ )
+ dep = SystemdDeployment.model_validate(
+ {
+ "manager": "systemd",
+ "program": "web",
+ "run": {"launcher": "command", "argv": ["web"]},
+ "requires": [{"kind": "system", "ref": "pandoc"}],
+ } # dup of program's
+ )
+ cfg = _cfg(
+ {"web": prog, "api": ProgramSpec(id="api")}, {"web": dep, "api": _dep("api")}
+ )
+ kinds = {(r.kind, r.ref) for r in R.requirements_of(cfg, "web")}
+ assert kinds == {("system", "pandoc"), ("deployment", "api")} # deduped
+
+
+def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
+ """A {kind: deployment} requirement becomes an edge (with bind), and the target's
+ fan-in is the count of distinct dependents."""
+ consumer = ProgramSpec(
+ id="web", requires=[Requirement(kind="deployment", ref="api", bind="API_URL")]
+ )
+ consumer2 = ProgramSpec(
+ id="cli", requires=[Requirement(kind="deployment", ref="api")]
+ )
+ cfg = _cfg(
+ {"web": consumer, "cli": consumer2, "api": ProgramSpec(id="api")},
+ {"web": _dep("web"), "cli": _dep("cli"), "api": _dep("api")},
+ )
+ m = R.build_model(cfg, check=False)
+ edge = next(e for e in m.edges if e.src == "web" and e.dst == "api")
+ assert edge.kind == "deployment" and edge.bind == "API_URL"
+ api = next(n for n in m.nodes if n.name == "api")
+ assert api.depended_on_by == 2 # web + cli
+
+
+def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> None:
+ """`functional?` is derived: a missing system package is unmet; a present
+ deployment requirement is satisfied."""
+ prog = ProgramSpec(
+ id="web",
+ system_dependencies=["pandoc"],
+ requires=[Requirement(kind="deployment", ref="api")],
+ )
+ cfg = _cfg(
+ {"web": prog, "api": ProgramSpec(id="api")},
+ {"web": _dep("web"), "api": _dep("api")},
+ )
+ monkeypatch.setattr(R.shutil, "which", lambda _: None) # nothing installed
+ m = R.build_model(cfg, check=True)
+ web = next(n for n in m.nodes if n.name == "web")
+ assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
+ assert web.functional is False
+
+
+def test_missing_deployment_requirement_is_unmet() -> None:
+ prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
+ cfg = _cfg({"web": prog}, {"web": _dep("web")})
+ m = R.build_model(cfg, check=True)
+ web = next(n for n in m.nodes if n.name == "web")
+ assert web.unmet == ["deployment:ghost"] and web.functional is False
diff --git a/docs/relationships.md b/docs/relationships.md
new file mode 100644
index 0000000..52e6833
--- /dev/null
+++ b/docs/relationships.md
@@ -0,0 +1,90 @@
+# Relationships: requires, repos, and derived predicates
+
+How castle models the relationships between **programs**, **deployments**, and
+**repos** — and answers questions like *"is this functional?"*, *"is it fresh?"*,
+*"is it deployed?"* — with the smallest possible amount of stored state.
+
+## The governing principle
+
+> **Predicates are always derived. Encode only what is not derivable.**
+
+A *predicate* is a question we ask about a program or deployment: `functional?`,
+`fresh?`, `deployed?`. None of these are ever stored — each is a **function** over
+data castle already has (git, config, the registry). When a predicate can't be
+answered from derived data, find the one missing datum and ask: is it about the
+**thing** (a node property) or about a **relationship** (an edge property)? Encode
+*only* that datum. Everything else stays computed.
+
+This is the same instinct as `kind` (derived from `manager`) — we don't store what
+we can compute, and a relationship that proves real and stable in the derived graph
+is a candidate to *promote* into a first-class concept. Diagnostic → evidence →
+abstraction, in that order.
+
+## Entities
+
+- **program** — the software catalog entry.
+- **deployment** — a program realized on this node (`kind` derived from `manager`).
+- **repo** — a git working copy. **Derived** from `git rev-parse --show-toplevel`
+ on each program's source; several programs sharing one toplevel is a *monorepo*.
+ Never stored.
+
+## The one encoded relation: `requires`
+
+Everything we were calling "substrate", "wiring", or "dependency" is one relation —
+**`requires`** ("A must have B to be functional") — with a typed target. The
+**kind fixes the meaning and the check**; there is no separate purpose/`for` tag:
+
+| kind | means | checked by |
+|------|-------|-----------|
+| `system` | the host package/binary must be **installed** | `which [` |
+| `deployment` | another deployment must **exist / be running** | registry / config |
+
+```yaml
+requires:
+ - { kind: system, ref: pandoc } # today's system_dependencies
+ - { kind: deployment, ref: astro-guru, bind: GURU_URL }
+ # - { kind: deployment, ref: litellm, version: ">=1" } # version: FUTURE, unused
+```
+
+`system_dependencies` is exactly the `{kind: system}` case and is kept as an alias.
+
+Only encode a `requires` edge that is **not derivable** and that **castle itself
+must traverse** for an operation (status, bring-up order, group ops). Do **not**
+duplicate what another layer already owns — systemd `Requires=`/`After=` for unit
+ordering, uv/pnpm for build graphs. This is *castle's* slice, uncoupled from any
+one package ecosystem.
+
+### Env is derived *from* `requires`, never scraped *into* it
+
+Reading dependencies out of env strings is unstable (formats vary; a static
+frontend's API URL is baked into its bundle and invisible). The stable direction is
+the reverse: from an encoded `{kind: deployment}` requirement castle **generates**
+the wiring env — it knows the target's address (`][.` / its port) and
+projects it into the consumer's env, optionally under the var named by `bind`. Same
+move as `${public_url}`, one step further. Dependency → env, never env → dependency.
+
+## Derived predicates
+
+Computed on demand from encoded `requires` + git + registry; nothing stored:
+
+- **`functional?`** — every `requires` is satisfied (system installed, deployment
+ exists). The unmet ones *are* the node's status (`doctor`/`status`).
+- **`fresh?`** — the program's repo is at latest and clean (git status).
+- **`deployed?`** — the deployment is active in the registry.
+
+New predicates are just new functions over the same preconditions — there is
+nothing to "unify" in storage.
+
+## What's encoded vs derived (the whole surface)
+
+| datum | source | stored? |
+|-------|--------|---------|
+| repo / monorepo | git toplevel | derived |
+| `fresh?` / `deployed?` / `functional?` | git / registry / requires | derived |
+| env wiring for a dependency | the `requires` edge + target address | derived |
+| fan-in ("widely depended-on") | count of requires | derived |
+| a **non-derivable** requirement (frontend→backend, host package) | — | **encoded** (`requires`) |
+| the env var to bind a dep's URL to, when non-conventional | — | **encoded** (`requires[].bind`) |
+
+Every irreducible found so far is an **edge** (a relationship); no new **node**
+property has been needed yet — a sign the encoded surface stays tiny.
]