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