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

@@ -1,17 +1,43 @@
import { useState } from "react"
import { useParams } from "react-router-dom"
import { useProgram } from "@/services/api/hooks"
import { useProgram, useProgramGit, useProgramSync } from "@/services/api/hooks"
import { subdomainUrl } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { DeploymentsSection } from "@/components/detail/DeploymentsSection"
import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions"
import { GitSyncRow } from "@/components/detail/GitSyncRow"
export function ProgramDetailPage() {
const { name } = useParams<{ name: string }>()
const { data: deployment, isLoading, error, refetch } = useProgram(name ?? "")
const git = useProgramGit(name ?? "", !!deployment?.repo)
const sync = useProgramSync()
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
const handleSync = () => {
if (!deployment) return
setActionOutput(null)
sync.mutate(deployment.id, {
onSuccess: (data) => {
const lines = [data.output || (data.pulled ? "Pulled." : "Already up to date.")]
if (data.pulled && data.deployments.length) {
lines.push("", `May need a restart/apply: ${data.deployments.join(", ")}`)
}
setActionOutput({ action: "sync", text: lines.join("\n"), ok: true })
},
onError: (err) => {
let text = String(err)
try {
text = JSON.parse((err as Error).message).detail ?? text
} catch {
text = (err as Error).message ?? text
}
setActionOutput({ action: "sync", text, ok: false })
},
})
}
if (isLoading) {
return (
<div className="max-w-3xl mx-auto px-6 py-8 text-[var(--muted)]">Loading...</div>
@@ -85,6 +111,18 @@ export function ProgramDetailPage() {
</span>
</>
)}
{deployment.repo && git.data?.is_repo && (
<>
<span className="text-[var(--muted)]">Git</span>
<GitSyncRow
status={git.data}
program={deployment.id}
loading={git.isFetching}
syncing={sync.isPending}
onSync={handleSync}
/>
</>
)}
{deployment.version && (
<>
<span className="text-[var(--muted)]">Version</span>