77//! relative path, or a missing **catalog-rooted** path — the renderer's own output). Exits non-zero.
88//! - **WARN** — advisory; the run still works (identity/host path↔content mismatch — the spec says a
99//! mismatch is a warning; a dangling supervisor — crash-dings just route nowhere; a missing
10- //! **external** path such as the workspace repo — the validate host may not be the run host; an
11- //! overlay `@import` that does not resolve — a *render* concern, not st2 law, since a valid spec
12- //! may carry no persona). `--strict` promotes every WARN to a failure so a renderer's CI can demand
13- //! spotless.
10+ //! **external** path for an agent assigned to the selected validation host; an overlay `@import`
11+ //! that does not resolve — a *render* concern, not st2 law, since a valid spec may carry no
12+ //! persona). `--strict` promotes every WARN to a failure so a renderer's CI can demand spotless.
1413//!
1514//! st2 stays render-agnostic: render-only fields (`harness`, `model`, `role`, `persona`,
1615//! `permissions`, …) are never required — their absence is never an issue.
@@ -103,6 +102,18 @@ impl Report {
103102
104103/// Validate a catalog. Returns every issue found, in a stable order (files sorted by discovery).
105104pub fn validate ( root : & Path ) -> Report {
105+ validate_scoped ( root, None )
106+ }
107+
108+ /// Validate a whole catalog while checking host-local filesystem facts only for `this_host`.
109+ ///
110+ /// Structural checks remain fleet-wide. This scope only prevents a synced multi-host catalog from
111+ /// warning that another machine's external workspace or task cwd is absent locally.
112+ pub fn validate_for_host ( root : & Path , this_host : & str ) -> Report {
113+ validate_scoped ( root, Some ( this_host) )
114+ }
115+
116+ fn validate_scoped ( root : & Path , this_host : Option < & str > ) -> Report {
106117 // Canonicalize so `$CATALOG`-rooted paths expand to absolute paths (a relative root would make
107118 // every `$CATALOG/...` look relative). Falls back to the given root if it does not exist yet.
108119 let root = & root. canonicalize ( ) . unwrap_or_else ( |_| root. to_path_buf ( ) ) ;
@@ -155,14 +166,29 @@ pub fn validate(root: &Path) -> Report {
155166 let mut seen: HashMap < String , PathBuf > = HashMap :: new ( ) ;
156167 // Placeholder host for bus-id collision: catalogs carry explicit host, and an empty host still
157168 // makes two unset-host same-identity specs collide (which is the real bug).
158- let this_host = "" ;
169+ let collision_host = "" ;
170+ let addresses: HashSet < String > = d
171+ . specs
172+ . iter ( )
173+ . flat_map ( |s| {
174+ let mut values = vec ! [ s. identity. clone( ) ] ;
175+ if s. host . is_some ( ) {
176+ values. push ( s. bus_id ( collision_host) ) ;
177+ }
178+ values
179+ } )
180+ . collect ( ) ;
159181
160182 for s in & d. specs {
161183 let rp = rel ( root, & s. path ) ;
162184 let ag = Some ( s. identity . clone ( ) ) ;
185+ let runs_on_selected_host = match this_host {
186+ Some ( host) => s. resolved_host ( host) == host,
187+ None => true ,
188+ } ;
163189
164190 // Duplicate bus id — the runner cannot run two agents under one <host>.<identity>.
165- let bid = s. bus_id ( this_host ) ;
191+ let bid = s. bus_id ( collision_host ) ;
166192 if let Some ( prev) = seen. insert ( bid. clone ( ) , s. path . clone ( ) ) {
167193 issues. push ( Issue :: error (
168194 "dup-id" ,
@@ -216,14 +242,16 @@ pub fn validate(root: &Path) -> Report {
216242
217243 // Path fields must be absolute or $CATALOG-rooted, and must exist.
218244 for ( field, raw) in path_fields ( s) {
219- if let Some ( issue) = check_path ( root, & rp, & ag, & field, & raw ) {
245+ if let Some ( issue) = check_path ( root, & rp, & ag, & field, & raw , runs_on_selected_host ) {
220246 issues. push ( issue) ;
221247 }
222248 }
223249
224- // A supervisor that names no agent in this catalog — advisory (may live elsewhere).
250+ // Runtime routing accepts either a bare identity or a fully-qualified <host>.<identity>.
251+ // Validation must index the same address set or it rejects declarations the bus can route.
225252 if let Some ( sup) = & s. supervisor
226253 && !identities. contains ( sup. as_str ( ) )
254+ && !addresses. contains ( sup)
227255 {
228256 issues. push ( Issue :: warn (
229257 "dangling-supervisor" ,
@@ -234,7 +262,9 @@ pub fn validate(root: &Path) -> Report {
234262 }
235263
236264 // Overlay lint: render's persona overlay `@import`s must resolve (WARN — render concern).
237- issues. extend ( overlay_lint ( & rp, & ag, s) ) ;
265+ if runs_on_selected_host {
266+ issues. extend ( overlay_lint ( & rp, & ag, s) ) ;
267+ }
238268
239269 // Declarative render is a pre-boot gate: malformed directives, unsafe destinations, or a
240270 // missing catalog-owned copy source would prevent this agent from booting.
@@ -273,8 +303,16 @@ fn path_fields(s: &AgentSpec) -> Vec<(String, String)> {
273303
274304/// Check one path field: `$CATALOG` expands to the catalog root; a path bearing any *other* `$VAR` is
275305/// skipped (an unset var is a literal token — do not guess). What remains must be absolute (R11:
276- /// final-spec paths are absolute or $CATALOG-rooted, never relative) and must exist.
277- fn check_path ( root : & Path , rp : & str , ag : & Option < String > , field : & str , raw : & str ) -> Option < Issue > {
306+ /// final-spec paths are absolute or $CATALOG-rooted, never relative). Catalog-owned paths must
307+ /// always exist; external paths are checked only for an agent assigned to the selected host.
308+ fn check_path (
309+ root : & Path ,
310+ rp : & str ,
311+ ag : & Option < String > ,
312+ field : & str ,
313+ raw : & str ,
314+ check_external_presence : bool ,
315+ ) -> Option < Issue > {
278316 let root_s = root. to_string_lossy ( ) ;
279317 let expanded = raw
280318 . replace ( "${CATALOG}" , & root_s)
@@ -295,24 +333,25 @@ fn check_path(root: &Path, rp: &str, ag: &Option<String>, field: &str, raw: &str
295333 }
296334 if !p. exists ( ) {
297335 // A **catalog-rooted** path is the renderer's own output — its absence is a real render bug
298- // (ERROR). An **external** absolute path (e.g. the workspace repo) may simply not be present
299- // on the host running `validate` — a nix build gate legitimately validates a catalog whose
300- // workspace is cloned on a different run host — so that is advisory (WARN), not a failure.
301- return Some ( if p. starts_with ( root) {
302- Issue :: error (
336+ // (ERROR). An **external** absolute path is checked only for the selected run host; its
337+ // absence there is advisory (WARN), not a structural catalog failure.
338+ return if p. starts_with ( root) {
339+ Some ( Issue :: error (
303340 "bad-path" ,
304341 rp. to_string ( ) ,
305342 ag. clone ( ) ,
306343 format ! ( "{field} '{raw}' does not exist" ) ,
307- )
308- } else {
309- Issue :: warn (
344+ ) )
345+ } else if check_external_presence {
346+ Some ( Issue :: warn (
310347 "bad-path" ,
311348 rp. to_string ( ) ,
312349 ag. clone ( ) ,
313350 format ! ( "{field} '{raw}' does not exist (absent on this host? — not the run host)" ) ,
314- )
315- } ) ;
351+ ) )
352+ } else {
353+ None
354+ } ;
316355 }
317356 None
318357}
0 commit comments