@@ -33,6 +33,23 @@ use ogar_vocab::{
3333
3434use crate :: do_arm:: ActionParam ;
3535
36+ /// The `action-ws` WebSocket connect path (HIRO Action API 1.0). The full URL is
37+ /// `wss://<host>/api/action-ws/1.0/connect`.
38+ pub const ACTION_WS_PATH : & str = "/api/action-ws/1.0/connect" ;
39+
40+ /// The WebSocket subprotocol header value carrying the auth token — HIRO passes
41+ /// the token as `sec-websocket-protocol: token-$TOKEN`.
42+ #[ must_use]
43+ pub fn auth_subprotocol ( token : & str ) -> String {
44+ format ! ( "token-{token}" )
45+ }
46+
47+ /// Spec bounds on a `submitAction` / `sendActionResult` correlation `id`
48+ /// (12–256 chars). [`validate_id`] enforces it.
49+ pub const ID_MIN_LEN : usize = 12 ;
50+ /// Upper bound on the correlation `id` length (spec).
51+ pub const ID_MAX_LEN : usize = 256 ;
52+
3653/// A `submitAction` message (engine → handler). The engine asks the handler to
3754/// run `capability` on a target with the supplied `parameters`.
3855#[ derive( Debug , Clone , PartialEq , Eq , Default ) ]
@@ -65,15 +82,55 @@ pub struct Acknowledged {
6582 pub message : String ,
6683}
6784
68- /// A `sendActionResult` message (handler → engine): the outcome payload — the
69- /// capability's `resultParameters` as `(key, value)` pairs.
85+ /// A `sendActionResult` message (handler → engine): the outcome payload.
86+ ///
87+ /// Per the `action-ws` spec the `result` is a **single string** (max
88+ /// `1048576` chars) — the capability's `resultParameters` JSON-encoded into one
89+ /// field (build it with [`json_object`]). The engine replies `acknowledged` /
90+ /// `negativeAcknowledged`.
7091#[ derive( Debug , Clone , PartialEq , Eq , Default ) ]
7192#[ cfg_attr( feature = "serde" , derive( Serialize , Deserialize ) ) ]
7293pub struct SendActionResult {
7394 /// The same correlation id as the originating [`SubmitAction`].
7495 pub id : String ,
75- /// The result fields (the `resultParameters` output signature, bound).
76- pub result : Vec < ( String , String ) > ,
96+ /// The result value — a JSON object string of the bound `resultParameters`
97+ /// (spec: `string`, max 1 MiB).
98+ pub result : String ,
99+ }
100+
101+ /// Max length of the [`SendActionResult::result`] string (spec: `1048576`).
102+ pub const MAX_RESULT_LEN : usize = 1_048_576 ;
103+
104+ /// A `negativeAcknowledged` message (engine ↔ handler): receipt *rejection*
105+ /// (e.g. `code = 400`). The negative twin of [`Acknowledged`].
106+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
107+ #[ cfg_attr( feature = "serde" , derive( Serialize , Deserialize ) ) ]
108+ pub struct NegativeAcknowledged {
109+ /// The id of the message being rejected.
110+ pub id : String ,
111+ /// Error code (e.g. `400`).
112+ pub code : u16 ,
113+ /// Error description.
114+ pub message : String ,
115+ }
116+
117+ /// A `configChanged` notification (engine → handler): the handler's
118+ /// capabilities / applicabilities changed; the handler must re-fetch them from
119+ /// the REST Action API (`GET /capabilities`, `GET /applicabilities`). Carries
120+ /// no payload beyond `type`; the handler replies `acknowledged`.
121+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Default ) ]
122+ #[ cfg_attr( feature = "serde" , derive( Serialize , Deserialize ) ) ]
123+ pub struct ConfigChanged ;
124+
125+ /// An asynchronous `error` message (engine → handler) — not tied to a specific
126+ /// request id.
127+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
128+ #[ cfg_attr( feature = "serde" , derive( Serialize , Deserialize ) ) ]
129+ pub struct InboundError {
130+ /// Error code.
131+ pub code : u16 ,
132+ /// Error details.
133+ pub message : String ,
77134}
78135
79136/// Errors in the protocol binding (the pure core — no I/O errors here).
@@ -93,6 +150,12 @@ pub enum ActionWsError {
93150 /// A result was requested from an invocation that has not reached
94151 /// [`ActionState::Committed`] (the Rubicon crossing).
95152 NotCommitted ( ActionState ) ,
153+ /// A correlation `id` outside the spec bounds (12–256 chars); carries the
154+ /// offending length.
155+ InvalidId ( usize ) ,
156+ /// The encoded `result` exceeds [`MAX_RESULT_LEN`] (spec: 1 MiB); carries the
157+ /// offending length.
158+ ResultTooLarge ( usize ) ,
96159}
97160
98161impl core:: fmt:: Display for ActionWsError {
@@ -106,6 +169,8 @@ impl core::fmt::Display for ActionWsError {
106169 }
107170 Self :: MissingMandatoryParam ( p) => write ! ( f, "missing mandatory parameter `{p}`" ) ,
108171 Self :: NotCommitted ( s) => write ! ( f, "invocation not committed (state = {s:?})" ) ,
172+ Self :: InvalidId ( n) => write ! ( f, "correlation id length {n} out of bounds (12..=256)" ) ,
173+ Self :: ResultTooLarge ( n) => write ! ( f, "result length {n} exceeds 1 MiB" ) ,
109174 }
110175 }
111176}
@@ -124,6 +189,70 @@ pub fn acknowledge(msg: &SubmitAction) -> Acknowledged {
124189 }
125190}
126191
192+ /// Reject a message by id (the `negativeAcknowledged` twin of [`acknowledge`]).
193+ #[ must_use]
194+ pub fn negative_acknowledge (
195+ id : & str ,
196+ code : u16 ,
197+ message : impl Into < String > ,
198+ ) -> NegativeAcknowledged {
199+ NegativeAcknowledged {
200+ id : id. to_owned ( ) ,
201+ code,
202+ message : message. into ( ) ,
203+ }
204+ }
205+
206+ /// Validate a correlation `id` against the spec bounds (12–256 chars).
207+ ///
208+ /// # Errors
209+ ///
210+ /// [`ActionWsError::InvalidId`] when the length is out of range.
211+ pub fn validate_id ( id : & str ) -> Result < ( ) , ActionWsError > {
212+ if ( ID_MIN_LEN ..=ID_MAX_LEN ) . contains ( & id. len ( ) ) {
213+ Ok ( ( ) )
214+ } else {
215+ Err ( ActionWsError :: InvalidId ( id. len ( ) ) )
216+ }
217+ }
218+
219+ /// Encode `(key, value)` pairs as a JSON object string — the wire form of the
220+ /// [`SendActionResult::result`] field (the bound `resultParameters`). A minimal,
221+ /// correctly-escaping encoder; the live transport may use `serde_json` instead.
222+ #[ must_use]
223+ pub fn json_object ( pairs : & [ ( String , String ) ] ) -> String {
224+ let mut s = String :: from ( "{" ) ;
225+ for ( i, ( k, v) ) in pairs. iter ( ) . enumerate ( ) {
226+ if i > 0 {
227+ s. push ( ',' ) ;
228+ }
229+ json_string ( k, & mut s) ;
230+ s. push ( ':' ) ;
231+ json_string ( v, & mut s) ;
232+ }
233+ s. push ( '}' ) ;
234+ s
235+ }
236+
237+ /// Append `raw` as a JSON string literal (RFC 8259 escaping) to `out`.
238+ fn json_string ( raw : & str , out : & mut String ) {
239+ out. push ( '"' ) ;
240+ for c in raw. chars ( ) {
241+ match c {
242+ '"' => out. push_str ( "\\ \" " ) ,
243+ '\\' => out. push_str ( "\\ \\ " ) ,
244+ '\n' => out. push_str ( "\\ n" ) ,
245+ '\r' => out. push_str ( "\\ r" ) ,
246+ '\t' => out. push_str ( "\\ t" ) ,
247+ c if ( c as u32 ) < 0x20 => {
248+ out. push_str ( & format ! ( "\\ u{:04x}" , c as u32 ) ) ;
249+ }
250+ c => out. push ( c) ,
251+ }
252+ }
253+ out. push ( '"' ) ;
254+ }
255+
127256/// Bind the engine-supplied `parameters` to the capability's [`ActionParam`]
128257/// signature: every mandatory param must be supplied (or have a default);
129258/// optional params fall back to their default when present, and are dropped
@@ -213,11 +342,17 @@ pub fn submit_to_invocation(
213342/// [`ActionState::Committed`].
214343pub fn invocation_to_result (
215344 inv : & ActionInvocation ,
216- result : Vec < ( String , String ) > ,
345+ result_params : & [ ( String , String ) ] ,
217346) -> Result < SendActionResult , ActionWsError > {
218347 if inv. state != ActionState :: Committed {
219348 return Err ( ActionWsError :: NotCommitted ( inv. state ) ) ;
220349 }
350+ // The spec's `result` is a single string (max 1 MiB) — JSON-encode the bound
351+ // resultParameters into it.
352+ let result = json_object ( result_params) ;
353+ if result. len ( ) > MAX_RESULT_LEN {
354+ return Err ( ActionWsError :: ResultTooLarge ( result. len ( ) ) ) ;
355+ }
221356 Ok ( SendActionResult {
222357 id : inv. idempotency_key . clone ( ) . unwrap_or_default ( ) ,
223358 result,
@@ -334,26 +469,64 @@ mod tests {
334469 let mut inv = submit_to_invocation ( & submit ( ) , & execute_command_def ( ) ) . expect ( "builds" ) ;
335470
336471 // Pending → no result on the success path.
337- let pending = invocation_to_result ( & inv, vec ! [ ] ) ;
472+ let pending = invocation_to_result ( & inv, & [ ] ) ;
338473 assert_eq ! (
339474 pending. unwrap_err( ) ,
340475 ActionWsError :: NotCommitted ( ActionState :: Pending )
341476 ) ;
342477
343- // The Rubicon crossing (the gate would set this) → result emitted.
478+ // The Rubicon crossing (the gate would set this) → result emitted as a
479+ // JSON object string (the spec's single `result` field).
344480 inv. state = ActionState :: Committed ;
345- let result = invocation_to_result (
346- & inv,
347- vec ! [ ( "output" . to_owned( ) , "12:00 up 3 days" . to_owned( ) ) ] ,
348- )
349- . expect ( "committed → result" ) ;
481+ let result =
482+ invocation_to_result ( & inv, & [ ( "output" . to_owned ( ) , "12:00 up 3 days" . to_owned ( ) ) ] )
483+ . expect ( "committed → result" ) ;
350484 assert_eq ! ( result. id, "app1:req42" ) ; // correlation id round-trips
485+ assert_eq ! ( result. result, r#"{"output":"12:00 up 3 days"}"# ) ;
486+ }
487+
488+ #[ test]
489+ fn negative_acknowledge_carries_code_and_message ( ) {
490+ let nack = negative_acknowledge ( "app1:req42" , 400 , "bad capability" ) ;
491+ assert_eq ! ( nack. id, "app1:req42" ) ;
492+ assert_eq ! ( nack. code, 400 ) ;
493+ assert_eq ! ( nack. message, "bad capability" ) ;
494+ }
495+
496+ #[ test]
497+ fn validate_id_enforces_spec_bounds ( ) {
498+ assert ! ( validate_id( "123456789012" ) . is_ok( ) ) ; // 12 chars (min)
499+ assert_eq ! (
500+ validate_id( "short" ) . unwrap_err( ) ,
501+ ActionWsError :: InvalidId ( 5 )
502+ ) ;
503+ let too_long = "x" . repeat ( 257 ) ;
351504 assert_eq ! (
352- result . result ,
353- vec! [ ( "output" . to_owned ( ) , "12:00 up 3 days" . to_owned ( ) ) ]
505+ validate_id ( & too_long ) . unwrap_err ( ) ,
506+ ActionWsError :: InvalidId ( 257 )
354507 ) ;
355508 }
356509
510+ #[ test]
511+ fn json_object_escapes_correctly ( ) {
512+ // Empty, simple, and escape-needing values.
513+ assert_eq ! ( json_object( & [ ] ) , "{}" ) ;
514+ assert_eq ! (
515+ json_object( & [ ( "k" . to_owned( ) , "v" . to_owned( ) ) ] ) ,
516+ r#"{"k":"v"}"#
517+ ) ;
518+ assert_eq ! (
519+ json_object( & [ ( "out" . to_owned( ) , "a\" b\\ c\n d" . to_owned( ) ) ] ) ,
520+ r#"{"out":"a\"b\\c\nd"}"#
521+ ) ;
522+ }
523+
524+ #[ test]
525+ fn auth_subprotocol_prefixes_the_token ( ) {
526+ assert_eq ! ( auth_subprotocol( "abc123" ) , "token-abc123" ) ;
527+ assert_eq ! ( ACTION_WS_PATH , "/api/action-ws/1.0/connect" ) ;
528+ }
529+
357530 /// The whole loop, end-to-end (socket-free): submit → ack → bind → invoke
358531 /// → commit → result, with the `id` correlating throughout.
359532 #[ test]
@@ -372,8 +545,9 @@ mod tests {
372545 // (the executor + commit_via gate run here; we simulate the crossing)
373546 inv. state = ActionState :: Committed ;
374547
375- let result = invocation_to_result ( & inv , vec ! [ ( "exitcode" . to_owned ( ) , "0" . to_owned ( ) ) ] )
376- . expect ( "result" ) ;
548+ let result =
549+ invocation_to_result ( & inv , & [ ( "exitcode" . to_owned ( ) , "0" . to_owned ( ) ) ] ) . expect ( "result" ) ;
377550 assert_eq ! ( result. id, msg. id) ;
551+ assert_eq ! ( result. result, r#"{"exitcode":"0"}"# ) ;
378552 }
379553}
0 commit comments