44//! allocates a terminal, an agent harness) and `exec{}` (a plain process — the ding, daemons, a
55//! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset:
66//! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`,
7- //! `restart{}`, and the tasks. Everything render-only (`harness`, `model`, `persona`, `permissions`,
8- //! ` transport`, `strategy`, `meta{}`) is baked into the tasks/commands by the render layer and
9- //! ignored here.
7+ //! `restart{}`, Resource bindings (declaration metadata), and the tasks. Everything render-only
8+ //! (`harness`, `model`, `persona`, `permissions`, ` transport`, `strategy`, `meta{}`) is baked into
9+ //! the tasks/commands by the render layer and ignored here.
1010//!
1111//! Three on-disk formats lower to this model: KDL (canonical, parsed by hand in `kdl_format`), and
1212//! TOML/JSON (serde). Every spec is a `service` — `type = batch` is retired; evals run through the
@@ -16,9 +16,10 @@ use std::collections::BTreeMap;
1616use std:: path:: PathBuf ;
1717use std:: time:: Duration ;
1818
19- use serde:: Deserialize ;
19+ use serde:: de:: { self , MapAccess , Visitor } ;
20+ use serde:: { Deserialize , Serialize } ;
2021
21- /// A rendered agent job, lowered to the fields st2 needs to run it .
22+ /// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect .
2223#[ derive( Debug , Clone , PartialEq , Eq ) ]
2324pub struct AgentSpec {
2425 /// Unique id; the bus id is `<host>.<identity>`.
@@ -39,12 +40,27 @@ pub struct AgentSpec {
3940 pub keep : bool ,
4041 /// Crash/restart policy (§4). `None` → the runner's default policy.
4142 pub restart : Option < Restart > ,
43+ /// Named typed references used by the agent. st2 preserves these for readers but does not
44+ /// resolve them or assign launch, readiness, access, or lifecycle semantics.
45+ pub resources : Vec < Resource > ,
4246 /// The runnable tasks (`pty` + `exec`), sorted by name for determinism.
4347 pub tasks : Vec < Task > ,
4448 /// Where this spec was loaded from — the anchor for its resources and for edits.
4549 pub path : PathBuf ,
4650}
4751
52+ /// One agent-local semantic binding to an externally identified resource.
53+ ///
54+ /// `name` is the role the resource plays for this agent, `tag` selects the downstream resource
55+ /// contract, and `uri` is the exact absolute identity. The envelope deliberately carries no policy.
56+ #[ derive( Debug , Clone , PartialEq , Eq , Serialize , Deserialize ) ]
57+ pub struct Resource {
58+ pub name : String ,
59+ #[ serde( rename = "_tag" ) ]
60+ pub tag : String ,
61+ pub uri : String ,
62+ }
63+
4864/// The kind of job. Only `service` (long-running) remains — `type = batch` is retired; the native
4965/// `st2 eval` path (eval_spec/eval_run) replaces the old staged batch executor.
5066#[ derive( Debug , Clone , Copy , PartialEq , Eq , Default ) ]
@@ -199,6 +215,10 @@ pub(crate) struct RawSpec {
199215 #[ serde( default ) ]
200216 pub keep : bool ,
201217 pub restart : Option < RawRestart > ,
218+ /// Named resource bindings. Singular `resource` matches canonical KDL and keeps TOML/JSON maps
219+ /// aligned with `resource "<name>"`.
220+ #[ serde( default ) ]
221+ pub resource : RawResources ,
202222 /// Compact catalog form: the agent itself is one pty carrying this command.
203223 pub command : Option < String > ,
204224 /// Compact catalog form: the agent itself is one pty launched directly with this argv.
@@ -217,6 +237,17 @@ pub(crate) struct RawSpec {
217237 pub exec : BTreeMap < String , RawTask > ,
218238}
219239
240+ #[ derive( Debug , Default ) ]
241+ pub ( crate ) struct RawResources ( BTreeMap < String , RawResource > ) ;
242+
243+ #[ derive( Debug , Deserialize ) ]
244+ #[ serde( deny_unknown_fields) ]
245+ pub ( crate ) struct RawResource {
246+ #[ serde( rename = "_tag" ) ]
247+ pub ( crate ) tag : String ,
248+ pub ( crate ) uri : String ,
249+ }
250+
220251#[ derive( Debug , Default , Deserialize ) ]
221252pub ( crate ) struct RawTask {
222253 pub id : Option < String > ,
@@ -261,6 +292,113 @@ impl RawRestart {
261292 }
262293}
263294
295+ impl RawResources {
296+ pub ( crate ) fn insert ( & mut self , name : String , resource : RawResource ) -> anyhow:: Result < ( ) > {
297+ if self . 0 . insert ( name. clone ( ) , resource) . is_some ( ) {
298+ anyhow:: bail!( "duplicate resource binding '{name}'" ) ;
299+ }
300+ Ok ( ( ) )
301+ }
302+
303+ fn lower ( self ) -> anyhow:: Result < Vec < Resource > > {
304+ self . 0
305+ . into_iter ( )
306+ . map ( |( name, resource) | {
307+ if name. is_empty ( ) {
308+ anyhow:: bail!( "resource binding name cannot be empty" ) ;
309+ }
310+ if resource. tag . is_empty ( ) {
311+ anyhow:: bail!( "resource binding '{name}' has an empty `_tag`" ) ;
312+ }
313+ validate_absolute_uri ( & resource. uri ) . map_err ( |reason| {
314+ anyhow:: anyhow!(
315+ "resource binding '{name}' `uri` must be an exact absolute URI: {reason}"
316+ )
317+ } ) ?;
318+ Ok ( Resource {
319+ name,
320+ tag : resource. tag ,
321+ uri : resource. uri ,
322+ } )
323+ } )
324+ . collect ( )
325+ }
326+ }
327+
328+ impl < ' de > Deserialize < ' de > for RawResources {
329+ fn deserialize < D > ( deserializer : D ) -> Result < Self , D :: Error >
330+ where
331+ D : serde:: Deserializer < ' de > ,
332+ {
333+ struct ResourceMapVisitor ;
334+
335+ impl < ' de > Visitor < ' de > for ResourceMapVisitor {
336+ type Value = RawResources ;
337+
338+ fn expecting ( & self , formatter : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
339+ formatter. write_str ( "a map of uniquely named resource bindings" )
340+ }
341+
342+ fn visit_map < A > ( self , mut map : A ) -> Result < Self :: Value , A :: Error >
343+ where
344+ A : MapAccess < ' de > ,
345+ {
346+ let mut resources = BTreeMap :: new ( ) ;
347+ while let Some ( ( name, resource) ) = map. next_entry :: < String , RawResource > ( ) ? {
348+ if resources. insert ( name. clone ( ) , resource) . is_some ( ) {
349+ return Err ( de:: Error :: custom ( format ! (
350+ "duplicate resource binding '{name}'"
351+ ) ) ) ;
352+ }
353+ }
354+ Ok ( RawResources ( resources) )
355+ }
356+ }
357+
358+ deserializer. deserialize_map ( ResourceMapVisitor )
359+ }
360+ }
361+
362+ fn validate_absolute_uri ( uri : & str ) -> Result < ( ) , & ' static str > {
363+ let Some ( colon) = uri. find ( ':' ) else {
364+ return Err ( "missing scheme" ) ;
365+ } ;
366+ let scheme = & uri[ ..colon] ;
367+ let mut chars = scheme. chars ( ) ;
368+ if !chars
369+ . next ( )
370+ . is_some_and ( |character| character. is_ascii_alphabetic ( ) )
371+ || !chars. all ( |character| {
372+ character. is_ascii_alphanumeric ( ) || matches ! ( character, '+' | '-' | '.' )
373+ } )
374+ {
375+ return Err ( "invalid scheme" ) ;
376+ }
377+ if !uri. is_ascii ( )
378+ || uri
379+ . bytes ( )
380+ . any ( |byte| byte. is_ascii_whitespace ( ) || byte. is_ascii_control ( ) )
381+ {
382+ return Err ( "contains characters outside URI syntax" ) ;
383+ }
384+ let bytes = uri. as_bytes ( ) ;
385+ let mut offset = colon + 1 ;
386+ while offset < bytes. len ( ) {
387+ if bytes[ offset] == b'%' {
388+ if offset + 2 >= bytes. len ( )
389+ || !bytes[ offset + 1 ] . is_ascii_hexdigit ( )
390+ || !bytes[ offset + 2 ] . is_ascii_hexdigit ( )
391+ {
392+ return Err ( "contains an invalid percent escape" ) ;
393+ }
394+ offset += 3 ;
395+ } else {
396+ offset += 1 ;
397+ }
398+ }
399+ Ok ( ( ) )
400+ }
401+
264402impl RawSpec {
265403 /// A parsed file is a *spec candidate* when it carries an agent-shaped signal — an identity, a
266404 /// `type`, or task blocks. Random TOML/JSON in the tree has none of these and is skipped.
@@ -270,6 +408,7 @@ impl RawSpec {
270408 || self . command . is_some ( )
271409 || self . argv . is_some ( )
272410 || self . ding
411+ || !self . resource . 0 . is_empty ( )
273412 || !self . pty . is_empty ( )
274413 || !self . exec . is_empty ( )
275414 }
@@ -337,6 +476,7 @@ impl RawSpec {
337476
338477 // `service` is the only job type; a stray `type` string is caught by validate (unknown-type).
339478 let job_type = JobType :: Service ;
479+ let resources = self . resource . lower ( ) ?;
340480
341481 Ok ( AgentSpec {
342482 identity,
@@ -348,6 +488,7 @@ impl RawSpec {
348488 retired : self . retired ,
349489 keep : self . keep ,
350490 restart : self . restart . map ( RawRestart :: lower) ,
491+ resources,
351492 tasks,
352493 path,
353494 } )
0 commit comments