Skip to main content

greentic_runner_host/
component_api.rs

1use anyhow::Result;
2
3pub mod v0_4 {
4    wasmtime::component::bindgen!({
5        inline: r#"
6        package greentic:component@0.4.0;
7
8        interface control {
9          should-cancel: func() -> bool;
10          yield-now: func();
11        }
12
13        interface node {
14          type json = string;
15
16          record tenant-ctx {
17            tenant: string,
18            team: option<string>,
19            user: option<string>,
20            trace-id: option<string>,
21            correlation-id: option<string>,
22            deadline-unix-ms: option<u64>,
23            attempt: u32,
24            idempotency-key: option<string>,
25          }
26
27          record exec-ctx {
28            tenant: tenant-ctx,
29            flow-id: string,
30            node-id: option<string>,
31          }
32
33          record node-error {
34            code: string,
35            message: string,
36            retryable: bool,
37            backoff-ms: option<u64>,
38            details: option<json>,
39          }
40
41          variant invoke-result {
42            ok(json),
43            err(node-error),
44          }
45
46          variant stream-event {
47            data(json),
48            progress(u8),
49            done,
50            error(string),
51          }
52
53          enum lifecycle-status { ok }
54
55          get-manifest: func() -> json;
56          on-start: func(ctx: exec-ctx) -> result<lifecycle-status, string>;
57          on-stop: func(ctx: exec-ctx, reason: string) -> result<lifecycle-status, string>;
58          invoke: func(ctx: exec-ctx, op: string, input: json) -> invoke-result;
59          invoke-stream: func(ctx: exec-ctx, op: string, input: json) -> list<stream-event>;
60        }
61
62        world component {
63          import control;
64          export node;
65        }
66        "#,
67        world: "component",
68    });
69}
70
71pub mod v0_5 {
72    wasmtime::component::bindgen!({
73        inline: r#"
74        package greentic:component@0.5.0;
75
76        interface control {
77          should-cancel: func() -> bool;
78          yield-now: func();
79        }
80
81        interface node {
82          type json = string;
83
84          record impersonation {
85            actor-id: string,
86            reason: option<string>,
87          }
88
89          record tenant-ctx {
90            env: string,
91            tenant: string,
92            tenant-id: string,
93            team: option<string>,
94            team-id: option<string>,
95            user: option<string>,
96            user-id: option<string>,
97            trace-id: option<string>,
98            i18n-id: option<string>,
99            correlation-id: option<string>,
100            attributes: list<tuple<string, string>>,
101            session-id: option<string>,
102            flow-id: option<string>,
103            node-id: option<string>,
104            provider-id: option<string>,
105            deadline-ms: option<s64>,
106            attempt: u32,
107            idempotency-key: option<string>,
108            impersonation: option<impersonation>,
109          }
110
111          record exec-ctx {
112            tenant: tenant-ctx,
113            i18n-id: option<string>,
114            flow-id: string,
115            node-id: option<string>,
116          }
117
118          record node-error {
119            code: string,
120            message: string,
121            retryable: bool,
122            backoff-ms: option<u64>,
123            details: option<json>,
124          }
125
126          variant invoke-result {
127            ok(json),
128            err(node-error),
129          }
130
131          variant stream-event {
132            data(json),
133            progress(u8),
134            done,
135            error(string),
136          }
137
138          enum lifecycle-status { ok }
139
140          get-manifest: func() -> json;
141          on-start: func(ctx: exec-ctx) -> result<lifecycle-status, string>;
142          on-stop: func(ctx: exec-ctx, reason: string) -> result<lifecycle-status, string>;
143          invoke: func(ctx: exec-ctx, op: string, input: json) -> invoke-result;
144          invoke-stream: func(ctx: exec-ctx, op: string, input: json) -> list<stream-event>;
145        }
146
147        world component {
148          import control;
149          export node;
150        }
151        "#,
152        world: "component",
153    });
154}
155
156pub mod v0_6_descriptor {
157    wasmtime::component::bindgen!({
158        inline: r#"
159        package greentic:component@0.6.0;
160
161        interface component-descriptor {
162          describe: func() -> list<u8>;
163        }
164
165        world component-v0-v6-v0 {
166          export component-descriptor;
167        }
168        "#,
169        world: "component-v0-v6-v0",
170    });
171}
172
173// Component ABI bridge for runner-host: adds greentic:component/node@0.6 invoke envelope/result adapters and
174// keeps backward compatibility with v0.5/v0.4.
175pub mod v0_6 {
176    wasmtime::component::bindgen!({
177        inline: r#"
178        package greentic:component@0.6.0;
179
180        interface node {
181          type capability-id = string;
182          type component-id = string;
183          type flow-id = string;
184          type step-id = string;
185          type tenant-id = string;
186          type team-id = string;
187          type user-id = string;
188          type env-id = string;
189          type trace-id = string;
190          type correlation-id = string;
191
192          record node-error {
193            code: string,
194            message: string,
195            retryable: bool,
196            backoff-ms: option<u64>,
197            details: option<list<u8>>,
198          }
199
200          record tenant-ctx {
201            tenant-id: tenant-id,
202            team-id: option<team-id>,
203            user-id: option<user-id>,
204            env-id: env-id,
205            trace-id: trace-id,
206            correlation-id: correlation-id,
207            deadline-ms: u64,
208            attempt: u32,
209            idempotency-key: option<string>,
210            i18n-id: string,
211          }
212
213          record invocation-envelope {
214            ctx: tenant-ctx,
215            flow-id: flow-id,
216            step-id: step-id,
217            component-id: component-id,
218            attempt: u32,
219            payload-cbor: list<u8>,
220            metadata-cbor: option<list<u8>>,
221          }
222
223          record invocation-result {
224            ok: bool,
225            output-cbor: list<u8>,
226            output-metadata-cbor: option<list<u8>>,
227          }
228
229          invoke: func(op: string, envelope: invocation-envelope) -> result<invocation-result, node-error>;
230        }
231
232        world component {
233          export node;
234        }
235        "#,
236        world: "component",
237    });
238}
239
240pub mod v0_6_runtime {
241    wasmtime::component::bindgen!({
242        inline: r#"
243        package greentic:component@0.6.0;
244
245        interface component-runtime {
246          record run-result {
247            output: list<u8>,
248            new-state: list<u8>,
249          }
250          run: func(input: list<u8>, state: list<u8>) -> run-result;
251        }
252
253        world component-v0-v6-runtime {
254          export component-runtime;
255        }
256        "#,
257        world: "component-v0-v6-runtime",
258    });
259}
260
261pub mod node {
262    pub type Json = String;
263
264    #[derive(Clone, Debug)]
265    pub struct TenantCtx {
266        pub tenant: String,
267        pub team: Option<String>,
268        pub user: Option<String>,
269        pub trace_id: Option<String>,
270        pub i18n_id: Option<String>,
271        pub correlation_id: Option<String>,
272        pub deadline_unix_ms: Option<u64>,
273        pub attempt: u32,
274        pub idempotency_key: Option<String>,
275    }
276
277    #[derive(Clone, Debug)]
278    pub struct ExecCtx {
279        pub tenant: TenantCtx,
280        pub i18n_id: Option<String>,
281        pub flow_id: String,
282        pub node_id: Option<String>,
283    }
284
285    #[derive(Clone, Debug)]
286    pub struct NodeError {
287        pub code: String,
288        pub message: String,
289        pub retryable: bool,
290        pub backoff_ms: Option<u64>,
291        pub details: Option<Json>,
292    }
293
294    #[derive(Clone, Debug)]
295    pub enum InvokeResult {
296        Ok(Json),
297        Err(NodeError),
298    }
299}
300
301pub fn exec_ctx_v0_4(ctx: &node::ExecCtx) -> v0_4::exports::greentic::component::node::ExecCtx {
302    v0_4::exports::greentic::component::node::ExecCtx {
303        tenant: v0_4::exports::greentic::component::node::TenantCtx {
304            tenant: ctx.tenant.tenant.clone(),
305            team: ctx.tenant.team.clone(),
306            user: ctx.tenant.user.clone(),
307            trace_id: ctx.tenant.trace_id.clone(),
308            correlation_id: ctx.tenant.correlation_id.clone(),
309            deadline_unix_ms: ctx.tenant.deadline_unix_ms,
310            attempt: ctx.tenant.attempt,
311            idempotency_key: ctx.tenant.idempotency_key.clone(),
312        },
313        flow_id: ctx.flow_id.clone(),
314        node_id: ctx.node_id.clone(),
315    }
316}
317
318pub fn exec_ctx_v0_5(ctx: &node::ExecCtx) -> v0_5::exports::greentic::component::node::ExecCtx {
319    let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
320    let team_id = ctx.tenant.team.clone();
321    let user_id = ctx.tenant.user.clone();
322    let deadline_ms = ctx
323        .tenant
324        .deadline_unix_ms
325        .and_then(|value| i64::try_from(value).ok());
326    v0_5::exports::greentic::component::node::ExecCtx {
327        tenant: v0_5::exports::greentic::component::node::TenantCtx {
328            env,
329            tenant: ctx.tenant.tenant.clone(),
330            tenant_id: ctx.tenant.tenant.clone(),
331            team: ctx.tenant.team.clone(),
332            team_id,
333            user: ctx.tenant.user.clone(),
334            user_id,
335            trace_id: ctx.tenant.trace_id.clone(),
336            i18n_id: ctx.tenant.i18n_id.clone(),
337            correlation_id: ctx.tenant.correlation_id.clone(),
338            attributes: Vec::new(),
339            session_id: ctx.tenant.correlation_id.clone(),
340            flow_id: Some(ctx.flow_id.clone()),
341            node_id: ctx.node_id.clone(),
342            provider_id: None,
343            deadline_ms,
344            attempt: ctx.tenant.attempt,
345            idempotency_key: ctx.tenant.idempotency_key.clone(),
346            impersonation: None,
347        },
348        i18n_id: ctx.i18n_id.clone(),
349        flow_id: ctx.flow_id.clone(),
350        node_id: ctx.node_id.clone(),
351    }
352}
353
354pub fn envelope_v0_6(
355    ctx: &node::ExecCtx,
356    component_id: &str,
357    payload_json: &str,
358) -> Result<v0_6::exports::greentic::component::node::InvocationEnvelope> {
359    let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
360    let step_id = ctx
361        .node_id
362        .clone()
363        .unwrap_or_else(|| "component.exec".to_string());
364    let i18n_id = ctx
365        .i18n_id
366        .clone()
367        .or_else(|| ctx.tenant.i18n_id.clone())
368        .unwrap_or_default();
369    let trace_id = ctx.tenant.trace_id.clone().unwrap_or_default();
370    let correlation_id = ctx.tenant.correlation_id.clone().unwrap_or_default();
371    let deadline_ms = ctx.tenant.deadline_unix_ms.unwrap_or(u64::MAX);
372    let payload_cbor = if let Ok(invocation) =
373        serde_json::from_str::<greentic_types::InvocationEnvelope>(payload_json)
374    {
375        match serde_json::from_slice::<serde_json::Value>(&invocation.payload) {
376            Ok(value) => serde_cbor::to_vec(&value)?,
377            Err(_) => serde_cbor::to_vec(&serde_cbor::Value::Bytes(invocation.payload))?,
378        }
379    } else {
380        let payload_value = serde_json::from_str::<serde_json::Value>(payload_json)
381            .unwrap_or_else(|_| serde_json::Value::String(payload_json.to_string()));
382        serde_cbor::to_vec(&payload_value)?
383    };
384
385    Ok(
386        v0_6::exports::greentic::component::node::InvocationEnvelope {
387            ctx: v0_6::exports::greentic::component::node::TenantCtx {
388                tenant_id: ctx.tenant.tenant.clone(),
389                team_id: ctx.tenant.team.clone(),
390                user_id: ctx.tenant.user.clone(),
391                env_id: env,
392                trace_id,
393                correlation_id,
394                deadline_ms,
395                attempt: ctx.tenant.attempt,
396                idempotency_key: ctx.tenant.idempotency_key.clone(),
397                i18n_id,
398            },
399            flow_id: ctx.flow_id.clone(),
400            step_id,
401            component_id: component_id.to_string(),
402            attempt: ctx.tenant.attempt,
403            payload_cbor,
404            metadata_cbor: None,
405        },
406    )
407}
408
409pub fn invoke_result_from_v0_4(
410    result: v0_4::exports::greentic::component::node::InvokeResult,
411) -> node::InvokeResult {
412    match result {
413        v0_4::exports::greentic::component::node::InvokeResult::Ok(body) => {
414            node::InvokeResult::Ok(body)
415        }
416        v0_4::exports::greentic::component::node::InvokeResult::Err(err) => {
417            node::InvokeResult::Err(node::NodeError {
418                code: err.code,
419                message: err.message,
420                retryable: err.retryable,
421                backoff_ms: err.backoff_ms,
422                details: err.details,
423            })
424        }
425    }
426}
427
428pub fn invoke_result_from_v0_6(
429    result: std::result::Result<
430        v0_6::exports::greentic::component::node::InvocationResult,
431        v0_6::exports::greentic::component::node::NodeError,
432    >,
433) -> Result<node::InvokeResult> {
434    match result {
435        Ok(ok) => {
436            let body = cbor_to_json_string(&ok.output_cbor);
437            if ok.ok {
438                Ok(node::InvokeResult::Ok(body))
439            } else {
440                Ok(node::InvokeResult::Err(node::NodeError {
441                    code: "COMPONENT_INVOCATION_FAILED".to_string(),
442                    message: body,
443                    retryable: false,
444                    backoff_ms: None,
445                    details: None,
446                }))
447            }
448        }
449        Err(err) => Ok(node::InvokeResult::Err(node::NodeError {
450            code: err.code,
451            message: err.message,
452            retryable: err.retryable,
453            backoff_ms: err.backoff_ms,
454            details: err.details.map(|bytes| cbor_to_json_string(bytes.as_ref())),
455        })),
456    }
457}
458
459pub fn invoke_result_from_v0_5(
460    result: v0_5::exports::greentic::component::node::InvokeResult,
461) -> node::InvokeResult {
462    match result {
463        v0_5::exports::greentic::component::node::InvokeResult::Ok(body) => {
464            node::InvokeResult::Ok(body)
465        }
466        v0_5::exports::greentic::component::node::InvokeResult::Err(err) => {
467            node::InvokeResult::Err(node::NodeError {
468                code: err.code,
469                message: err.message,
470                retryable: err.retryable,
471                backoff_ms: err.backoff_ms,
472                details: err.details,
473            })
474        }
475    }
476}
477
478fn cbor_to_json_string(bytes: &[u8]) -> String {
479    match serde_cbor::from_slice::<serde_json::Value>(bytes)
480        .ok()
481        .and_then(|value| serde_json::to_string(&value).ok())
482    {
483        Some(json) => json,
484        None => String::from_utf8_lossy(bytes).to_string(),
485    }
486}
487
488/// Convert v0.6 `component-runtime::run()` output to the canonical InvokeResult.
489/// Decodes CBOR output bytes to a JSON string.
490pub fn invoke_result_from_v0_6_run(
491    result: v0_6_runtime::exports::greentic::component::component_runtime::RunResult,
492) -> node::InvokeResult {
493    let json_str = match serde_cbor::from_slice::<serde_json::Value>(&result.output) {
494        Ok(value) => serde_json::to_string(&value).unwrap_or_default(),
495        Err(_) => {
496            // Fallback: try as UTF-8 string
497            String::from_utf8(result.output).unwrap_or_default()
498        }
499    };
500    node::InvokeResult::Ok(json_str)
501}
502
503#[cfg(test)]
504mod tests {
505    use super::{envelope_v0_6, node};
506    use greentic_types::{EnvId, InvocationEnvelope, TenantCtx, TenantId};
507    use std::str::FromStr;
508
509    fn sample_exec_ctx() -> node::ExecCtx {
510        node::ExecCtx {
511            tenant: node::TenantCtx {
512                tenant: "tenant.demo".to_string(),
513                team: Some("team.demo".to_string()),
514                user: Some("user.demo".to_string()),
515                trace_id: Some("trace.demo".to_string()),
516                i18n_id: Some("en-US".to_string()),
517                correlation_id: Some("corr.demo".to_string()),
518                deadline_unix_ms: Some(123),
519                attempt: 2,
520                idempotency_key: Some("idem.demo".to_string()),
521            },
522            i18n_id: Some("en-US".to_string()),
523            flow_id: "flow.demo".to_string(),
524            node_id: Some("node.demo".to_string()),
525        }
526    }
527
528    #[test]
529    fn envelope_v0_6_preserves_binary_payload_when_envelope_payload_is_not_json() {
530        let envelope = InvocationEnvelope {
531            ctx: TenantCtx::new(
532                EnvId::from_str("local").expect("valid env id"),
533                TenantId::from_str("tenant.default").expect("valid tenant id"),
534            ),
535            flow_id: "flow.demo".to_string(),
536            node_id: Some("node.demo".to_string()),
537            op: "on_message".to_string(),
538            payload: vec![255, 0, 1, 42],
539            metadata: Vec::new(),
540        };
541        let payload_json = serde_json::to_string(&envelope).expect("serialize invocation envelope");
542        let envelope = envelope_v0_6(&sample_exec_ctx(), "component.demo", &payload_json)
543            .expect("envelope conversion");
544        let decoded: serde_cbor::Value =
545            serde_cbor::from_slice(&envelope.payload_cbor).expect("decode cbor");
546        assert_eq!(
547            decoded,
548            serde_cbor::Value::Bytes(vec![255, 0, 1, 42]),
549            "binary payload must not be dropped when not valid json bytes"
550        );
551    }
552}