diff --git a/app/src/pages/SystemMap.tsx b/app/src/pages/SystemMap.tsx
index 5e04975..3579f12 100644
--- a/app/src/pages/SystemMap.tsx
+++ b/app/src/pages/SystemMap.tsx
@@ -324,6 +324,8 @@ function RemoteNode({ data }: NodeProps) {
{d.sub &&
{d.sub}
}
+
+
)
}
@@ -655,6 +657,47 @@ export function SystemMapPage() {
})
}
+ // Cross-node consumption — a remote deployment's `requires` resolved against
+ // the provider set across nodes: same machine first, then a local provider
+ // (a cross-machine edge), then another machine. This is the multi-node payoff.
+ const meshDeps = meshResp?.deployments ?? []
+ const meshByNode = new Map>()
+ for (const md of meshDeps) {
+ const s = meshByNode.get(md.node) ?? new Set()
+ s.add(md.name)
+ meshByNode.set(md.node, s)
+ }
+ const remoteId = (node: string, name: string) => `__remote_${node}_${name}__`
+ for (const md of meshDeps) {
+ for (const ref of md.requires ?? []) {
+ let target: string | null = null
+ if (meshByNode.get(md.node)?.has(ref) && present.has(remoteId(md.node, ref)))
+ target = remoteId(md.node, ref) // same machine
+ else if (present.has(ref))
+ target = ref // a local provider — cross-machine edge
+ else
+ for (const [mn, names] of meshByNode)
+ if (mn !== md.node && names.has(ref) && present.has(remoteId(mn, ref))) {
+ target = remoteId(mn, ref)
+ break
+ }
+ if (!target) continue
+ edges.push({
+ id: `xnode:${md.node}:${md.name}->${ref}`,
+ source: remoteId(md.node, md.name),
+ target,
+ sourceHandle: "rs",
+ targetHandle: "lt",
+ animated: true,
+ deletable: false,
+ label: `${md.node} → ${ref}`,
+ style: { stroke: "#e879f9", strokeWidth: 1.5, strokeDasharray: "6 3" },
+ labelStyle: { fill: "#e879f9", fontSize: 9 },
+ labelBgStyle: { fill: "#0d1117" },
+ })
+ }
+ }
+
return { nodes, edges, kindOf, reachOf }
}, [graph, jobs, programs, gateway, suggestionsResp, meshResp])
@@ -1176,6 +1219,7 @@ function Legend() {
{ c: PROTO_COLOR.system, label: "consumes — other" },
{ c: "#bc8cff", label: "same program", dashed: true },
{ c: "#f59e0b", label: "suggested — click to accept", dashed: true },
+ { c: "#e879f9", label: "cross-node", dashed: true },
{ c: "#58a6ff", label: "internal — LAN" },
{ c: "#2ea043", label: "public — internet" },
]
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index e431bc5..4f53186 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -172,6 +172,7 @@ export interface MeshDeployment {
base_url: string | null
subdomain: string | null
endpoints: GraphEndpoint[]
+ requires: string[] // deployment refs it consumes (for cross-node edges)
}
// POST /programs/{name}/sync — a fast-forward pull (no build/apply/restart).
diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py
index d65651d..c212b62 100644
--- a/castle-api/src/castle_api/mqtt_client.py
+++ b/castle-api/src/castle_api/mqtt_client.py
@@ -67,6 +67,9 @@ def _registry_to_json(registry: NodeRegistry) -> str:
entry["tcp_port"] = comp.tcp_port
if getattr(comp, "base_url", None):
entry["base_url"] = comp.base_url
+ # requires — deployment refs (no secrets), so peers can draw cross-node deps.
+ if getattr(comp, "requires", None):
+ entry["requires"] = comp.requires
data["deployed"][NodeRegistry.key(comp.kind, name)] = entry
return json.dumps(data)
@@ -101,6 +104,7 @@ def _json_to_registry(payload: str) -> NodeRegistry:
managed=comp_data.get("managed", False),
tcp_port=comp_data.get("tcp_port"),
base_url=comp_data.get("base_url"),
+ requires=comp_data.get("requires", []),
)
return NodeRegistry(node=node, deployed=deployed)
diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py
index 4d70b24..4b8b76f 100644
--- a/castle-api/src/castle_api/nodes.py
+++ b/castle-api/src/castle_api/nodes.py
@@ -126,6 +126,9 @@ def mesh_deployments() -> dict:
"base_url": getattr(d, "base_url", None),
"subdomain": d.subdomain,
"endpoints": _endpoints_of_registry(d),
+ "requires": [
+ r.get("ref") for r in (getattr(d, "requires", None) or []) if r.get("ref")
+ ],
}
)
return {"deployments": out}
diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py
index b9efcfa..9d7eb65 100644
--- a/core/src/castle_core/deploy.py
+++ b/core/src/castle_core/deploy.py
@@ -615,6 +615,15 @@ def _resolve_description(config: CastleConfig, spec: DeploymentBase) -> str | No
return None
+def _registry_requires(dep: DeploymentSpec) -> list[dict]:
+ """A deployment's `requires` as plain dicts for the registry — carried into the
+ mesh payload so peers can draw cross-node consumption."""
+ return [
+ {"kind": r.kind, "ref": r.ref, "bind": r.bind}
+ for r in (getattr(dep, "requires", None) or [])
+ ]
+
+
def _build_deployed(
config: CastleConfig, name: str, dep: DeploymentSpec, messages: list[str]
) -> Deployment:
@@ -642,6 +651,7 @@ def _build_deployed(
static_root=static_root,
managed=False,
enabled=dep.enabled,
+ requires=_registry_requires(dep),
)
if isinstance(dep, PathDeployment):
return Deployment(
@@ -652,6 +662,7 @@ def _build_deployed(
stack=stack,
managed=False,
enabled=dep.enabled,
+ requires=_registry_requires(dep),
)
if isinstance(dep, RemoteDeployment):
return Deployment(
@@ -663,6 +674,7 @@ def _build_deployed(
base_url=dep.base_url,
managed=False,
enabled=dep.enabled,
+ requires=_registry_requires(dep),
)
# systemd: a supervised process (a service, or a job when scheduled).
@@ -766,6 +778,7 @@ def _build_deployed(
schedule=getattr(dep, "schedule", None),
managed=managed,
enabled=dep.enabled,
+ requires=_registry_requires(dep),
)
diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py
index a6d53d9..f479e2f 100644
--- a/core/src/castle_core/registry.py
+++ b/core/src/castle_core/registry.py
@@ -86,6 +86,10 @@ class Deployment:
# Declared desired state (from the deployment's `enabled:`). `castle apply`
# activates enabled deployments and deactivates disabled ones. Default True.
enabled: bool = True
+ # Deployment `requires` (list of {kind, ref, bind}) — carried so the mesh can
+ # draw cross-node consumption. A remote consumer's ref resolves against the
+ # provider set across nodes.
+ requires: list[dict] = field(default_factory=list)
@dataclass