1#![cfg_attr(not(feature = "std"), no_std)]
2#![forbid(unsafe_code)]
3#![deny(missing_docs)]
4#![warn(clippy::unwrap_used, clippy::expect_used)]
5
6extern crate alloc;
51
52pub const VERSION: &str = env!("CARGO_PKG_VERSION");
54pub const SCHEMA_BASE_URL: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1";
56
57pub mod bindings;
58pub mod capabilities;
59pub mod component;
60pub mod deployment;
61pub mod flow;
62pub mod pack_manifest;
63pub mod pack_spec;
64
65pub mod context;
66pub mod error;
67pub mod outcome;
68pub mod pack;
69pub mod policy;
70pub mod run;
71#[cfg(all(feature = "schemars", feature = "std"))]
72pub mod schema;
73pub mod session;
74pub mod state;
75pub mod telemetry;
76pub mod tenant;
77
78pub use bindings::hints::{
79 BindingsHints, EnvHints, McpHints, McpServer, NetworkHints, SecretsHints,
80};
81pub use capabilities::{
82 Capabilities, FsCaps, HttpCaps, KvCaps, Limits, NetCaps, SecretsCaps, TelemetrySpec, ToolsCaps,
83};
84pub use component::{
85 ComponentCapabilities, ComponentConfigurators, ComponentManifest, ComponentProfileError,
86 ComponentProfiles, EnvCapabilities, EventsCapabilities, FilesystemCapabilities, FilesystemMode,
87 FilesystemMount, HostCapabilities, HttpCapabilities, IaCCapabilities, MessagingCapabilities,
88 SecretsCapabilities, StateCapabilities, TelemetryCapabilities, TelemetryScope,
89 WasiCapabilities,
90};
91pub use context::{Cloud, DeploymentCtx, Platform};
92pub use deployment::{
93 ChannelPlan, DeploymentPlan, MessagingPlan, MessagingSubjectPlan, OAuthPlan, RunnerPlan,
94 SecretPlan, TelemetryPlan,
95};
96pub use error::{ErrorCode, GResult, GreenticError};
97pub use flow::{Flow, FlowKind, FlowNodes, FlowValidationError, Node};
98pub use outcome::Outcome;
99pub use pack::{PackRef, Signature, SignatureAlgorithm};
100pub use pack_manifest::{PackComponentRef, PackFlowRef, PackKind, PackManifest};
101#[allow(deprecated)]
102pub use pack_spec::{PackSpec, ToolSpec};
103pub use policy::{AllowList, NetworkPolicy, PolicyDecision, Protocol};
104#[cfg(feature = "time")]
105pub use run::RunResult;
106pub use run::{NodeFailure, NodeStatus, NodeSummary, RunStatus, TranscriptOffset};
107pub use session::canonical_session_key;
108pub use session::{SessionCursor, SessionData, SessionKey};
109pub use state::{StateKey, StatePath};
110#[cfg(feature = "otel-keys")]
111pub use telemetry::OtlpKeys;
112pub use telemetry::SpanContext;
113#[cfg(feature = "telemetry-autoinit")]
114pub use telemetry::TelemetryCtx;
115pub use tenant::{Impersonation, TenantIdentity};
116
117#[cfg(feature = "schemars")]
118use alloc::borrow::Cow;
119use alloc::{borrow::ToOwned, format, string::String, vec::Vec};
120use core::fmt;
121use core::str::FromStr;
122#[cfg(feature = "schemars")]
123use schemars::JsonSchema;
124use semver::VersionReq;
125#[cfg(feature = "time")]
126use time::OffsetDateTime;
127
128#[cfg(feature = "serde")]
129use serde::{Deserialize, Serialize};
130
131#[cfg(feature = "std")]
132use alloc::boxed::Box;
133
134#[cfg(feature = "std")]
135use std::error::Error as StdError;
136
137fn validate_identifier(value: &str, label: &str) -> GResult<()> {
138 if value.is_empty() {
139 return Err(GreenticError::new(
140 ErrorCode::InvalidInput,
141 format!("{label} must not be empty"),
142 ));
143 }
144 if value
145 .chars()
146 .any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
147 {
148 return Err(GreenticError::new(
149 ErrorCode::InvalidInput,
150 format!("{label} must contain only ASCII letters, digits, '.', '-', or '_'"),
151 ));
152 }
153 Ok(())
154}
155
156pub mod ids {
158 pub const PACK_ID: &str =
160 "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-id.schema.json";
161 pub const COMPONENT_ID: &str =
163 "https://greentic-ai.github.io/greentic-types/schemas/v1/component-id.schema.json";
164 pub const FLOW_ID: &str =
166 "https://greentic-ai.github.io/greentic-types/schemas/v1/flow-id.schema.json";
167 pub const NODE_ID: &str =
169 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-id.schema.json";
170 pub const TENANT_CONTEXT: &str =
172 "https://greentic-ai.github.io/greentic-types/schemas/v1/tenant-context.schema.json";
173 pub const HASH_DIGEST: &str =
175 "https://greentic-ai.github.io/greentic-types/schemas/v1/hash-digest.schema.json";
176 pub const SEMVER_REQ: &str =
178 "https://greentic-ai.github.io/greentic-types/schemas/v1/semver-req.schema.json";
179 pub const REDACTION_PATH: &str =
181 "https://greentic-ai.github.io/greentic-types/schemas/v1/redaction-path.schema.json";
182 pub const CAPABILITIES: &str =
184 "https://greentic-ai.github.io/greentic-types/schemas/v1/capabilities.schema.json";
185 pub const FLOW: &str =
187 "https://greentic-ai.github.io/greentic-types/schemas/v1/flow.schema.json";
188 pub const NODE: &str =
190 "https://greentic-ai.github.io/greentic-types/schemas/v1/node.schema.json";
191 pub const COMPONENT_MANIFEST: &str =
193 "https://greentic-ai.github.io/greentic-types/schemas/v1/component-manifest.schema.json";
194 pub const PACK_MANIFEST: &str =
196 "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-manifest.schema.json";
197 pub const LIMITS: &str =
199 "https://greentic-ai.github.io/greentic-types/schemas/v1/limits.schema.json";
200 pub const TELEMETRY_SPEC: &str =
202 "https://greentic-ai.github.io/greentic-types/schemas/v1/telemetry-spec.schema.json";
203 pub const NODE_SUMMARY: &str =
205 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-summary.schema.json";
206 pub const NODE_FAILURE: &str =
208 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-failure.schema.json";
209 pub const NODE_STATUS: &str =
211 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-status.schema.json";
212 pub const RUN_STATUS: &str =
214 "https://greentic-ai.github.io/greentic-types/schemas/v1/run-status.schema.json";
215 pub const TRANSCRIPT_OFFSET: &str =
217 "https://greentic-ai.github.io/greentic-types/schemas/v1/transcript-offset.schema.json";
218 pub const TOOLS_CAPS: &str =
220 "https://greentic-ai.github.io/greentic-types/schemas/v1/tools-caps.schema.json";
221 pub const SECRETS_CAPS: &str =
223 "https://greentic-ai.github.io/greentic-types/schemas/v1/secrets-caps.schema.json";
224 pub const OTLP_KEYS: &str =
226 "https://greentic-ai.github.io/greentic-types/schemas/v1/otlp-keys.schema.json";
227 pub const RUN_RESULT: &str =
229 "https://greentic-ai.github.io/greentic-types/schemas/v1/run-result.schema.json";
230}
231
232#[cfg(all(feature = "schema", feature = "std"))]
233pub fn write_all_schemas(out_dir: &std::path::Path) -> anyhow::Result<()> {
235 use anyhow::Context;
236 use std::fs;
237
238 fs::create_dir_all(out_dir)
239 .with_context(|| format!("failed to create {}", out_dir.display()))?;
240
241 for entry in crate::schema::entries() {
242 let schema = (entry.generator)();
243 let path = out_dir.join(entry.file_name);
244 if let Some(parent) = path.parent() {
245 fs::create_dir_all(parent)
246 .with_context(|| format!("failed to create {}", parent.display()))?;
247 }
248
249 let json =
250 serde_json::to_vec_pretty(&schema).context("failed to serialize schema to JSON")?;
251 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
252 }
253
254 Ok(())
255}
256
257macro_rules! id_newtype {
258 ($name:ident, $doc:literal) => {
259 #[doc = $doc]
260 #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
261 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
262 #[cfg_attr(feature = "schemars", derive(JsonSchema))]
263 #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
264 pub struct $name(pub String);
265
266 impl $name {
267 pub fn as_str(&self) -> &str {
269 &self.0
270 }
271
272 pub fn new(value: impl AsRef<str>) -> GResult<Self> {
274 value.as_ref().parse()
275 }
276 }
277
278 impl From<$name> for String {
279 fn from(value: $name) -> Self {
280 value.0
281 }
282 }
283
284 impl AsRef<str> for $name {
285 fn as_ref(&self) -> &str {
286 self.as_str()
287 }
288 }
289
290 impl fmt::Display for $name {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 f.write_str(self.as_str())
293 }
294 }
295
296 impl FromStr for $name {
297 type Err = GreenticError;
298
299 fn from_str(value: &str) -> Result<Self, Self::Err> {
300 validate_identifier(value, stringify!($name))?;
301 Ok(Self(value.to_owned()))
302 }
303 }
304
305 impl TryFrom<String> for $name {
306 type Error = GreenticError;
307
308 fn try_from(value: String) -> Result<Self, Self::Error> {
309 $name::from_str(&value)
310 }
311 }
312
313 impl TryFrom<&str> for $name {
314 type Error = GreenticError;
315
316 fn try_from(value: &str) -> Result<Self, Self::Error> {
317 $name::from_str(value)
318 }
319 }
320 };
321}
322
323id_newtype!(EnvId, "Environment identifier for a tenant context.");
324id_newtype!(TenantId, "Tenant identifier within an environment.");
325id_newtype!(TeamId, "Team identifier belonging to a tenant.");
326id_newtype!(UserId, "User identifier within a tenant.");
327id_newtype!(PackId, "Globally unique pack identifier.");
328id_newtype!(
329 ComponentId,
330 "Identifier referencing a component binding in a pack."
331);
332id_newtype!(FlowId, "Identifier referencing a flow inside a pack.");
333id_newtype!(NodeId, "Identifier referencing a node inside a flow graph.");
334
335#[derive(Clone, Debug, PartialEq, Eq, Hash)]
337#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
338#[cfg_attr(feature = "schemars", derive(JsonSchema))]
339pub struct TenantContext {
340 pub tenant_id: TenantId,
342 #[cfg_attr(
344 feature = "serde",
345 serde(default, skip_serializing_if = "Option::is_none")
346 )]
347 pub team_id: Option<TeamId>,
348 #[cfg_attr(
350 feature = "serde",
351 serde(default, skip_serializing_if = "Option::is_none")
352 )]
353 pub user_id: Option<UserId>,
354 #[cfg_attr(
356 feature = "serde",
357 serde(default, skip_serializing_if = "Option::is_none")
358 )]
359 pub session_id: Option<String>,
360}
361
362impl TenantContext {
363 pub fn new(tenant_id: TenantId) -> Self {
365 Self {
366 tenant_id,
367 team_id: None,
368 user_id: None,
369 session_id: None,
370 }
371 }
372}
373
374impl From<&TenantCtx> for TenantContext {
375 fn from(ctx: &TenantCtx) -> Self {
376 Self {
377 tenant_id: ctx.tenant_id.clone(),
378 team_id: ctx.team_id.clone().or_else(|| ctx.team.clone()),
379 user_id: ctx.user_id.clone().or_else(|| ctx.user.clone()),
380 session_id: ctx.session_id.clone(),
381 }
382 }
383}
384
385#[derive(Clone, Debug, PartialEq, Eq, Hash)]
387#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
388#[cfg_attr(feature = "schemars", derive(JsonSchema))]
389#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
390pub enum HashAlgorithm {
391 Blake3,
393 Other(String),
395}
396
397#[derive(Clone, Debug, PartialEq, Eq, Hash)]
399#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
400#[cfg_attr(
401 feature = "serde",
402 serde(into = "HashDigestRepr", try_from = "HashDigestRepr")
403)]
404#[cfg_attr(feature = "schemars", derive(JsonSchema))]
405pub struct HashDigest {
406 pub algo: HashAlgorithm,
408 pub hex: String,
410}
411
412impl HashDigest {
413 pub fn new(algo: HashAlgorithm, hex: impl Into<String>) -> GResult<Self> {
415 let hex = hex.into();
416 validate_hex(&hex)?;
417 Ok(Self { algo, hex })
418 }
419
420 pub fn blake3(hex: impl Into<String>) -> GResult<Self> {
422 Self::new(HashAlgorithm::Blake3, hex)
423 }
424}
425
426#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
427#[cfg_attr(feature = "schemars", derive(JsonSchema))]
428struct HashDigestRepr {
429 algo: HashAlgorithm,
430 hex: String,
431}
432
433impl From<HashDigest> for HashDigestRepr {
434 fn from(value: HashDigest) -> Self {
435 Self {
436 algo: value.algo,
437 hex: value.hex,
438 }
439 }
440}
441
442impl TryFrom<HashDigestRepr> for HashDigest {
443 type Error = GreenticError;
444
445 fn try_from(value: HashDigestRepr) -> Result<Self, Self::Error> {
446 HashDigest::new(value.algo, value.hex)
447 }
448}
449
450fn validate_hex(hex: &str) -> GResult<()> {
451 if hex.is_empty() {
452 return Err(GreenticError::new(
453 ErrorCode::InvalidInput,
454 "digest hex payload must not be empty",
455 ));
456 }
457 if hex.len() % 2 != 0 {
458 return Err(GreenticError::new(
459 ErrorCode::InvalidInput,
460 "digest hex payload must have an even number of digits",
461 ));
462 }
463 if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
464 return Err(GreenticError::new(
465 ErrorCode::InvalidInput,
466 "digest hex payload must be hexadecimal",
467 ));
468 }
469 Ok(())
470}
471
472#[derive(Clone, Debug, PartialEq, Eq, Hash)]
474#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
475#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
476pub struct SemverReq(String);
477
478impl SemverReq {
479 pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
481 let value = value.as_ref();
482 VersionReq::parse(value).map_err(|err| {
483 GreenticError::new(
484 ErrorCode::InvalidInput,
485 format!("invalid semver requirement '{value}': {err}"),
486 )
487 })?;
488 Ok(Self(value.to_owned()))
489 }
490
491 pub fn as_str(&self) -> &str {
493 &self.0
494 }
495
496 pub fn to_version_req(&self) -> VersionReq {
498 VersionReq::parse(&self.0)
499 .unwrap_or_else(|err| unreachable!("SemverReq::parse validated inputs: {err}"))
500 }
501}
502
503impl fmt::Display for SemverReq {
504 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505 f.write_str(self.as_str())
506 }
507}
508
509impl From<SemverReq> for String {
510 fn from(value: SemverReq) -> Self {
511 value.0
512 }
513}
514
515impl TryFrom<String> for SemverReq {
516 type Error = GreenticError;
517
518 fn try_from(value: String) -> Result<Self, Self::Error> {
519 SemverReq::parse(&value)
520 }
521}
522
523impl TryFrom<&str> for SemverReq {
524 type Error = GreenticError;
525
526 fn try_from(value: &str) -> Result<Self, Self::Error> {
527 SemverReq::parse(value)
528 }
529}
530
531impl FromStr for SemverReq {
532 type Err = GreenticError;
533
534 fn from_str(s: &str) -> Result<Self, Self::Err> {
535 SemverReq::parse(s)
536 }
537}
538
539#[derive(Clone, Debug, PartialEq, Eq, Hash)]
541#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
542#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
543pub struct RedactionPath(String);
544
545impl RedactionPath {
546 pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
548 let value = value.as_ref();
549 validate_jsonpath(value)?;
550 Ok(Self(value.to_owned()))
551 }
552
553 pub fn as_str(&self) -> &str {
555 &self.0
556 }
557}
558
559impl fmt::Display for RedactionPath {
560 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561 f.write_str(self.as_str())
562 }
563}
564
565impl From<RedactionPath> for String {
566 fn from(value: RedactionPath) -> Self {
567 value.0
568 }
569}
570
571impl TryFrom<String> for RedactionPath {
572 type Error = GreenticError;
573
574 fn try_from(value: String) -> Result<Self, Self::Error> {
575 RedactionPath::parse(&value)
576 }
577}
578
579impl TryFrom<&str> for RedactionPath {
580 type Error = GreenticError;
581
582 fn try_from(value: &str) -> Result<Self, Self::Error> {
583 RedactionPath::parse(value)
584 }
585}
586
587fn validate_jsonpath(path: &str) -> GResult<()> {
588 if path.is_empty() {
589 return Err(GreenticError::new(
590 ErrorCode::InvalidInput,
591 "redaction path cannot be empty",
592 ));
593 }
594 if !path.starts_with('$') {
595 return Err(GreenticError::new(
596 ErrorCode::InvalidInput,
597 "redaction path must start with '$'",
598 ));
599 }
600 if path.chars().any(|c| c.is_control()) {
601 return Err(GreenticError::new(
602 ErrorCode::InvalidInput,
603 "redaction path cannot contain control characters",
604 ));
605 }
606 Ok(())
607}
608
609#[cfg(feature = "schemars")]
610impl JsonSchema for SemverReq {
611 fn schema_name() -> Cow<'static, str> {
612 Cow::Borrowed("SemverReq")
613 }
614
615 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
616 let mut schema = <String>::json_schema(generator);
617 if schema.get("description").is_none() {
618 schema.insert(
619 "description".into(),
620 "Validated semantic version requirement string".into(),
621 );
622 }
623 schema
624 }
625}
626
627#[cfg(feature = "schemars")]
628impl JsonSchema for RedactionPath {
629 fn schema_name() -> Cow<'static, str> {
630 Cow::Borrowed("RedactionPath")
631 }
632
633 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
634 let mut schema = <String>::json_schema(generator);
635 if schema.get("description").is_none() {
636 schema.insert(
637 "description".into(),
638 "JSONPath expression used for runtime redaction".into(),
639 );
640 }
641 schema
642 }
643}
644
645#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
647#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
648#[cfg_attr(feature = "schemars", derive(JsonSchema))]
649pub struct InvocationDeadline {
650 unix_millis: i128,
651}
652
653impl InvocationDeadline {
654 pub const fn from_unix_millis(unix_millis: i128) -> Self {
656 Self { unix_millis }
657 }
658
659 pub const fn unix_millis(&self) -> i128 {
661 self.unix_millis
662 }
663
664 #[cfg(feature = "time")]
666 pub fn to_offset_date_time(&self) -> Result<OffsetDateTime, time::error::ComponentRange> {
667 OffsetDateTime::from_unix_timestamp_nanos(self.unix_millis * 1_000_000)
668 }
669
670 #[cfg(feature = "time")]
672 pub fn from_offset_date_time(value: OffsetDateTime) -> Self {
673 let nanos = value.unix_timestamp_nanos();
674 Self {
675 unix_millis: nanos / 1_000_000,
676 }
677 }
678}
679
680#[derive(Clone, Debug, PartialEq, Eq, Hash)]
682#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
683#[cfg_attr(feature = "schemars", derive(JsonSchema))]
684pub struct TenantCtx {
685 pub env: EnvId,
687 pub tenant: TenantId,
689 pub tenant_id: TenantId,
691 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
693 pub team: Option<TeamId>,
694 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
696 pub team_id: Option<TeamId>,
697 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
699 pub user: Option<UserId>,
700 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
702 pub user_id: Option<UserId>,
703 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
705 pub session_id: Option<String>,
706 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
708 pub flow_id: Option<String>,
709 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
711 pub node_id: Option<String>,
712 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
714 pub provider_id: Option<String>,
715 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
717 pub trace_id: Option<String>,
718 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
720 pub correlation_id: Option<String>,
721 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
723 pub deadline: Option<InvocationDeadline>,
724 pub attempt: u32,
726 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
728 pub idempotency_key: Option<String>,
729 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
731 pub impersonation: Option<Impersonation>,
732}
733
734impl TenantCtx {
735 pub fn new(env: EnvId, tenant: TenantId) -> Self {
737 let tenant_id = tenant.clone();
738 Self {
739 env,
740 tenant: tenant.clone(),
741 tenant_id,
742 team: None,
743 team_id: None,
744 user: None,
745 user_id: None,
746 session_id: None,
747 flow_id: None,
748 node_id: None,
749 provider_id: None,
750 trace_id: None,
751 correlation_id: None,
752 deadline: None,
753 attempt: 0,
754 idempotency_key: None,
755 impersonation: None,
756 }
757 }
758
759 pub fn with_team(mut self, team: Option<TeamId>) -> Self {
761 self.team = team.clone();
762 self.team_id = team;
763 self
764 }
765
766 pub fn with_user(mut self, user: Option<UserId>) -> Self {
768 self.user = user.clone();
769 self.user_id = user;
770 self
771 }
772
773 pub fn with_session(mut self, session: impl Into<String>) -> Self {
775 self.session_id = Some(session.into());
776 self
777 }
778
779 pub fn with_flow(mut self, flow: impl Into<String>) -> Self {
781 self.flow_id = Some(flow.into());
782 self
783 }
784
785 pub fn with_node(mut self, node: impl Into<String>) -> Self {
787 self.node_id = Some(node.into());
788 self
789 }
790
791 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
793 self.provider_id = Some(provider.into());
794 self
795 }
796
797 pub fn with_impersonation(mut self, impersonation: Option<Impersonation>) -> Self {
799 self.impersonation = impersonation;
800 self
801 }
802
803 pub fn with_attempt(mut self, attempt: u32) -> Self {
805 self.attempt = attempt;
806 self
807 }
808
809 pub fn with_deadline(mut self, deadline: Option<InvocationDeadline>) -> Self {
811 self.deadline = deadline;
812 self
813 }
814
815 pub fn session_id(&self) -> Option<&str> {
817 self.session_id.as_deref()
818 }
819
820 pub fn flow_id(&self) -> Option<&str> {
822 self.flow_id.as_deref()
823 }
824
825 pub fn node_id(&self) -> Option<&str> {
827 self.node_id.as_deref()
828 }
829
830 pub fn provider_id(&self) -> Option<&str> {
832 self.provider_id.as_deref()
833 }
834}
835
836pub type BinaryPayload = Vec<u8>;
838
839#[derive(Clone, Debug, PartialEq, Eq)]
841#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
842#[cfg_attr(feature = "schemars", derive(JsonSchema))]
843pub struct InvocationEnvelope {
844 pub ctx: TenantCtx,
846 pub flow_id: String,
848 pub node_id: Option<String>,
850 pub op: String,
852 pub payload: BinaryPayload,
854 pub metadata: BinaryPayload,
856}
857
858#[derive(Clone, Debug, PartialEq, Eq)]
860#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
861#[cfg_attr(feature = "schemars", derive(JsonSchema))]
862pub enum ErrorDetail {
863 Text(String),
865 Binary(BinaryPayload),
867}
868
869#[derive(Debug)]
871#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
872#[cfg_attr(feature = "schemars", derive(JsonSchema))]
873pub struct NodeError {
874 pub code: String,
876 pub message: String,
878 pub retryable: bool,
880 pub backoff_ms: Option<u64>,
882 pub details: Option<ErrorDetail>,
884 #[cfg(feature = "std")]
885 #[cfg_attr(feature = "serde", serde(skip, default = "default_source"))]
886 #[cfg_attr(feature = "schemars", schemars(skip))]
887 source: Option<Box<dyn StdError + Send + Sync>>,
888}
889
890#[cfg(all(feature = "std", feature = "serde"))]
891fn default_source() -> Option<Box<dyn StdError + Send + Sync>> {
892 None
893}
894
895impl NodeError {
896 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
898 Self {
899 code: code.into(),
900 message: message.into(),
901 retryable: false,
902 backoff_ms: None,
903 details: None,
904 #[cfg(feature = "std")]
905 source: None,
906 }
907 }
908
909 pub fn with_retry(mut self, backoff_ms: Option<u64>) -> Self {
911 self.retryable = true;
912 self.backoff_ms = backoff_ms;
913 self
914 }
915
916 pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
918 self.details = Some(detail);
919 self
920 }
921
922 pub fn with_detail_text(mut self, detail: impl Into<String>) -> Self {
924 self.details = Some(ErrorDetail::Text(detail.into()));
925 self
926 }
927
928 pub fn with_detail_binary(mut self, detail: BinaryPayload) -> Self {
930 self.details = Some(ErrorDetail::Binary(detail));
931 self
932 }
933
934 #[cfg(feature = "std")]
935 pub fn with_source<E>(mut self, source: E) -> Self
937 where
938 E: StdError + Send + Sync + 'static,
939 {
940 self.source = Some(Box::new(source));
941 self
942 }
943
944 pub fn detail(&self) -> Option<&ErrorDetail> {
946 self.details.as_ref()
947 }
948
949 #[cfg(feature = "std")]
950 pub fn source(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
952 self.source.as_deref()
953 }
954}
955
956impl fmt::Display for NodeError {
957 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
958 write!(f, "{}: {}", self.code, self.message)
959 }
960}
961
962#[cfg(feature = "std")]
963impl StdError for NodeError {
964 fn source(&self) -> Option<&(dyn StdError + 'static)> {
965 self.source
966 .as_ref()
967 .map(|err| err.as_ref() as &(dyn StdError + 'static))
968 }
969}
970
971pub type NodeResult<T> = Result<T, NodeError>;
973
974pub fn make_idempotency_key(
979 ctx: &TenantCtx,
980 flow_id: &str,
981 node_id: Option<&str>,
982 correlation: Option<&str>,
983) -> String {
984 let node_segment = node_id.unwrap_or_default();
985 let correlation_segment = correlation
986 .or(ctx.correlation_id.as_deref())
987 .unwrap_or_default();
988 let input = format!(
989 "{}|{}|{}|{}",
990 ctx.tenant_id.as_str(),
991 flow_id,
992 node_segment,
993 correlation_segment
994 );
995 fnv1a_128_hex(input.as_bytes())
996}
997
998const FNV_OFFSET_BASIS: u128 = 0x6c62272e07bb014262b821756295c58d;
999const FNV_PRIME: u128 = 0x0000000001000000000000000000013b;
1000
1001fn fnv1a_128_hex(bytes: &[u8]) -> String {
1002 let mut hash = FNV_OFFSET_BASIS;
1003 for &byte in bytes {
1004 hash ^= byte as u128;
1005 hash = hash.wrapping_mul(FNV_PRIME);
1006 }
1007
1008 let mut output = String::with_capacity(32);
1009 for shift in (0..32).rev() {
1010 let nibble = ((hash >> (shift * 4)) & 0x0f) as u8;
1011 output.push(match nibble {
1012 0..=9 => (b'0' + nibble) as char,
1013 _ => (b'a' + (nibble - 10)) as char,
1014 });
1015 }
1016 output
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use core::convert::TryFrom;
1023 use time::OffsetDateTime;
1024
1025 fn sample_ctx() -> TenantCtx {
1026 let env = EnvId::try_from("prod").unwrap_or_else(|err| panic!("{err}"));
1027 let tenant = TenantId::try_from("tenant-123").unwrap_or_else(|err| panic!("{err}"));
1028 let team = TeamId::try_from("team-456").unwrap_or_else(|err| panic!("{err}"));
1029 let user = UserId::try_from("user-789").unwrap_or_else(|err| panic!("{err}"));
1030
1031 let mut ctx = TenantCtx::new(env, tenant)
1032 .with_team(Some(team))
1033 .with_user(Some(user))
1034 .with_attempt(2)
1035 .with_deadline(Some(InvocationDeadline::from_unix_millis(
1036 1_700_000_000_000,
1037 )));
1038 ctx.trace_id = Some("trace-abc".to_owned());
1039 ctx.correlation_id = Some("corr-xyz".to_owned());
1040 ctx.idempotency_key = Some("key-123".to_owned());
1041 ctx
1042 }
1043
1044 #[test]
1045 fn idempotent_key_stable() {
1046 let ctx = sample_ctx();
1047 let key_a = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1048 let key_b = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1049 assert_eq!(key_a, key_b);
1050 assert_eq!(key_a.len(), 32);
1051 }
1052
1053 #[test]
1054 fn idempotent_key_uses_context_correlation() {
1055 let ctx = sample_ctx();
1056 let key = make_idempotency_key(&ctx, "flow-1", None, None);
1057 let expected = make_idempotency_key(&ctx, "flow-1", None, ctx.correlation_id.as_deref());
1058 assert_eq!(key, expected);
1059 }
1060
1061 #[test]
1062 #[cfg(feature = "time")]
1063 fn deadline_roundtrips_through_offset_datetime() {
1064 let dt = OffsetDateTime::from_unix_timestamp(1_700_000_000)
1065 .unwrap_or_else(|err| panic!("valid timestamp: {err}"));
1066 let deadline = InvocationDeadline::from_offset_date_time(dt);
1067 let roundtrip = deadline
1068 .to_offset_date_time()
1069 .unwrap_or_else(|err| panic!("round-trip conversion failed: {err}"));
1070 let millis = dt.unix_timestamp_nanos() / 1_000_000;
1071 assert_eq!(deadline.unix_millis(), millis);
1072 assert_eq!(roundtrip.unix_timestamp_nanos() / 1_000_000, millis);
1073 }
1074
1075 #[test]
1076 fn node_error_builder_sets_fields() {
1077 let err = NodeError::new("TEST", "example")
1078 .with_retry(Some(500))
1079 .with_detail_text("context");
1080
1081 assert!(err.retryable);
1082 assert_eq!(err.backoff_ms, Some(500));
1083 match err.detail() {
1084 Some(ErrorDetail::Text(detail)) => assert_eq!(detail, "context"),
1085 other => panic!("unexpected detail {other:?}"),
1086 }
1087 }
1088
1089 #[cfg(feature = "std")]
1090 #[test]
1091 fn node_error_source_roundtrips() {
1092 use std::io::Error;
1093
1094 let source = Error::other("boom");
1095 let err = NodeError::new("TEST", "example").with_source(source);
1096 assert!(err.source().is_some());
1097 }
1098}