Skip to main content

cloacina_workflow_plugin/
lib.rs

1/*
2 *  Copyright 2025-2026 Colliery Software
3 *
4 *  Licensed under the Apache License, Version 2.0 (the "License");
5 *  you may not use this file except in compliance with the License.
6 *  You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 *  Unless required by applicable law or agreed to in writing, software
11 *  distributed under the License is distributed on an "AS IS" BASIS,
12 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 *  See the License for the specific language governing permissions and
14 *  limitations under the License.
15 */
16
17//! Cloacina plugin interface for the fidius plugin system.
18//!
19//! This crate defines the plugin contract between cloacina workflow packages
20//! (compiled as cdylib plugins) and the cloacina host engine. Both sides
21//! depend on this crate — it is the single source of truth for the FFI ABI.
22//!
23//! # For plugin authors
24//!
25//! Use `#[workflow]` with `features = ["packaged"]` — the macro generates
26//! the `#[plugin_impl(CloacinaPlugin)]` code automatically. You don't need
27//! to implement this trait directly.
28//!
29//! # For host integration
30//!
31//! Use `fidius-host` to load plugins and call methods through `PluginHandle`.
32//! Validate loaded plugins against `CloacinaPlugin_INTERFACE_HASH` to detect
33//! ABI drift at load time.
34
35// The `#[fidius::plugin_interface]` macro on `CloacinaPlugin` emits a
36// `#[cfg(host)]` gate and produces methods whose arity exceeds clippy's
37// default 7-argument cap. Both warnings are internal to the fidius
38// macro and not actionable at our call site.
39#![allow(unexpected_cfgs, clippy::too_many_arguments)]
40
41pub mod inventory_entries;
42pub mod types;
43
44pub use inventory_entries::{
45    ComputationGraphEntry, ReactorEntry, TaskEntry, TriggerEntry, TriggerlessGraph,
46    TriggerlessGraphEntry, TriggerlessGraphFn, TriggerlessGraphRegistration,
47    WorkflowDescriptorEntry,
48};
49
50// Re-export the interface types for convenience
51pub use types::{
52    AccumulatorDeclarationEntry, CloacinaMetadata, GraphExecutionRequest, GraphExecutionResult,
53    GraphPackageMetadata, PackageTasksMetadata, ReactorPackageMetadata, TaskExecutionRequest,
54    TaskExecutionResult, TaskMetadataEntry, TriggerInvokeRequest, TriggerInvokeResult,
55    TriggerPackageMetadata, TriggerlessGraphInvokeRequest, TriggerlessGraphInvokeResult,
56    TriggerlessGraphMetadataEntry,
57};
58
59// Re-export fidius crates so generated code can reference them
60pub use fidius;
61pub use fidius_core;
62
63// Re-export fidius modules needed by generated code when crate = "cloacina_workflow_plugin"
64pub use fidius_core::descriptor;
65pub use fidius_core::inventory;
66pub use fidius_core::registry;
67pub use fidius_core::status;
68pub use fidius_core::wire;
69
70// Re-export fidius types that plugin authors need
71pub use fidius::plugin_impl;
72pub use fidius_core::error::PluginError;
73pub use fidius_core::package::{PackageHeader, PackageManifest};
74
75// Re-export the plugin registry macro
76pub use fidius::fidius_plugin_registry;
77
78/// Unified plugin shell macro for I-0102.
79///
80/// Single-line invocation at the crate root of any packaged Rust cdylib:
81///
82/// ```rust,ignore
83/// cloacina::package!();
84/// ```
85///
86/// Emits, gated on `#[cfg(feature = "packaged")]`:
87///
88/// 1. A `pub mod _ffi` containing a `CloacinaPackagePlugin` unit struct.
89/// 2. A `#[plugin_impl]` block exporting the version-2 `CloacinaPlugin`
90///    trait. Six methods: `get_task_metadata`, `execute_task`,
91///    `get_graph_metadata`, `execute_graph`, `get_reactor_metadata`,
92///    `get_trigger_metadata`. The reactor body walks
93///    `inventory::iter::<ReactorEntry>` and projects each entry to a
94///    `ReactorPackageMetadata` value. The other bodies are stubs at this
95///    iteration of T-A — they ship empty/NotImplemented and will be
96///    fleshed out as the remaining inventory entry types relocate to leaf
97///    crates reachable from packaged cdylibs.
98/// 3. A `mod __cloacina_package_marker { pub struct Once; }` guard. A
99///    second invocation in the same crate fails to compile (duplicate
100///    module name).
101/// 4. A `fidius_plugin_registry!()` invocation to export the plugin.
102///
103/// **Coexistence:** in T-A the per-macro `_ffi` emission from
104/// `#[computation_graph]` and `#[workflow]` is unchanged. A crate that
105/// adds `cloacina::package!();` AND has `#[computation_graph]` /
106/// `#[workflow]` would emit two `fidius_plugin_registry!()` calls →
107/// linker conflict. T-C strips the per-macro emission so the shell
108/// becomes the only path.
109#[macro_export]
110macro_rules! package {
111    () => {
112        #[cfg(feature = "packaged")]
113        pub mod _ffi {
114            use $crate::CloacinaPlugin as _;
115            use $crate::__fidius_CloacinaPlugin;
116
117            // Single-emission guard: a duplicate `cloacina::package!()` in
118            // the same crate produces "the name `__cloacina_package_marker`
119            // is defined multiple times" at compile time.
120            mod __cloacina_package_marker {
121                pub struct Once;
122            }
123
124            pub struct CloacinaPackagePlugin;
125
126            #[$crate::plugin_impl(CloacinaPlugin, crate = "cloacina_workflow_plugin")]
127            impl $crate::CloacinaPlugin for CloacinaPackagePlugin {
128                fn get_task_metadata(
129                    &self,
130                ) -> ::core::result::Result<$crate::PackageTasksMetadata, $crate::PluginError>
131                {
132                    let mut tasks: ::std::vec::Vec<$crate::TaskMetadataEntry> =
133                        ::std::vec::Vec::new();
134                    let mut workflow_name: ::std::string::String = ::std::string::String::new();
135                    for (idx, entry) in $crate::inventory::iter::<$crate::TaskEntry>
136                        .into_iter()
137                        .enumerate()
138                    {
139                        let ns = (entry.namespace)();
140                        if workflow_name.is_empty() {
141                            workflow_name = ns.workflow_id.clone();
142                        }
143                        let task = (entry.constructor)();
144                        let dependencies: ::std::vec::Vec<::std::string::String> =
145                            cloacina_workflow::Task::dependencies(&*task)
146                                .iter()
147                                .map(|n| n.task_id.clone())
148                                .collect();
149                        tasks.push($crate::TaskMetadataEntry {
150                            index: idx as u32,
151                            id: cloacina_workflow::Task::id(&*task).to_string(),
152                            namespaced_id_template: format!(
153                                "{}::{}::{}::{}",
154                                ns.tenant_id, ns.package_name, ns.workflow_id, ns.task_id,
155                            ),
156                            dependencies,
157                            description: format!("Task: {}", cloacina_workflow::Task::id(&*task)),
158                            source_location: format!("{}/lib.rs", env!("CARGO_PKG_NAME")),
159                        });
160                    }
161                    // Look up WorkflowDescriptorEntry for description / author /
162                    // fingerprint / graph_data / triggers. Multiple workflows in
163                    // the same cdylib aren't supported by the shell — the first
164                    // descriptor wins; T-D fixtures verify single-workflow
165                    // assumption.
166                    let descriptor = $crate::inventory::iter::<$crate::WorkflowDescriptorEntry>
167                        .into_iter()
168                        .next();
169                    let (description, author, fingerprint, graph_data_json, triggers) =
170                        match descriptor {
171                            Some(d) => (
172                                if d.description.is_empty() {
173                                    None
174                                } else {
175                                    Some(d.description.to_string())
176                                },
177                                if d.author.is_empty() {
178                                    None
179                                } else {
180                                    Some(d.author.to_string())
181                                },
182                                if d.fingerprint.is_empty() {
183                                    None
184                                } else {
185                                    Some(d.fingerprint.to_string())
186                                },
187                                if d.graph_data_json.is_empty() {
188                                    None
189                                } else {
190                                    Some(d.graph_data_json.to_string())
191                                },
192                                (d.triggers)(),
193                            ),
194                            None => (None, None, None, None, ::std::vec::Vec::new()),
195                        };
196                    Ok($crate::PackageTasksMetadata {
197                        workflow_name,
198                        package_name: env!("CARGO_PKG_NAME").to_string(),
199                        package_description: description,
200                        package_author: author,
201                        workflow_fingerprint: fingerprint,
202                        graph_data_json,
203                        tasks,
204                        triggers,
205                    })
206                }
207
208                fn execute_task(
209                    &self,
210                    request: $crate::TaskExecutionRequest,
211                ) -> ::core::result::Result<$crate::TaskExecutionResult, $crate::PluginError>
212                {
213                    use $crate::CloacinaPlugin as _;
214                    static CDYLIB_RUNTIME: ::std::sync::OnceLock<
215                        cloacina_workflow::__private::tokio::runtime::Runtime,
216                    > = ::std::sync::OnceLock::new();
217                    let rt = CDYLIB_RUNTIME.get_or_init(|| {
218                        cloacina_workflow::__private::tokio::runtime::Builder::new_multi_thread()
219                            .enable_all()
220                            .worker_threads(2)
221                            .thread_name("package-shell-cdylib-worker")
222                            .build()
223                            .expect("Failed to create cdylib tokio runtime")
224                    });
225
226                    // Resolve the named task by walking inventory.
227                    let task_arc_opt = $crate::inventory::iter::<$crate::TaskEntry>
228                        .into_iter()
229                        .map(|entry| (entry.constructor)())
230                        .find(|t| cloacina_workflow::Task::id(&**t) == request.task_name);
231
232                    let task = match task_arc_opt {
233                        Some(t) => t,
234                        None => {
235                            return Ok($crate::TaskExecutionResult {
236                                success: false,
237                                context_json: None,
238                                error: Some(format!("Unknown task: {}", request.task_name)),
239                            });
240                        }
241                    };
242
243                    let context: cloacina_workflow::Context<::serde_json::Value> =
244                        match cloacina_workflow::Context::from_json(request.context_json) {
245                            Ok(c) => c,
246                            Err(e) => {
247                                return Err($crate::PluginError {
248                                    code: "CONTEXT_ERROR".to_string(),
249                                    message: format!("Failed to parse context: {}", e),
250                                    details: None,
251                                });
252                            }
253                        };
254
255                    let result = rt.block_on(async move {
256                        cloacina_workflow::Task::execute(&*task, context).await
257                    });
258
259                    match result {
260                        Ok(updated) => {
261                            let ctx_json = updated.to_json().map_err(|e| $crate::PluginError {
262                                code: "SERIALIZATION_ERROR".to_string(),
263                                message: format!("Failed to serialize context: {}", e),
264                                details: None,
265                            })?;
266                            Ok($crate::TaskExecutionResult {
267                                success: true,
268                                context_json: Some(ctx_json),
269                                error: None,
270                            })
271                        }
272                        Err(e) => Ok($crate::TaskExecutionResult {
273                            success: false,
274                            context_json: None,
275                            error: Some(format!("Task '{}' failed: {:?}", request.task_name, e)),
276                        }),
277                    }
278                }
279
280                fn get_graph_metadata(
281                    &self,
282                ) -> ::core::result::Result<$crate::GraphPackageMetadata, $crate::PluginError>
283                {
284                    let entries: ::std::vec::Vec<&$crate::ComputationGraphEntry> =
285                        $crate::inventory::iter::<$crate::ComputationGraphEntry>
286                            .into_iter()
287                            .collect();
288                    if entries.is_empty() {
289                        return Err($crate::PluginError {
290                            code: "NOT_SUPPORTED".to_string(),
291                            message: "Package declares no computation graph".to_string(),
292                            details: None,
293                        });
294                    }
295                    if entries.len() > 1 {
296                        return Err($crate::PluginError {
297                            code: "MULTIPLE_GRAPHS".to_string(),
298                            message: format!(
299                                "Package declares {} computation graphs; the unified shell \
300                                 supports at most one CG per cdylib",
301                                entries.len()
302                            ),
303                            details: None,
304                        });
305                    }
306                    let reg = (entries[0].constructor)();
307                    let accumulators: ::std::vec::Vec<$crate::AccumulatorDeclarationEntry> = reg
308                        .accumulator_names
309                        .iter()
310                        .map(|name| $crate::AccumulatorDeclarationEntry {
311                            name: name.clone(),
312                            accumulator_type: "passthrough".to_string(),
313                            config: ::std::collections::HashMap::new(),
314                        })
315                        .collect();
316                    Ok($crate::GraphPackageMetadata {
317                        graph_name: entries[0].name.to_string(),
318                        package_name: env!("CARGO_PKG_NAME").to_string(),
319                        reaction_mode: reg.reaction_mode.clone(),
320                        input_strategy: "latest".to_string(),
321                        accumulators,
322                        trigger_reactor: reg.trigger_reactor.clone(),
323                    })
324                }
325
326                fn execute_graph(
327                    &self,
328                    request: $crate::GraphExecutionRequest,
329                ) -> ::core::result::Result<$crate::GraphExecutionResult, $crate::PluginError>
330                {
331                    static CDYLIB_RUNTIME: ::std::sync::OnceLock<
332                        cloacina_workflow::__private::tokio::runtime::Runtime,
333                    > = ::std::sync::OnceLock::new();
334                    let rt = CDYLIB_RUNTIME.get_or_init(|| {
335                        cloacina_workflow::__private::tokio::runtime::Builder::new_multi_thread()
336                            .enable_all()
337                            .worker_threads(2)
338                            .thread_name("package-shell-cg-worker")
339                            .build()
340                            .expect("Failed to create cdylib tokio runtime for computation graph")
341                    });
342
343                    let entry_opt = $crate::inventory::iter::<$crate::ComputationGraphEntry>
344                        .into_iter()
345                        .next();
346                    let entry = match entry_opt {
347                        Some(e) => e,
348                        None => {
349                            return Err($crate::PluginError {
350                                code: "NOT_SUPPORTED".to_string(),
351                                message: "Package declares no computation graph".to_string(),
352                                details: None,
353                            });
354                        }
355                    };
356                    let reg = (entry.constructor)();
357
358                    let mut cache = cloacina_computation_graph::InputCache::new();
359                    for (source_name, json_str) in &request.cache {
360                        let value: ::serde_json::Value =
361                            ::serde_json::from_str(json_str).map_err(|e| $crate::PluginError {
362                                code: "DESERIALIZATION_ERROR".to_string(),
363                                message: format!(
364                                    "Failed to parse cache entry '{}': {}",
365                                    source_name, e
366                                ),
367                                details: None,
368                            })?;
369                        let bytes = cloacina_computation_graph::serialize(&value).map_err(|e| {
370                            $crate::PluginError {
371                                code: "SERIALIZATION_ERROR".to_string(),
372                                message: format!(
373                                    "Failed to serialize cache entry '{}': {}",
374                                    source_name, e
375                                ),
376                                details: None,
377                            }
378                        })?;
379                        cache.update(
380                            cloacina_computation_graph::SourceName::new(source_name),
381                            bytes,
382                        );
383                    }
384
385                    let result = rt.block_on(async { (reg.graph_fn)(cache).await });
386
387                    match result {
388                        cloacina_computation_graph::GraphResult::Completed { outputs } => {
389                            let terminal_json: ::std::vec::Vec<::std::string::String> = outputs
390                                .iter()
391                                .filter_map(|o| {
392                                    o.downcast_ref::<::serde_json::Value>()
393                                        .map(|v| ::serde_json::to_string(v).unwrap_or_default())
394                                })
395                                .collect();
396                            Ok($crate::GraphExecutionResult {
397                                success: true,
398                                terminal_outputs_json: if terminal_json.is_empty() {
399                                    None
400                                } else {
401                                    Some(terminal_json)
402                                },
403                                error: None,
404                            })
405                        }
406                        cloacina_computation_graph::GraphResult::Error(e) => {
407                            Ok($crate::GraphExecutionResult {
408                                success: false,
409                                terminal_outputs_json: None,
410                                error: Some(format!("{}", e)),
411                            })
412                        }
413                    }
414                }
415
416                fn get_reactor_metadata(
417                    &self,
418                ) -> ::core::result::Result<
419                    ::std::vec::Vec<$crate::ReactorPackageMetadata>,
420                    $crate::PluginError,
421                > {
422                    let mut out: ::std::vec::Vec<$crate::ReactorPackageMetadata> =
423                        ::std::vec::Vec::new();
424                    for entry in $crate::inventory::iter::<$crate::ReactorEntry> {
425                        let reg = (entry.constructor)();
426                        let accumulators: ::std::vec::Vec<$crate::AccumulatorDeclarationEntry> =
427                            reg.accumulator_names
428                                .iter()
429                                .map(|name| $crate::AccumulatorDeclarationEntry {
430                                    name: name.clone(),
431                                    accumulator_type: "passthrough".to_string(),
432                                    config: ::std::collections::HashMap::new(),
433                                })
434                                .collect();
435                        out.push($crate::ReactorPackageMetadata {
436                            name: reg.name,
437                            package_name: env!("CARGO_PKG_NAME").to_string(),
438                            reaction_mode: reg.reaction_mode.as_str().to_string(),
439                            accumulators,
440                        });
441                    }
442                    Ok(out)
443                }
444
445                fn get_trigger_metadata(
446                    &self,
447                ) -> ::core::result::Result<
448                    ::std::vec::Vec<$crate::TriggerPackageMetadata>,
449                    $crate::PluginError,
450                > {
451                    let mut out: ::std::vec::Vec<$crate::TriggerPackageMetadata> =
452                        ::std::vec::Vec::new();
453                    for entry in $crate::inventory::iter::<$crate::TriggerEntry> {
454                        let trigger = (entry.constructor)();
455                        let poll_interval = format!(
456                            "{}ms",
457                            cloacina_workflow::Trigger::poll_interval(&*trigger).as_millis()
458                        );
459                        out.push($crate::TriggerPackageMetadata {
460                            name: entry.name.to_string(),
461                            package_name: env!("CARGO_PKG_NAME").to_string(),
462                            poll_interval,
463                            cron_expression: cloacina_workflow::Trigger::cron_expression(&*trigger),
464                            allow_concurrent: cloacina_workflow::Trigger::allow_concurrent(
465                                &*trigger,
466                            ),
467                        });
468                    }
469                    Ok(out)
470                }
471
472                fn invoke_trigger_poll(
473                    &self,
474                    request: $crate::TriggerInvokeRequest,
475                ) -> ::core::result::Result<$crate::TriggerInvokeResult, $crate::PluginError>
476                {
477                    static CDYLIB_TRIGGER_RUNTIME: ::std::sync::OnceLock<
478                        cloacina_workflow::__private::tokio::runtime::Runtime,
479                    > = ::std::sync::OnceLock::new();
480                    let rt = CDYLIB_TRIGGER_RUNTIME.get_or_init(|| {
481                        cloacina_workflow::__private::tokio::runtime::Builder::new_multi_thread()
482                            .enable_all()
483                            .worker_threads(2)
484                            .thread_name("package-shell-trigger-worker")
485                            .build()
486                            .expect("Failed to create cdylib trigger tokio runtime")
487                    });
488
489                    let trigger_arc_opt = $crate::inventory::iter::<$crate::TriggerEntry>
490                        .into_iter()
491                        .find(|entry| entry.name == request.trigger_name)
492                        .map(|entry| (entry.constructor)());
493
494                    let trigger = match trigger_arc_opt {
495                        Some(t) => t,
496                        None => {
497                            return Ok($crate::TriggerInvokeResult {
498                                fire: false,
499                                context_json: None,
500                                error: Some(format!("Unknown trigger: {}", request.trigger_name)),
501                            });
502                        }
503                    };
504
505                    let poll_result = rt
506                        .block_on(async move { cloacina_workflow::Trigger::poll(&*trigger).await });
507
508                    match poll_result {
509                        Ok(cloacina_workflow::TriggerResult::Skip) => {
510                            Ok($crate::TriggerInvokeResult {
511                                fire: false,
512                                context_json: None,
513                                error: None,
514                            })
515                        }
516                        Ok(cloacina_workflow::TriggerResult::Fire(None)) => {
517                            Ok($crate::TriggerInvokeResult {
518                                fire: true,
519                                context_json: None,
520                                error: None,
521                            })
522                        }
523                        Ok(cloacina_workflow::TriggerResult::Fire(Some(ctx))) => {
524                            match ctx.to_json() {
525                                Ok(ctx_json) => Ok($crate::TriggerInvokeResult {
526                                    fire: true,
527                                    context_json: Some(ctx_json),
528                                    error: None,
529                                }),
530                                Err(e) => Err($crate::PluginError {
531                                    code: "SERIALIZATION_ERROR".to_string(),
532                                    message: format!("Failed to serialize trigger context: {}", e),
533                                    details: None,
534                                }),
535                            }
536                        }
537                        Err(e) => Ok($crate::TriggerInvokeResult {
538                            fire: false,
539                            context_json: None,
540                            error: Some(format!(
541                                "Trigger '{}' poll failed: {:?}",
542                                request.trigger_name, e
543                            )),
544                        }),
545                    }
546                }
547
548                fn get_triggerless_graph_metadata(
549                    &self,
550                ) -> ::core::result::Result<
551                    ::std::vec::Vec<$crate::TriggerlessGraphMetadataEntry>,
552                    $crate::PluginError,
553                > {
554                    let mut out: ::std::vec::Vec<$crate::TriggerlessGraphMetadataEntry> =
555                        ::std::vec::Vec::new();
556                    for entry in $crate::inventory::iter::<$crate::TriggerlessGraphEntry> {
557                        let reg = (entry.constructor)();
558                        out.push($crate::TriggerlessGraphMetadataEntry {
559                            name: entry.name.to_string(),
560                            package_name: env!("CARGO_PKG_NAME").to_string(),
561                            terminal_node_names: reg.terminal_node_names.clone(),
562                        });
563                    }
564                    Ok(out)
565                }
566
567                fn invoke_triggerless_graph(
568                    &self,
569                    request: $crate::TriggerlessGraphInvokeRequest,
570                ) -> ::core::result::Result<
571                    $crate::TriggerlessGraphInvokeResult,
572                    $crate::PluginError,
573                > {
574                    static CDYLIB_TLCG_RUNTIME: ::std::sync::OnceLock<
575                        cloacina_workflow::__private::tokio::runtime::Runtime,
576                    > = ::std::sync::OnceLock::new();
577                    let rt = CDYLIB_TLCG_RUNTIME.get_or_init(|| {
578                        cloacina_workflow::__private::tokio::runtime::Builder::new_multi_thread()
579                            .enable_all()
580                            .worker_threads(2)
581                            .thread_name("package-shell-tlcg-worker")
582                            .build()
583                            .expect("Failed to create cdylib trigger-less CG tokio runtime")
584                    });
585
586                    let entry_opt = $crate::inventory::iter::<$crate::TriggerlessGraphEntry>
587                        .into_iter()
588                        .find(|e| e.name == request.graph_name);
589                    let entry = match entry_opt {
590                        Some(e) => e,
591                        None => {
592                            return Ok($crate::TriggerlessGraphInvokeResult {
593                                success: false,
594                                terminal_outputs_json: None,
595                                error: Some(format!(
596                                    "Unknown trigger-less graph: {}",
597                                    request.graph_name
598                                )),
599                            });
600                        }
601                    };
602
603                    let context: cloacina_workflow::Context<::serde_json::Value> =
604                        match cloacina_workflow::Context::from_json(request.context_json) {
605                            Ok(c) => c,
606                            Err(e) => {
607                                return Err($crate::PluginError {
608                                    code: "CONTEXT_ERROR".to_string(),
609                                    message: format!(
610                                        "Failed to parse context for graph '{}': {}",
611                                        request.graph_name, e
612                                    ),
613                                    details: None,
614                                });
615                            }
616                        };
617
618                    let reg = (entry.constructor)();
619                    let graph_fn = reg.graph_fn.clone();
620                    let terminal_names = reg.terminal_node_names.clone();
621                    let result = rt.block_on(async move { graph_fn(context).await });
622
623                    match result {
624                        ::cloacina_computation_graph::GraphResult::Completed { outputs } => {
625                            let mut json_outputs: ::std::vec::Vec<::serde_json::Value> =
626                                ::std::vec::Vec::with_capacity(outputs.len());
627                            for boxed in outputs.iter() {
628                                if let Some(value) = boxed.downcast_ref::<::serde_json::Value>() {
629                                    json_outputs.push(value.clone());
630                                } else {
631                                    json_outputs.push(::serde_json::Value::Null);
632                                }
633                            }
634                            while json_outputs.len() < terminal_names.len() {
635                                json_outputs.push(::serde_json::Value::Null);
636                            }
637                            let outputs_json = match ::serde_json::to_string(&json_outputs) {
638                                Ok(s) => s,
639                                Err(e) => {
640                                    return Err($crate::PluginError {
641                                        code: "SERIALIZATION_ERROR".to_string(),
642                                        message: format!(
643                                            "Failed to serialize terminal outputs: {}",
644                                            e
645                                        ),
646                                        details: None,
647                                    });
648                                }
649                            };
650                            Ok($crate::TriggerlessGraphInvokeResult {
651                                success: true,
652                                terminal_outputs_json: Some(outputs_json),
653                                error: None,
654                            })
655                        }
656                        ::cloacina_computation_graph::GraphResult::Error(err) => {
657                            Ok($crate::TriggerlessGraphInvokeResult {
658                                success: false,
659                                terminal_outputs_json: None,
660                                error: Some(format!(
661                                    "Graph '{}' failed: {}",
662                                    request.graph_name, err
663                                )),
664                            })
665                        }
666                    }
667                }
668            }
669
670            $crate::fidius_plugin_registry!();
671        }
672    };
673}
674
675/// Method index constants for the `CloacinaPlugin` trait. The fidius
676/// plugin ABI dispatches by positional method index — call sites on the
677/// host (and any out-of-tree consumers) must match the order in which
678/// methods are declared on the trait below. Defining them here in the
679/// canonical interface crate keeps host and plugin in lockstep: a
680/// reorder of trait methods is impossible without also re-numbering
681/// these constants.
682pub const METHOD_GET_TASK_METADATA: usize = 0;
683/// See [`METHOD_GET_TASK_METADATA`].
684pub const METHOD_EXECUTE_TASK: usize = 1;
685/// See [`METHOD_GET_TASK_METADATA`].
686pub const METHOD_GET_GRAPH_METADATA: usize = 2;
687/// See [`METHOD_GET_TASK_METADATA`].
688pub const METHOD_EXECUTE_GRAPH: usize = 3;
689/// See [`METHOD_GET_TASK_METADATA`].
690pub const METHOD_GET_REACTOR_METADATA: usize = 4;
691/// See [`METHOD_GET_TASK_METADATA`].
692pub const METHOD_GET_TRIGGER_METADATA: usize = 5;
693/// See [`METHOD_GET_TASK_METADATA`].
694pub const METHOD_INVOKE_TRIGGER_POLL: usize = 6;
695/// See [`METHOD_GET_TASK_METADATA`].
696pub const METHOD_GET_TRIGGERLESS_GRAPH_METADATA: usize = 7;
697/// See [`METHOD_GET_TASK_METADATA`].
698pub const METHOD_INVOKE_TRIGGERLESS_GRAPH: usize = 8;
699
700/// The plugin interface for cloacina workflow packages.
701///
702/// Every packaged workflow implements this trait (via `#[plugin_impl]` generated
703/// by the `#[workflow]` macro). The host calls these methods through a fidius
704/// `PluginHandle` — never directly.
705///
706/// ## Methods
707///
708/// - `get_task_metadata` — Returns metadata about all tasks in the workflow
709///   (IDs, dependencies, descriptions). Called once at registration time.
710/// - `execute_task` — Runs a specific task by name with a JSON-serialized
711///   context. Returns the updated context or an error.
712#[fidius::plugin_interface(version = 2, buffer = PluginAllocated)]
713pub trait CloacinaPlugin: Send + Sync {
714    /// Returns metadata about all tasks in this workflow package.
715    /// Method index 0.
716    fn get_task_metadata(&self) -> Result<PackageTasksMetadata, PluginError>;
717
718    /// Executes a task by name with the given context.
719    /// Method index 1.
720    fn execute_task(
721        &self,
722        request: TaskExecutionRequest,
723    ) -> Result<TaskExecutionResult, PluginError>;
724
725    /// Returns metadata about the computation graph in this package.
726    /// Method index 2. Only called when the package declares a CG.
727    /// Packages without a graph return an error or NotImplemented.
728    fn get_graph_metadata(&self) -> Result<GraphPackageMetadata, PluginError>;
729
730    /// Executes the computation graph with the given cache state.
731    /// Method index 3. Only called when the package declares a CG.
732    /// Packages without a graph return an error.
733    fn execute_graph(
734        &self,
735        request: GraphExecutionRequest,
736    ) -> Result<GraphExecutionResult, PluginError>;
737
738    /// Returns metadata about all reactors declared by this package.
739    /// Method index 4. Optional (since version 2): plugins built against
740    /// version-1 hosts return `CallError::NotImplemented`, which the
741    /// reconciler treats as "package declares no reactors". Packages built
742    /// from the unified `cloacina::package!()` shell walk their local
743    /// `inventory::iter::<ReactorEntry>` and project each entry into a
744    /// `ReactorPackageMetadata` value. (T-A — I-0102)
745    #[optional(since = 2)]
746    fn get_reactor_metadata(&self) -> Result<Vec<ReactorPackageMetadata>, PluginError>;
747
748    /// Returns metadata about all triggers declared by this package.
749    /// Method index 5. Optional (since version 2): same NotImplemented
750    /// fallback as `get_reactor_metadata`. The unified `cloacina::package!()`
751    /// shell walks `inventory::iter::<TriggerEntry>`, calls each entry's
752    /// constructor, and queries `poll_interval()` / `cron_expression()` /
753    /// `allow_concurrent()` on the resulting `Arc<dyn Trigger>`. The
754    /// reconciler routes cron-shaped entries (cron_expression present) to
755    /// the cron scheduler and the rest to the runtime trigger registry.
756    /// (T-A — I-0102)
757    #[optional(since = 2)]
758    fn get_trigger_metadata(&self) -> Result<Vec<TriggerPackageMetadata>, PluginError>;
759
760    /// Polls a named trigger across the FFI boundary and returns a wire-
761    /// format `TriggerInvokeResult` describing whether to fire the
762    /// associated workflow. Method index 6. Optional (since version 2):
763    /// the host's `FfiTriggerImpl` adapter calls this on every scheduled
764    /// poll for triggers that came from a packaged cdylib (where
765    /// `inventory` doesn't span linker boundaries, so the host can't
766    /// build a host-side `Arc<dyn Trigger>` directly). Plugins built
767    /// from the unified `cloacina::package!()` shell walk
768    /// `inventory::iter::<TriggerEntry>` for the matching name, call
769    /// the constructor, and dispatch `Trigger::poll()` through the
770    /// shared cdylib tokio runtime.
771    #[optional(since = 2)]
772    fn invoke_trigger_poll(
773        &self,
774        request: TriggerInvokeRequest,
775    ) -> Result<TriggerInvokeResult, PluginError>;
776
777    /// Returns metadata about every trigger-less computation graph
778    /// declared by this package. Method index 7. Optional (since
779    /// version 2): plugins built before T-0553's follow-up gap-close
780    /// return `CallError::NotImplemented`, which the reconciler treats
781    /// as "package declares no trigger-less graphs". The unified
782    /// `cloacina::package!()` shell walks
783    /// `inventory::iter::<TriggerlessGraphEntry>` and projects each
784    /// entry's name + terminal_node_names into a
785    /// `TriggerlessGraphMetadataEntry`. Used by
786    /// `step_load_triggerless_cgs` to register host-side
787    /// `TriggerlessGraphRegistration` adapters that dispatch
788    /// invocation through `invoke_triggerless_graph` (method index 8).
789    #[optional(since = 2)]
790    fn get_triggerless_graph_metadata(
791        &self,
792    ) -> Result<Vec<TriggerlessGraphMetadataEntry>, PluginError>;
793
794    /// Invokes a named trigger-less computation graph across the FFI
795    /// boundary and returns a wire-format result. Method index 8.
796    /// Optional (since version 2): the host's `FfiTriggerlessGraph`
797    /// adapter calls this on every workflow-task invocation for graphs
798    /// that came from a packaged cdylib. The shell walks
799    /// `inventory::iter::<TriggerlessGraphEntry>` for the matching
800    /// name, calls the constructor, and dispatches `graph_fn(ctx)` on
801    /// the cdylib's shared tokio runtime.
802    #[optional(since = 2)]
803    fn invoke_triggerless_graph(
804        &self,
805        request: TriggerlessGraphInvokeRequest,
806    ) -> Result<TriggerlessGraphInvokeResult, PluginError>;
807}