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:
@@ -12,6 +12,7 @@ import {
|
||||
Package,
|
||||
Server,
|
||||
Share2,
|
||||
Network,
|
||||
Wrench,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -38,6 +39,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
||||
],
|
||||
},
|
||||
{ to: "/programs", label: "Programs", icon: Package },
|
||||
{ to: "/graph", label: "Graph", icon: Network },
|
||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||
]
|
||||
|
||||
|
||||
68
app/src/components/MonorepoBanner.tsx
Normal file
68
app/src/components/MonorepoBanner.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react"
|
||||
import { GitFork, Loader2, RefreshCw } from "lucide-react"
|
||||
import { useRepos, useRepoSync } from "@/services/api/hooks"
|
||||
import type { RepoSummary } from "@/types"
|
||||
|
||||
// Repos that back more than one program (monorepos) get one row with a single
|
||||
// repo-scoped Sync — the honest place for it, since a pull moves the whole working
|
||||
// copy. Standalone programs (a repo of one) sync from their own program page.
|
||||
export function MonorepoBanner() {
|
||||
const { data: repos } = useRepos()
|
||||
const monorepos = (repos ?? []).filter((r) => r.programs.length > 1)
|
||||
if (monorepos.length === 0) return null
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
{monorepos.map((r) => (
|
||||
<MonorepoRow key={r.key} repo={r} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonorepoRow({ repo }: { repo: RepoSummary }) {
|
||||
const sync = useRepoSync()
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
|
||||
const behind = repo.behind ?? 0
|
||||
const onSync = () => {
|
||||
setMsg(null)
|
||||
sync.mutate(repo.key, {
|
||||
onSuccess: (d) =>
|
||||
setMsg(d.pulled ? `Pulled — ${d.deployments.join(", ")} may need restart/apply` : "Already up to date"),
|
||||
onError: (e) => {
|
||||
let t = String(e)
|
||||
try {
|
||||
t = JSON.parse((e as Error).message).detail ?? t
|
||||
} catch {
|
||||
t = (e as Error).message ?? t
|
||||
}
|
||||
setMsg(t)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<GitFork size={15} className="text-[var(--muted)]" />
|
||||
<span className="font-mono font-semibold">{repo.key}</span>
|
||||
<span className="text-xs text-[var(--muted)]">monorepo · {repo.programs.join(", ")}</span>
|
||||
{behind > 0 ? (
|
||||
<span className="text-xs text-amber-400">● {behind} behind</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">✓ up to date</span>
|
||||
)}
|
||||
{repo.dirty && <span className="text-xs text-amber-400">· dirty</span>}
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={sync.isPending}
|
||||
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-[var(--border)] hover:bg-[var(--muted)]/10 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{sync.isPending ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
|
||||
Sync repo
|
||||
</button>
|
||||
</div>
|
||||
{msg && <div className="text-xs text-[var(--muted)] mt-1.5">{msg}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
app/src/components/detail/GitSyncRow.tsx
Normal file
78
app/src/components/detail/GitSyncRow.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { GitBranch, Loader2, RefreshCw } from "lucide-react"
|
||||
import type { GitStatus } from "@/types"
|
||||
|
||||
interface GitSyncRowProps {
|
||||
status: GitStatus
|
||||
program: string // the program whose page this is (excluded from "shared by")
|
||||
loading: boolean // a fetch/refresh of the status is in flight
|
||||
syncing: boolean // a git pull is in flight
|
||||
onSync: () => void
|
||||
}
|
||||
|
||||
// The "Git" value cell in the Program Info card: branch · behind/ahead · dirty,
|
||||
// plus a Sync (git pull) button. Pull-only — converge stays a separate step. For a
|
||||
// monorepo, sync operates on the whole repo and lists the sibling programs.
|
||||
export function GitSyncRow({ status, program, loading, syncing, onSync }: GitSyncRowProps) {
|
||||
const behind = status.behind ?? 0
|
||||
const ahead = status.ahead ?? 0
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GitBranch size={13} className="text-[var(--muted)]" />
|
||||
<span className="font-mono">{status.detached ? "detached" : status.branch ?? "—"}</span>
|
||||
</span>
|
||||
|
||||
{status.behind === null ? (
|
||||
<span className="text-xs text-[var(--muted)]">no upstream</span>
|
||||
) : behind > 0 ? (
|
||||
<span className="text-xs text-amber-400">
|
||||
● {behind} behind{ahead > 0 ? `, ${ahead} ahead` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">
|
||||
✓ up to date{ahead > 0 ? ` (${ahead} ahead)` : ""}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-xs ${status.dirty ? "text-amber-400" : "text-[var(--muted)]"}`}>
|
||||
· {status.dirty ? "dirty" : "clean"}
|
||||
</span>
|
||||
|
||||
{status.error && (
|
||||
<span className="text-xs text-red-400" title={status.error}>
|
||||
· fetch error
|
||||
</span>
|
||||
)}
|
||||
|
||||
{status.repo?.multi && (
|
||||
<span
|
||||
className="text-xs text-[var(--muted)]"
|
||||
title={`Shared repo — syncing pulls the whole ${status.repo.key} working copy`}
|
||||
>
|
||||
· shared by {status.repo.programs.filter((p) => p !== program).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={syncing || loading}
|
||||
title={
|
||||
status.repo?.multi
|
||||
? `git pull the whole ${status.repo.key} repo (${status.repo.programs.join(", ")})`
|
||||
: status.dirty
|
||||
? "Working copy has local changes — a pull may be refused"
|
||||
: "git pull (fast-forward)"
|
||||
}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]/10 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
)}
|
||||
{status.repo?.multi ? "Sync repo" : "Sync"}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user