runtara_workflow_stdlib/
instance_output.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Instance output handling for workflow binaries.
4//!
5//! Workflows communicate their exit state via output.json file.
6//! Environment reads this file to determine the next action (scheduling wake, marking completed, etc.).
7//!
8//! The instance run directory is mounted at `/data` by the OCI runner.
9//! Output path: /data/output.json
10
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14/// Instance output status.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum InstanceOutputStatus {
18    /// Instance completed successfully
19    Completed,
20    /// Instance failed with an error
21    Failed,
22    /// Instance suspended (paused via signal)
23    Suspended,
24    /// Instance is sleeping (durable sleep requested)
25    Sleeping,
26    /// Instance was cancelled
27    Cancelled,
28}
29
30/// Instance output written to output.json on exit.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct InstanceOutput {
33    /// The status/reason for exit
34    pub status: InstanceOutputStatus,
35
36    /// Result data (for completed status)
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub result: Option<serde_json::Value>,
39
40    /// Error message (for failed status)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub error: Option<String>,
43
44    /// Checkpoint ID to resume from (for suspended/sleeping status)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub checkpoint_id: Option<String>,
47
48    /// Wake delay in milliseconds (for sleeping status)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub wake_after_ms: Option<u64>,
51}
52
53impl InstanceOutput {
54    /// Create a completed output.
55    pub fn completed(result: serde_json::Value) -> Self {
56        Self {
57            status: InstanceOutputStatus::Completed,
58            result: Some(result),
59            error: None,
60            checkpoint_id: None,
61            wake_after_ms: None,
62        }
63    }
64
65    /// Create a failed output.
66    pub fn failed(error: impl Into<String>) -> Self {
67        Self {
68            status: InstanceOutputStatus::Failed,
69            result: None,
70            error: Some(error.into()),
71            checkpoint_id: None,
72            wake_after_ms: None,
73        }
74    }
75
76    /// Create a suspended output.
77    pub fn suspended(checkpoint_id: impl Into<String>) -> Self {
78        Self {
79            status: InstanceOutputStatus::Suspended,
80            result: None,
81            error: None,
82            checkpoint_id: Some(checkpoint_id.into()),
83            wake_after_ms: None,
84        }
85    }
86
87    /// Create a sleeping output.
88    pub fn sleeping(checkpoint_id: impl Into<String>, wake_after_ms: u64) -> Self {
89        Self {
90            status: InstanceOutputStatus::Sleeping,
91            result: None,
92            error: None,
93            checkpoint_id: Some(checkpoint_id.into()),
94            wake_after_ms: Some(wake_after_ms),
95        }
96    }
97
98    /// Create a cancelled output.
99    pub fn cancelled() -> Self {
100        Self {
101            status: InstanceOutputStatus::Cancelled,
102            result: None,
103            error: None,
104            checkpoint_id: None,
105            wake_after_ms: None,
106        }
107    }
108
109    /// Write instance output to the standard output file location.
110    ///
111    /// Path is determined from environment variables:
112    /// - DATA_DIR (defaults to ".")
113    /// - RUNTARA_TENANT_ID
114    /// - RUNTARA_INSTANCE_ID
115    pub fn write_to_output_file(&self) -> std::io::Result<()> {
116        let path = get_output_file_path();
117        self.write_to_file(&path)
118    }
119
120    /// Write instance output to a specific file path.
121    pub fn write_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
122        let content = serde_json::to_string_pretty(self)?;
123        if let Some(parent) = path.parent() {
124            std::fs::create_dir_all(parent)?;
125        }
126        std::fs::write(path, content)
127    }
128}
129
130/// Get the output file path for the current instance.
131///
132/// The instance run directory is mounted at `/data` by the OCI runner.
133pub fn get_output_file_path() -> PathBuf {
134    PathBuf::from("/data/output.json")
135}
136
137/// Convenience function to write completed output.
138pub fn write_completed(result: serde_json::Value) -> std::io::Result<()> {
139    InstanceOutput::completed(result).write_to_output_file()
140}
141
142/// Convenience function to write failed output.
143pub fn write_failed(error: impl Into<String>) -> std::io::Result<()> {
144    InstanceOutput::failed(error).write_to_output_file()
145}
146
147/// Convenience function to write suspended output.
148pub fn write_suspended(checkpoint_id: impl Into<String>) -> std::io::Result<()> {
149    InstanceOutput::suspended(checkpoint_id).write_to_output_file()
150}
151
152/// Convenience function to write sleeping output.
153pub fn write_sleeping(checkpoint_id: impl Into<String>, wake_after_ms: u64) -> std::io::Result<()> {
154    InstanceOutput::sleeping(checkpoint_id, wake_after_ms).write_to_output_file()
155}
156
157/// Convenience function to write cancelled output.
158pub fn write_cancelled() -> std::io::Result<()> {
159    InstanceOutput::cancelled().write_to_output_file()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_serialize_completed() {
168        let output = InstanceOutput::completed(serde_json::json!({"key": "value"}));
169        let json = serde_json::to_string(&output).unwrap();
170        assert!(json.contains("\"status\":\"completed\""));
171        assert!(json.contains("\"result\""));
172    }
173
174    #[test]
175    fn test_serialize_sleeping() {
176        let output = InstanceOutput::sleeping("checkpoint-1", 3600000);
177        let json = serde_json::to_string(&output).unwrap();
178        assert!(json.contains("\"status\":\"sleeping\""));
179        assert!(json.contains("\"checkpoint_id\":\"checkpoint-1\""));
180        assert!(json.contains("\"wake_after_ms\":3600000"));
181    }
182
183    #[test]
184    fn test_serialize_cancelled() {
185        let output = InstanceOutput::cancelled();
186        let json = serde_json::to_string(&output).unwrap();
187        assert!(json.contains("\"status\":\"cancelled\""));
188    }
189}